From 144bda426b31e9f0d35cb90eb9201003f01b52ca Mon Sep 17 00:00:00 2001 From: Krao Hasanee Date: Tue, 2 Jun 2026 14:43:05 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20FileBrowser=20matches=20FileSharing=20?= =?UTF-8?q?=E2=80=94=20no=20inline=20row=20buttons,=20context=20menu=20cop?= =?UTF-8?q?y/cut/paste/rename/delete,=20selection=20Download+Delete=20pane?= =?UTF-8?q?l,=20drag=20hint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/FileBrowser.jsx | 273 +++++++++++++++++++-------------- 1 file changed, 158 insertions(+), 115 deletions(-) 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 (
{dragging && (
@@ -330,43 +325,59 @@ export default function FileBrowser({ initialPath = '/', rootPath = '/' }) { )} {/* Toolbar */} -
-
- - {breadcrumbs.slice(pathParts(rootPath).length).map((part, i) => { - const absIdx = pathParts(rootPath).length + i; - return ( - - / - - - ); - })} -
+
+ {/* Selection actions — absolute top-right like FileSharing */} + {selected.size > 0 && ( +
+ + {!readOnly && } +
+ )} -
- {uploadProgress !== null && Uploading {uploadProgress}%} - {selected.size > 0 && ( - - )} - {!readOnly && (showFolderInput ? ( -
- setFolderName(e.target.value)} placeholder="Folder name" disabled={Boolean(working)} style={{ fontSize: 12, padding: '3px 8px', borderRadius: 4, border: '1px solid var(--border)', background: 'var(--card-bg-2)', color: 'var(--text-primary)', width: 140 }} /> - Create - -
- ) : ( - - ))} - {!readOnly && ( - <> - uploadFolder(e.target.files)} {...{ webkitdirectory: '' }} /> - uploadFiles(e.target.files)} /> - folderInputRef.current?.click()}>↑ Folder - fileInputRef.current?.click()}>↑ Files - - )} - loadFiles(currentPath)}>⟳ +
0 ? 260 : 0 }}> +
+
Files
+
+ {uploadProgress !== null && Uploading {uploadProgress}%} + {clipboard && ( + + )} + {!readOnly && (showFolderInput ? ( +
+ setFolderName(e.target.value)} placeholder="Folder name" disabled={Boolean(working)} style={{ fontSize: 12, padding: '3px 8px', borderRadius: 4, border: '1px solid var(--border)', background: 'var(--card-bg-2)', color: 'var(--text-primary)', width: 140 }} /> + Create + +
+ ) : ( + + ))} + {!readOnly && ( + <> + uploadFolder(e.target.files)} {...{ webkitdirectory: '' }} /> + uploadFiles(e.target.files)} /> + folderInputRef.current?.click()}>↑ Folder + fileInputRef.current?.click()}>↑ Files + + )} + loadFiles(currentPath)}>⟳ +
+
+
+ Drag and drop files or folders here. +
+ {/* Breadcrumbs */} +
+ + {breadcrumbs.slice(pathParts(rootPath).length).map((part, i) => { + const absIdx = pathParts(rootPath).length + i; + return ( + + / + + + ); + })} +
@@ -380,9 +391,9 @@ export default function FileBrowser({ initialPath = '/', rootPath = '/' }) {
{error &&
{error}
} {loading ? ( -
Loading…
+
Loading…
) : entries.length === 0 ? ( -
This folder is empty.
+
This folder is empty.
) : ( @@ -390,7 +401,6 @@ export default function FileBrowser({ initialPath = '/', rootPath = '/' }) { - @@ -400,7 +410,6 @@ export default function FileBrowser({ initialPath = '/', rootPath = '/' }) { NameSizeModified - @@ -415,7 +424,7 @@ export default function FileBrowser({ initialPath = '/', rootPath = '/' }) { Up one folder - )} {sortedEntries.map((entry, idx) => { @@ -444,28 +453,15 @@ export default function FileBrowser({ initialPath = '/', rootPath = '/' }) {
{isDir ? ( - + ) : ( {entry.name} )}
)} - - - + + ); })} @@ -476,22 +472,69 @@ export default function FileBrowser({ initialPath = '/', rootPath = '/' }) { {/* Context menu */} {ctxMenu && ( -
e.stopPropagation()} style={{ ...popupMenuStyle, position: 'fixed', left: ctxMenu.x, top: ctxMenu.y, zIndex: 1000, padding: '4px 0', minWidth: 150 }}> - {ctxMenu.entry && ctxMenu.entry.type !== 'dir' && ( - + )} + {ctxMenu.entry?.type !== 'dir' && ( + )} - {ctxMenu.entry && ( + {ctxMenu.entry && !readOnly && ( <> - -
- + {!readOnly && ( + + )} + + )} + {clipboard && ( + <> +
+ + + )} + {!readOnly && ctxMenu.entry && ( + <> +
+
+
{isDir ? '—' : formatBytes(entry.size)}{formatDate(entry.mtime)} e.stopPropagation()}> - {!isRenaming && !readOnly && ( -
- {!isDir && ( - downloadFile(entry)}>↓ - )} - - -
- )} -
{isDir ? '—' : formatBytes(entry.size)}{formatDate(entry.mtime)}