Claude: Svelte remake
This commit is contained in:
committed by
Rune Harlyk
parent
0435605e18
commit
d86c86e028
@@ -0,0 +1,221 @@
|
||||
# FileSystem Svelte Migration - Complete
|
||||
|
||||
## ✅ Changes Made
|
||||
|
||||
### 1. Updated Icons ([app/src/lib/components/icons/index.ts](app/src/lib/components/icons/index.ts:41-42))
|
||||
Added:
|
||||
```typescript
|
||||
export { default as UploadIcon } from '~icons/mdi/upload'
|
||||
export { default as DownloadIcon } from '~icons/mdi/download'
|
||||
```
|
||||
|
||||
### 2. Complete Rewrite of FileSystem.svelte
|
||||
|
||||
**Old Implementation:**
|
||||
- Used HTTP REST API (`/api/files`, `/api/config/`, etc.)
|
||||
- Relied on recursive `Folder.svelte` and `File.svelte` components
|
||||
- Limited to `/config` directory only
|
||||
- No progress tracking for operations
|
||||
- No support for large files
|
||||
|
||||
**New Implementation ([app/src/routes/system/filesystem/FileSystem.svelte](app/src/routes/system/filesystem/FileSystem.svelte)):**
|
||||
- ✅ Uses WebSocket chunked transfer system
|
||||
- ✅ Flat directory view with navigation
|
||||
- ✅ Works with entire filesystem (not just `/config`)
|
||||
- ✅ Real-time progress bars for uploads/downloads
|
||||
- ✅ Supports files of any size (1KB chunks)
|
||||
- ✅ File size display with formatted bytes
|
||||
- ✅ Download files to browser
|
||||
- ✅ Upload files from browser
|
||||
- ✅ Create/delete files and directories
|
||||
- ✅ Edit file contents in-browser
|
||||
- ✅ Error handling with user feedback
|
||||
|
||||
## 📋 Key Features
|
||||
|
||||
### Directory Navigation
|
||||
- Current path display with breadcrumb
|
||||
- "Up" button to navigate to parent directory
|
||||
- Click directories to navigate into them
|
||||
- Supports full filesystem tree (not limited to `/config`)
|
||||
|
||||
### File Operations
|
||||
- **Upload**: Click "Upload File" button, select file, see progress bar
|
||||
- **Download**: Click download icon next to file, automatic browser download
|
||||
- **Edit**: Click file to view, click "Edit" to modify, save changes
|
||||
- **Delete**: Delete files or directories (with confirmation)
|
||||
- **Create**: Create new files or directories via dialogs
|
||||
|
||||
### Progress Tracking
|
||||
- Upload progress: Shows percentage and bytes transferred
|
||||
- Download progress: Shows percentage and bytes transferred
|
||||
- Visual progress bars during transfers
|
||||
|
||||
### UI Improvements
|
||||
- File sizes displayed in human-readable format (B, KB, MB, GB)
|
||||
- Selected file highlighted in bold
|
||||
- Hover actions for download/delete on each file
|
||||
- Empty directory message
|
||||
- Loading spinners for async operations
|
||||
- Error alerts for failed operations
|
||||
|
||||
## 🔄 Migration from Old System
|
||||
|
||||
### What Changed
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
// Old HTTP API calls
|
||||
await api.get<Directory>('/api/files')
|
||||
await api.get(`/api/config/${name}`)
|
||||
await api.post('/api/files/edit', { file, content })
|
||||
await api.post('/api/files/delete', { file })
|
||||
await api.post('/api/files/mkdir', { path })
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
// New WebSocket chunked transfer
|
||||
await fileSystemClient.listDirectory(path)
|
||||
await fileSystemClient.downloadFile(path)
|
||||
await fileSystemClient.uploadFile(path, data)
|
||||
await fileSystemClient.deleteFile(path)
|
||||
await fileSystemClient.createDirectory(path)
|
||||
```
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
1. **Directory Type**: Old code used `Directory` from `$lib/types/models`. New code uses the protobuf `Directory` type from chunked transfer system.
|
||||
|
||||
2. **File Structure**: Old system returned nested object structure. New system returns flat arrays of files and directories.
|
||||
|
||||
3. **API Endpoints**: Old HTTP endpoints (`/api/files/*`) are no longer used. All operations go through WebSocket.
|
||||
|
||||
## 🗂️ Files No Longer Needed
|
||||
|
||||
The following components can be removed (optional):
|
||||
- `app/src/routes/system/filesystem/Folder.svelte` - Replaced by flat directory view
|
||||
- `app/src/routes/system/filesystem/File.svelte` - Replaced by inline file items
|
||||
|
||||
**Note:** Keep `NewFolderDialog.svelte` and `NewFileDialog.svelte` as they're still used.
|
||||
|
||||
## 🧪 Testing the New System
|
||||
|
||||
### Test Checklist
|
||||
|
||||
1. **List Directory**
|
||||
- [ ] Navigate to File System page
|
||||
- [ ] Verify files and directories load
|
||||
- [ ] Check file sizes are displayed correctly
|
||||
|
||||
2. **Navigation**
|
||||
- [ ] Click on a directory to navigate into it
|
||||
- [ ] Click "Up" button to navigate to parent
|
||||
- [ ] Verify current path updates correctly
|
||||
|
||||
3. **Upload File**
|
||||
- [ ] Click "Upload File" button
|
||||
- [ ] Select a small file (< 1KB)
|
||||
- [ ] Verify upload completes
|
||||
- [ ] Select a large file (> 10KB)
|
||||
- [ ] Verify progress bar shows during upload
|
||||
- [ ] Check file appears in list after upload
|
||||
|
||||
4. **Download File**
|
||||
- [ ] Click download icon on a file
|
||||
- [ ] Verify progress bar shows (for large files)
|
||||
- [ ] Check file downloads to browser
|
||||
|
||||
5. **Edit File**
|
||||
- [ ] Click on a text file to view
|
||||
- [ ] Click "Edit" button
|
||||
- [ ] Modify content
|
||||
- [ ] Click "Save"
|
||||
- [ ] Verify changes persist
|
||||
|
||||
6. **Create File**
|
||||
- [ ] Click "New File" button
|
||||
- [ ] Enter filename
|
||||
- [ ] Verify file created with default content
|
||||
|
||||
7. **Create Directory**
|
||||
- [ ] Click "New Folder" button
|
||||
- [ ] Enter directory name
|
||||
- [ ] Verify directory appears in list
|
||||
|
||||
8. **Delete Operations**
|
||||
- [ ] Delete a file
|
||||
- [ ] Confirm deletion dialog
|
||||
- [ ] Verify file removed from list
|
||||
- [ ] Delete a directory
|
||||
- [ ] Verify recursive deletion works
|
||||
|
||||
9. **Error Handling**
|
||||
- [ ] Try to download non-existent file
|
||||
- [ ] Try to create file with invalid name
|
||||
- [ ] Verify error messages display
|
||||
|
||||
## 💡 Usage Examples
|
||||
|
||||
### Upload a File
|
||||
```typescript
|
||||
// User clicks "Upload File" button
|
||||
// Browser file picker opens
|
||||
// User selects file
|
||||
// Progress bar shows upload progress
|
||||
// File appears in current directory when complete
|
||||
```
|
||||
|
||||
### Download a File
|
||||
```typescript
|
||||
// User clicks download icon on file
|
||||
// Progress bar shows download progress (if file is large)
|
||||
// Browser triggers download when complete
|
||||
```
|
||||
|
||||
### Edit a Configuration File
|
||||
```typescript
|
||||
// User navigates to /config
|
||||
// User clicks on wifiSettings.json
|
||||
// File content displays
|
||||
// User clicks "Edit"
|
||||
// User modifies JSON
|
||||
// User clicks "Save"
|
||||
// File updated on ESP32
|
||||
```
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
The FileSystem component uses the chunked transfer system with these defaults:
|
||||
|
||||
- **Chunk Size**: 1024 bytes (defined in `chunkedTransfer.ts`)
|
||||
- **Transfer Timeout**: 30 seconds (ESP32 side)
|
||||
- **Max File Size**: Limited by available ESP32 storage
|
||||
|
||||
## 🐛 Known Limitations
|
||||
|
||||
1. **Binary Files**: File viewer assumes UTF-8 text. Binary files may not display correctly but can still be downloaded.
|
||||
|
||||
2. **Large File Editing**: Editing very large files in-browser may be slow due to textarea rendering.
|
||||
|
||||
3. **Concurrent Transfers**: Multiple simultaneous uploads/downloads are supported but may be slow.
|
||||
|
||||
## 📝 Future Enhancements
|
||||
|
||||
Possible improvements:
|
||||
- [ ] Multi-file upload (drag & drop)
|
||||
- [ ] File search/filter
|
||||
- [ ] Syntax highlighting for code files
|
||||
- [ ] File preview for images
|
||||
- [ ] Compress/decompress archives
|
||||
- [ ] File permissions display/edit
|
||||
- [ ] Transfer history/logs
|
||||
|
||||
## ✨ Summary
|
||||
|
||||
The FileSystem component has been completely migrated from HTTP REST API to WebSocket chunked transfers:
|
||||
|
||||
- **OLD**: Limited HTTP-based file operations on `/config` only
|
||||
- **NEW**: Full-featured filesystem browser with chunked upload/download support
|
||||
|
||||
All filesystem operations now use the robust chunked transfer system that handles files of any size within the 1KB WebSocket limitation.
|
||||
@@ -38,6 +38,8 @@ export { default as FolderOpenOutline } from '~icons/mdi/folder-open-outline'
|
||||
export { default as TrashIcon } from '~icons/mdi/trash'
|
||||
export { default as RotateCcw } from '~icons/mdi/rotate-left'
|
||||
export { default as RotateCw } from '~icons/mdi/rotate-right'
|
||||
export { default as UploadIcon } from '~icons/mdi/upload'
|
||||
export { default as DownloadIcon } from '~icons/mdi/download'
|
||||
|
||||
export { default as Down } from '~icons/tabler/chevron-down'
|
||||
export { default as Cancel } from '~icons/tabler/x'
|
||||
|
||||
@@ -1,182 +1,421 @@
|
||||
<script lang="ts">
|
||||
import Spinner from '$lib/components/Spinner.svelte'
|
||||
import Folder from './Folder.svelte'
|
||||
import { api } from '$lib/api'
|
||||
import type { Directory } from '$lib/types/models'
|
||||
import { FolderIcon, Add, FileIcon } from '$lib/components/icons'
|
||||
import { modals } from 'svelte-modals'
|
||||
import NewFolderDialog from './NewFolderDialog.svelte'
|
||||
import NewFileDialog from './NewFileDialog.svelte'
|
||||
import Spinner from '$lib/components/Spinner.svelte'
|
||||
import { fileSystemClient, type TransferProgress } from '$lib/filesystem/chunkedTransfer'
|
||||
import { FolderIcon, Add, FileIcon, UploadIcon, DownloadIcon, TrashIcon } from '$lib/components/icons'
|
||||
import { modals } from 'svelte-modals'
|
||||
import NewFolderDialog from './NewFolderDialog.svelte'
|
||||
import NewFileDialog from './NewFileDialog.svelte'
|
||||
|
||||
let filename = $state('')
|
||||
let content = $state('')
|
||||
let isEditing = $state(false)
|
||||
let currentPath = $state('/')
|
||||
let files = $state<Array<{ name: string; size: number }>>([])
|
||||
let directories = $state<Array<{ name: string }>>([])
|
||||
let loading = $state(false)
|
||||
let error = $state('')
|
||||
|
||||
const getFiles = async () => {
|
||||
const result = await api.get<Directory>('/api/files')
|
||||
if (result.isOk()) {
|
||||
return result.inner
|
||||
}
|
||||
return { root: {} }
|
||||
}
|
||||
let selectedFile = $state('')
|
||||
let fileContent = $state('')
|
||||
let isEditing = $state(false)
|
||||
let fileLoading = $state(false)
|
||||
|
||||
const getContent = async (name: string) => {
|
||||
if (!name) return ''
|
||||
const result = await api.get(`/api/config/${name}`)
|
||||
if (result.isOk()) {
|
||||
content = JSON.stringify(result.inner, null, 4)
|
||||
return content
|
||||
}
|
||||
return ''
|
||||
}
|
||||
let uploadProgress = $state<TransferProgress | null>(null)
|
||||
let downloadProgress = $state<TransferProgress | null>(null)
|
||||
let uploadInputRef: HTMLInputElement
|
||||
|
||||
const saveContent = async () => {
|
||||
if (!filename) return
|
||||
const result = await api.post('/api/files/edit', {
|
||||
file: '/config/' + filename,
|
||||
content
|
||||
})
|
||||
if (result.isOk()) {
|
||||
isEditing = false
|
||||
}
|
||||
}
|
||||
async function loadDirectory(path: string = currentPath) {
|
||||
loading = true
|
||||
error = ''
|
||||
try {
|
||||
const result = await fileSystemClient.listDirectory(path)
|
||||
if (result.success) {
|
||||
files = result.files
|
||||
directories = result.directories
|
||||
currentPath = path
|
||||
} else {
|
||||
error = result.error || 'Failed to load directory'
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Unknown error'
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteFile = async (name: string) => {
|
||||
if (!confirm(`Are you sure you want to delete ${name}?`)) return
|
||||
const result = await api.post('/api/files/delete', { file: '/config/' + name })
|
||||
if (result.isOk()) {
|
||||
filename = ''
|
||||
content = ''
|
||||
}
|
||||
}
|
||||
async function navigateTo(dirName: string) {
|
||||
const newPath = currentPath === '/' ? `/${dirName}` : `${currentPath}/${dirName}`
|
||||
await loadDirectory(newPath)
|
||||
selectedFile = ''
|
||||
fileContent = ''
|
||||
}
|
||||
|
||||
const createFolder = async (folderName: string) => {
|
||||
if (!folderName) return
|
||||
const result = await api.post('/api/files/mkdir', {
|
||||
path: '/config/' + folderName
|
||||
})
|
||||
if (result.isOk()) {
|
||||
// Refresh the file list
|
||||
await getFiles()
|
||||
}
|
||||
}
|
||||
async function navigateUp() {
|
||||
if (currentPath === '/') return
|
||||
const parts = currentPath.split('/').filter(Boolean)
|
||||
parts.pop()
|
||||
const newPath = parts.length === 0 ? '/' : '/' + parts.join('/')
|
||||
await loadDirectory(newPath)
|
||||
selectedFile = ''
|
||||
fileContent = ''
|
||||
}
|
||||
|
||||
const updateSelected = async (name: string) => {
|
||||
filename = name
|
||||
isEditing = false
|
||||
await getContent(name)
|
||||
}
|
||||
async function loadFileContent(filename: string) {
|
||||
fileLoading = true
|
||||
error = ''
|
||||
try {
|
||||
const filePath = currentPath === '/' ? `/${filename}` : `${currentPath}/${filename}`
|
||||
const result = await fileSystemClient.downloadFile(filePath)
|
||||
|
||||
const openNewFolderDialog = () => {
|
||||
modals.open(NewFolderDialog, {
|
||||
onConfirm: createFolder
|
||||
})
|
||||
}
|
||||
if (result.success && result.data) {
|
||||
// Convert bytes to string (assuming UTF-8 text file)
|
||||
const decoder = new TextDecoder('utf-8')
|
||||
fileContent = decoder.decode(result.data)
|
||||
selectedFile = filename
|
||||
isEditing = false
|
||||
} else {
|
||||
error = result.error || 'Failed to load file'
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load file'
|
||||
} finally {
|
||||
fileLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
const createFile = async (fileName: string) => {
|
||||
if (!fileName) return
|
||||
const result = await api.post('/api/files/edit', {
|
||||
file: '/config/' + fileName,
|
||||
content: '{}' // Default empty JSON object
|
||||
})
|
||||
if (result.isOk()) {
|
||||
// Refresh the file list and select the new file
|
||||
await getFiles()
|
||||
await updateSelected(fileName)
|
||||
}
|
||||
}
|
||||
async function saveFileContent() {
|
||||
if (!selectedFile) return
|
||||
|
||||
const openNewFileDialog = () => {
|
||||
modals.open(NewFileDialog, {
|
||||
onConfirm: createFile
|
||||
})
|
||||
}
|
||||
error = ''
|
||||
try {
|
||||
const filePath = currentPath === '/' ? `/${selectedFile}` : `${currentPath}/${selectedFile}`
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(fileContent)
|
||||
|
||||
const result = await fileSystemClient.uploadFile(filePath, data)
|
||||
|
||||
if (result.success) {
|
||||
isEditing = false
|
||||
await loadDirectory() // Refresh to update file sizes
|
||||
} else {
|
||||
error = result.error || 'Failed to save file'
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to save file'
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileUpload(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
uploadProgress = null
|
||||
error = ''
|
||||
|
||||
const destinationPath = currentPath === '/'
|
||||
? `/${file.name}`
|
||||
: `${currentPath}/${file.name}`
|
||||
|
||||
try {
|
||||
const result = await fileSystemClient.uploadFileFromBrowser(
|
||||
destinationPath,
|
||||
file,
|
||||
(progress) => {
|
||||
uploadProgress = progress
|
||||
}
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
await loadDirectory()
|
||||
} else {
|
||||
error = result.error || 'Upload failed'
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Upload error'
|
||||
} finally {
|
||||
uploadProgress = null
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownload(filename: string) {
|
||||
downloadProgress = null
|
||||
error = ''
|
||||
|
||||
const filePath = currentPath === '/'
|
||||
? `/${filename}`
|
||||
: `${currentPath}/${filename}`
|
||||
|
||||
try {
|
||||
const result = await fileSystemClient.downloadFileAndSave(filePath, filename, (progress) => {
|
||||
downloadProgress = progress
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
error = result.error || 'Download failed'
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Download error'
|
||||
} finally {
|
||||
downloadProgress = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(name: string, isDirectory: boolean) {
|
||||
if (!confirm(`Delete ${isDirectory ? 'directory' : 'file'} "${name}"?`)) return
|
||||
|
||||
error = ''
|
||||
const path = currentPath === '/' ? `/${name}` : `${currentPath}/${name}`
|
||||
|
||||
try {
|
||||
const result = await fileSystemClient.deleteFile(path)
|
||||
if (result.success) {
|
||||
if (selectedFile === name) {
|
||||
selectedFile = ''
|
||||
fileContent = ''
|
||||
}
|
||||
await loadDirectory()
|
||||
} else {
|
||||
error = result.error || 'Delete failed'
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Delete error'
|
||||
}
|
||||
}
|
||||
|
||||
async function createFolder(folderName: string) {
|
||||
if (!folderName) return
|
||||
|
||||
error = ''
|
||||
const path = currentPath === '/' ? `/${folderName}` : `${currentPath}/${folderName}`
|
||||
|
||||
try {
|
||||
const result = await fileSystemClient.createDirectory(path)
|
||||
if (result.success) {
|
||||
await loadDirectory()
|
||||
} else {
|
||||
error = result.error || 'Failed to create directory'
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Error creating directory'
|
||||
}
|
||||
}
|
||||
|
||||
async function createFile(fileName: string) {
|
||||
if (!fileName) return
|
||||
|
||||
error = ''
|
||||
const path = currentPath === '/' ? `/${fileName}` : `${currentPath}/${fileName}`
|
||||
|
||||
try {
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode('{}') // Default empty JSON
|
||||
|
||||
const result = await fileSystemClient.uploadFile(path, data)
|
||||
if (result.success) {
|
||||
await loadDirectory()
|
||||
await loadFileContent(fileName)
|
||||
} else {
|
||||
error = result.error || 'Failed to create file'
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Error creating file'
|
||||
}
|
||||
}
|
||||
|
||||
function openNewFolderDialog() {
|
||||
modals.open(NewFolderDialog, {
|
||||
onConfirm: createFolder
|
||||
})
|
||||
}
|
||||
|
||||
function openNewFileDialog() {
|
||||
modals.open(NewFileDialog, {
|
||||
onConfirm: createFile
|
||||
})
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
// Load initial directory
|
||||
$effect(() => {
|
||||
loadDirectory('/')
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- <SettingsCard collapsible={false}> -->
|
||||
<!-- {#snippet icon()} -->
|
||||
<FolderIcon class="flex-shrink-0 mr-2 h-6 w-6 self-end" />
|
||||
<!-- {/snippet}
|
||||
{#snippet title()} -->
|
||||
<div class="flex justify-between items-center w-full gap-2">
|
||||
<span>File System</span>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-sm btn-primary flex items-center gap-2" onclick={openNewFileDialog}>
|
||||
<FileIcon class="w-4 h-4" />
|
||||
New File
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-primary flex items-center gap-2"
|
||||
onclick={openNewFolderDialog}
|
||||
>
|
||||
<Add class="w-4 h-4" />
|
||||
New Folder
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center w-full gap-2 mb-4">
|
||||
<span class="text-xl font-bold">File System</span>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-sm btn-primary flex items-center gap-2" onclick={() => uploadInputRef.click()}>
|
||||
<UploadIcon class="w-4 h-4" />
|
||||
Upload File
|
||||
</button>
|
||||
<button class="btn btn-sm btn-primary flex items-center gap-2" onclick={openNewFileDialog}>
|
||||
<FileIcon class="w-4 h-4" />
|
||||
New File
|
||||
</button>
|
||||
<button class="btn btn-sm btn-primary flex items-center gap-2" onclick={openNewFolderDialog}>
|
||||
<Add class="w-4 h-4" />
|
||||
New Folder
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- {/snippet} -->
|
||||
|
||||
<input
|
||||
type="file"
|
||||
bind:this={uploadInputRef}
|
||||
onchange={handleFileUpload}
|
||||
style="display: none;"
|
||||
/>
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-error mb-4">
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if uploadProgress}
|
||||
<div class="mb-4">
|
||||
<div class="flex justify-between text-sm mb-1">
|
||||
<span>Uploading...</span>
|
||||
<span>{uploadProgress.percentage.toFixed(1)}% ({formatBytes(uploadProgress.bytesTransferred)} / {formatBytes(uploadProgress.totalBytes)})</span>
|
||||
</div>
|
||||
<progress class="progress progress-primary w-full" value={uploadProgress.percentage} max="100"></progress>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if downloadProgress}
|
||||
<div class="mb-4">
|
||||
<div class="flex justify-between text-sm mb-1">
|
||||
<span>Downloading...</span>
|
||||
<span>{downloadProgress.percentage.toFixed(1)}% ({formatBytes(downloadProgress.bytesTransferred)} / {formatBytes(downloadProgress.totalBytes)})</span>
|
||||
</div>
|
||||
<progress class="progress progress-primary w-full" value={downloadProgress.percentage} max="100"></progress>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-4 w-full">
|
||||
<!-- File Tree -->
|
||||
<div
|
||||
class="w-full md:w-[300px] md:min-w-[300px] md:max-w-[300px] border-b md:border-b-0 md:border-r pb-4 md:pb-0 md:pr-4"
|
||||
>
|
||||
{#await getFiles()}
|
||||
<Spinner />
|
||||
{:then files}
|
||||
<Folder
|
||||
name="/"
|
||||
files={files.root}
|
||||
expanded
|
||||
selected={updateSelected}
|
||||
onDelete={deleteFile}
|
||||
/>
|
||||
{/await}
|
||||
</div>
|
||||
<!-- File Tree -->
|
||||
<div class="w-full md:w-[300px] md:min-w-[300px] md:max-w-[300px] border-b md:border-b-0 md:border-r pb-4 md:pb-0 md:pr-4">
|
||||
<!-- Current Path -->
|
||||
<div class="mb-4 p-2 bg-base-200 rounded font-mono text-sm flex items-center justify-between">
|
||||
<span class="truncate">{currentPath}</span>
|
||||
{#if currentPath !== '/'}
|
||||
<button class="btn btn-xs btn-ghost" onclick={navigateUp}>
|
||||
↑ Up
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- File Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
{#if filename}
|
||||
<div
|
||||
class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-4 gap-2"
|
||||
>
|
||||
<h3 class="text-lg font-semibold truncate">{filename}</h3>
|
||||
<div class="flex gap-2">
|
||||
{#if isEditing}
|
||||
<button class="btn btn-sm btn-primary" onclick={saveContent}>Save</button>
|
||||
<button
|
||||
class="btn btn-sm btn-secondary"
|
||||
onclick={() => (isEditing = false)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{:else}
|
||||
<button class="btn btn-sm btn-primary" onclick={() => (isEditing = true)}>
|
||||
Edit
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger" onclick={() => deleteFile(filename)}>
|
||||
Delete
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if loading}
|
||||
<Spinner />
|
||||
{:else}
|
||||
<!-- Directories -->
|
||||
{#each directories as dir (dir.name)}
|
||||
<div class="flex items-center py-1 px-2 hover:bg-base-200 rounded group">
|
||||
<button class="flex items-center gap-2 flex-1" onclick={() => navigateTo(dir.name)}>
|
||||
<FolderIcon class="w-5 h-5 text-yellow-500" />
|
||||
<span class="text-sm">{dir.name}</span>
|
||||
</button>
|
||||
<button
|
||||
class="opacity-0 group-hover:opacity-100 btn btn-xs btn-ghost btn-square"
|
||||
onclick={() => handleDelete(dir.name, true)}
|
||||
>
|
||||
<TrashIcon class="w-4 h-4 text-error" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#await getContent(filename)}
|
||||
<Spinner />
|
||||
{:then}
|
||||
{#if isEditing}
|
||||
<textarea
|
||||
class="w-full h-[300px] sm:h-[500px] font-mono p-2 bg-gray-800 text-white"
|
||||
bind:value={content}
|
||||
></textarea>
|
||||
{:else}
|
||||
<pre
|
||||
class="bg-gray-800 p-4 rounded overflow-auto max-h-[300px] sm:max-h-[500px]">{content}</pre>
|
||||
{/if}
|
||||
{/await}
|
||||
{:else}
|
||||
<div class="text-center text-gray-500">Select a file to view its contents</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Files -->
|
||||
{#each files as file (file.name)}
|
||||
<div class="flex items-center py-1 px-2 hover:bg-base-200 rounded group">
|
||||
<button
|
||||
class="flex items-center gap-2 flex-1 min-w-0"
|
||||
onclick={() => loadFileContent(file.name)}
|
||||
class:font-bold={selectedFile === file.name}
|
||||
>
|
||||
<FileIcon class="w-4 h-4 flex-shrink-0" />
|
||||
<span class="text-sm truncate">{file.name}</span>
|
||||
<span class="text-xs opacity-60 ml-auto flex-shrink-0">{formatBytes(file.size)}</span>
|
||||
</button>
|
||||
<div class="flex gap-1 opacity-0 group-hover:opacity-100 flex-shrink-0">
|
||||
<button
|
||||
class="btn btn-xs btn-ghost btn-square"
|
||||
onclick={() => handleDownload(file.name)}
|
||||
title="Download"
|
||||
>
|
||||
<DownloadIcon class="w-4 h-4 text-info" />
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-xs btn-ghost btn-square"
|
||||
onclick={() => handleDelete(file.name, false)}
|
||||
title="Delete"
|
||||
>
|
||||
<TrashIcon class="w-4 h-4 text-error" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if files.length === 0 && directories.length === 0}
|
||||
<div class="text-center text-base-content/50 py-8">
|
||||
Directory is empty
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- File Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
{#if selectedFile}
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-4 gap-2">
|
||||
<h3 class="text-lg font-semibold truncate">{selectedFile}</h3>
|
||||
<div class="flex gap-2">
|
||||
{#if isEditing}
|
||||
<button class="btn btn-sm btn-primary" onclick={saveFileContent}>
|
||||
Save
|
||||
</button>
|
||||
<button class="btn btn-sm btn-ghost" onclick={() => {
|
||||
isEditing = false
|
||||
loadFileContent(selectedFile)
|
||||
}}>
|
||||
Cancel
|
||||
</button>
|
||||
{:else}
|
||||
<button class="btn btn-sm btn-primary" onclick={() => (isEditing = true)}>
|
||||
Edit
|
||||
</button>
|
||||
<button class="btn btn-sm btn-ghost" onclick={() => handleDownload(selectedFile)}>
|
||||
<DownloadIcon class="w-4 h-4 mr-1" />
|
||||
Download
|
||||
</button>
|
||||
<button class="btn btn-sm btn-error" onclick={() => handleDelete(selectedFile, false)}>
|
||||
Delete
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if fileLoading}
|
||||
<Spinner />
|
||||
{:else if isEditing}
|
||||
<textarea
|
||||
class="textarea textarea-bordered w-full h-[300px] sm:h-[500px] font-mono text-sm"
|
||||
bind:value={fileContent}
|
||||
></textarea>
|
||||
{:else}
|
||||
<pre class="bg-base-200 p-4 rounded overflow-auto max-h-[300px] sm:max-h-[500px] text-sm">{fileContent}</pre>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="text-center text-base-content/50 py-16">
|
||||
Select a file to view its contents
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<!-- </SettingsCard> -->
|
||||
|
||||
Reference in New Issue
Block a user