diff --git a/src/components/FileBrowser.jsx b/src/components/FileBrowser.jsx index 77ce220..21dd6b4 100644 --- a/src/components/FileBrowser.jsx +++ b/src/components/FileBrowser.jsx @@ -6,6 +6,9 @@ import { popupMenuStyle } from '../lib/popupStyles'; const TABLE_TH = { fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px', border: 'none', background: 'transparent', verticalAlign: 'top' }; const TABLE_TD = { padding: '5px', fontSize: 13, color: 'var(--text-primary)', border: 'none', background: 'transparent' }; +const LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }; +const ITEM_STYLE = { display: 'flex', alignItems: 'center', gap: 8, padding: '7px 14px', fontSize: 13, cursor: 'pointer', color: 'var(--text-primary)', background: 'none', border: 'none', width: '100%', textAlign: 'left', borderRadius: 4, whiteSpace: 'nowrap' }; +const SEP = { height: 1, background: 'var(--border)', margin: '4px 0' }; function formatBytes(bytes) { const value = Number(bytes || 0); @@ -81,6 +84,7 @@ export default function FileBrowser({ initialPath = '/', rootPath = '/' }) { const [sortKey, setSortKey] = useState('name'); const [sortDir, setSortDir] = useState('asc'); const [ctxMenu, setCtxMenu] = useState(null); + const [clipboard, setClipboard] = useState(null); const fileInputRef = useRef(null); const folderInputRef = useRef(null); @@ -110,9 +114,7 @@ export default function FileBrowser({ initialPath = '/', rootPath = '/' }) { }; const loadFiles = async (path = currentPath) => { - setLoading(true); - setError(''); - setSelected(new Set()); + setLoading(true); setError(''); setSelected(new Set()); try { const params = new URLSearchParams({ action: 'list', path }); const data = await apiFetch(`/api/filebrowser?${params}`); @@ -122,11 +124,8 @@ export default function FileBrowser({ initialPath = '/', rootPath = '/' }) { setParentPath(data.parentPath || '/'); setCanGoUp(data.canGoUp || false); setReadOnly(data.readOnly || false); - } catch (err) { - setError(err.message); - } finally { - setLoading(false); - } + } catch (err) { setError(err.message); } + finally { setLoading(false); } }; useEffect(() => { loadFiles(initialPath); }, [initialPath]); // eslint-disable-line react-hooks/exhaustive-deps @@ -165,6 +164,17 @@ export default function FileBrowser({ initialPath = '/', rootPath = '/' }) { finally { setWorking(''); } }; + const downloadSelected = async () => { + if (!selected.size) return; + setWorking('dl-sel'); + try { + for (const name of selected) { + const entry = entries.find(e => e.name === name); + if (entry && entry.type !== 'dir') await downloadFile(entry); + } + } finally { setWorking(''); } + }; + const deleteEntry = async (entry) => { if (!window.confirm(`Delete "${entry.name}"? This cannot be undone.`)) return; setWorking(`del:${entry.path}`); @@ -211,15 +221,28 @@ export default function FileBrowser({ initialPath = '/', rootPath = '/' }) { finally { setWorking(''); } }; + const pasteClipboard = async () => { + if (!clipboard) return; + setWorking('paste'); + try { + for (const item of clipboard.items) { + await apiFetch('/api/filebrowser?action=' + (clipboard.mode === 'copy' ? 'copy' : 'move'), { + method: 'POST', + body: JSON.stringify({ srcPath: item.path, dstParentPath: currentPath }), + }); + } + if (clipboard.mode === 'move') setClipboard(null); + await loadFiles(currentPath); + } catch (err) { setError(err.message); } + finally { setWorking(''); } + }; + async function uploadOneFile(vPath, file, retries = 3) { for (let attempt = 1; attempt <= retries; attempt++) { try { - const res = await apiFetch(`/api/filebrowser?action=upload&path=${encodeURIComponent(vPath)}`, { - method: 'POST', - headers: { 'Content-Type': 'application/octet-stream' }, - body: file, + await apiFetch(`/api/filebrowser?action=upload&path=${encodeURIComponent(vPath)}`, { + method: 'POST', headers: { 'Content-Type': 'application/octet-stream' }, body: file, }); - if (!res) return; return; } catch (err) { if (attempt === retries) throw err; @@ -238,11 +261,7 @@ export default function FileBrowser({ initialPath = '/', rootPath = '/' }) { setUploadProgress(Math.round(((i + 1) / sel.length) * 100)); } } catch (err) { setError(err.message); } - finally { - setWorking(''); setUploadProgress(null); - if (fileInputRef.current) fileInputRef.current.value = ''; - setDragging(false); await loadFiles(currentPath); - } + finally { setWorking(''); setUploadProgress(null); if (fileInputRef.current) fileInputRef.current.value = ''; setDragging(false); await loadFiles(currentPath); } }; const uploadFolder = async (files) => { @@ -256,18 +275,14 @@ export default function FileBrowser({ initialPath = '/', rootPath = '/' }) { for (let i = 1; i <= parts.length; i++) dirsNeeded.add(parts.slice(0, i).join('/')); } for (const dir of [...dirsNeeded].sort((a, b) => a.split('/').length - b.split('/').length)) { - await apiFetch(`/api/filebrowser?action=mkdir`, { method: 'POST', body: JSON.stringify({ path: currentPath, name: dir.split('/').pop() }) }).catch(() => {}); + await apiFetch('/api/filebrowser?action=mkdir', { method: 'POST', body: JSON.stringify({ path: currentPath, name: dir.split('/').pop() }) }).catch(() => {}); } for (let i = 0; i < sel.length; i++) { await uploadOneFile(joinVirtualPath(currentPath, sel[i].webkitRelativePath), sel[i]); setUploadProgress(Math.round(((i + 1) / sel.length) * 100)); } } catch (err) { setError(err.message); } - finally { - setWorking(''); setUploadProgress(null); - if (folderInputRef.current) folderInputRef.current.value = ''; - await loadFiles(currentPath); - } + finally { setWorking(''); setUploadProgress(null); if (folderInputRef.current) folderInputRef.current.value = ''; await loadFiles(currentPath); } }; const handleDragEnter = (e) => { e.preventDefault(); if (!readOnly) setDragging(true); }; @@ -280,23 +295,11 @@ export default function FileBrowser({ initialPath = '/', rootPath = '/' }) { const fsEntries = items.map(i => i.webkitGetAsEntry?.()).filter(Boolean); if (fsEntries.some(en => en.isDirectory)) { const readFsEntry = (entry) => new Promise(resolve => { - if (entry.isFile) { - entry.file(file => { - Object.defineProperty(file, 'webkitRelativePath', { value: entry.fullPath.replace(/^\//, ''), writable: false, configurable: true }); - resolve([file]); - }); - } else if (entry.isDirectory) { - const reader = entry.createReader(); - const readAll = acc => reader.readEntries(async entries => { - if (!entries.length) { resolve(acc); return; } - const nested = await Promise.all(entries.map(readFsEntry)); - readAll([...acc, ...nested.flat()]); - }); - readAll([]); - } else { resolve([]); } + if (entry.isFile) { entry.file(file => { Object.defineProperty(file, 'webkitRelativePath', { value: entry.fullPath.replace(/^\//, ''), writable: false, configurable: true }); resolve([file]); }); } + else if (entry.isDirectory) { const reader = entry.createReader(); const readAll = acc => reader.readEntries(async entries => { if (!entries.length) { resolve(acc); return; } const nested = await Promise.all(entries.map(readFsEntry)); readAll([...acc, ...nested.flat()]); }); readAll([]); } + else { resolve([]); } }); - const allFiles = (await Promise.all(fsEntries.map(readFsEntry))).flat(); - uploadFolder(allFiles); + uploadFolder((await Promise.all(fsEntries.map(readFsEntry))).flat()); } else { if (!e.dataTransfer.files?.length) return; uploadFiles(e.dataTransfer.files); @@ -305,20 +308,12 @@ export default function FileBrowser({ initialPath = '/', rootPath = '/' }) { const toggleSelect = (name) => setSelected(prev => { const next = new Set(prev); next.has(name) ? next.delete(name) : next.add(name); return next; }); - const openCtxMenu = (e, entry, childPath) => { - e.preventDefault(); e.stopPropagation(); - setCtxMenu({ x: e.clientX, y: e.clientY, entry, childPath }); - }; - - const CTX_ITEM = { display: 'flex', alignItems: 'center', gap: 8, padding: '7px 14px', fontSize: 13, cursor: 'pointer', color: 'var(--text-primary)', background: 'none', border: 'none', width: '100%', textAlign: 'left', borderRadius: 4, whiteSpace: 'nowrap' }; + const openCtxMenu = (e, entry, childPath) => { e.preventDefault(); e.stopPropagation(); setCtxMenu({ x: e.clientX, y: e.clientY, entry, childPath }); }; return (
| + | )} {sortedEntries.map((entry, idx) => { @@ -444,28 +453,15 @@ export default function FileBrowser({ initialPath = '/', rootPath = '/' }) { | {isDir ? '—' : formatBytes(entry.size)} | -{formatDate(entry.mtime)} | - e.stopPropagation()}>
- {!isRenaming && !readOnly && (
-
- {!isDir && (
-
- )}
- |
+ {isDir ? '—' : formatBytes(entry.size)} | +{formatDate(entry.mtime)} | ); })} @@ -476,22 +472,69 @@ export default function FileBrowser({ initialPath = '/', rootPath = '/' }) { {/* Context menu */} {ctxMenu && ( -