import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import JSZip from 'jszip'; import Layout from '../components/Layout'; import PageLoader from '../components/PageLoader'; import SortTh from '../components/SortTh'; import { supabase } from '../lib/supabase'; import { useAuth } from '../context/AuthContext'; import { popupMenuStyle } from '../lib/popupStyles'; const CARD = { background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', }; const LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 14, }; 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', }; function formatBytes(value) { if (!value) return '—'; const units = ['B', 'KB', 'MB', 'GB']; const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), 3); return `${(value / 1024 ** index).toFixed(index === 0 ? 0 : 1)} ${units[index]}`; } function formatDate(value) { if (!value) return '—'; const date = new Date(value); const dateLabel = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', }); const timeLabel = date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true, }); return `${dateLabel} ${timeLabel}`; } function extOf(name) { const dot = name.lastIndexOf('.'); return dot > 0 ? name.slice(dot + 1).toUpperCase() : 'FILE'; } function fileIconStyle(ext) { const kind = ext.toLowerCase(); if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'avif', 'heic'].includes(kind)) return { bg: '#16a34a', color: '#fff' }; if (['mp4', 'mov', 'avi', 'mkv', 'webm'].includes(kind)) return { bg: '#7c3aed', color: '#fff' }; if (['mp3', 'wav', 'flac', 'aac'].includes(kind)) return { bg: '#db2777', color: '#fff' }; if (kind === 'pdf') return { bg: '#dc2626', color: '#fff' }; if (['doc', 'docx'].includes(kind)) return { bg: '#2563eb', color: '#fff' }; if (['xls', 'xlsx', 'csv'].includes(kind)) return { bg: '#16a34a', color: '#fff' }; if (['ppt', 'pptx'].includes(kind)) return { bg: '#ea580c', color: '#fff' }; if (['zip', 'rar', '7z'].includes(kind)) return { bg: '#92400e', color: '#fff' }; if (['ai', 'eps'].includes(kind)) return { bg: '#ff6c00', color: '#fff' }; if (['psd', 'psb'].includes(kind)) return { bg: '#001e36', color: '#31a8ff' }; if (['fig', 'sketch'].includes(kind)) return { bg: '#7c3aed', color: '#fff' }; return { bg: '#475569', color: '#fff' }; } function FileIcon({ name, isDir }) { if (isDir) { return (
); } const ext = extOf(name); const { bg, color } = fileIconStyle(ext); return (
{ext.slice(0, 4)}
); } async function getAccessTokenOrThrow() { const { data: { session } } = await supabase.auth.getSession(); if (session?.access_token) return session.access_token; const { data: refreshed, error } = await supabase.auth.refreshSession(); if (error) throw new Error('Session expired. Please sign in again.'); if (refreshed?.session?.access_token) return refreshed.session.access_token; throw new Error('Session expired. Please sign in again.'); } async function downloadViaLocalFilebrowser(path, session) { const accessToken = session?.access_token || await getAccessTokenOrThrow(); const response = await fetch(`/api/filebrowser?action=download-blob&path=${encodeURIComponent(path)}&sb_access_token=${encodeURIComponent(accessToken)}`, { headers: { Authorization: `Bearer ${accessToken}` }, }); if (!response.ok) { const text = await response.text(); throw new Error(text || `Error ${response.status}`); } return response; } async function downloadViaApiToken(path, filename) { const accessToken = await getAccessTokenOrThrow(); const metaResponse = await fetch(`/api/filebrowser?action=download&path=${encodeURIComponent(path)}&sb_access_token=${encodeURIComponent(accessToken)}`, { headers: { Authorization: `Bearer ${accessToken}` }, }); if (!metaResponse.ok) { const text = await metaResponse.text(); throw new Error(text || `Error ${metaResponse.status}`); } const { url, token } = await metaResponse.json(); if (!url || !token) throw new Error('Download URL unavailable.'); const urlObj = new URL(url, window.location.origin); urlObj.searchParams.set('auth', token); const link = document.createElement('a'); link.href = urlObj.toString(); link.download = filename; link.target = '_blank'; link.rel = 'noopener noreferrer'; link.style.display = 'none'; document.body.appendChild(link); link.click(); document.body.removeChild(link); } async function downloadBlobViaLocalFilebrowser(path) { const accessToken = await getAccessTokenOrThrow(); const response = await downloadViaLocalFilebrowser(path, { access_token: accessToken }); return response.blob(); } async function fbqProxy(op, path, extra = {}) { const accessToken = await getAccessTokenOrThrow(); const authHeaders = { Authorization: `Bearer ${accessToken}` }; if (op === 'list') { const response = await fetch(`/api/filebrowser?action=list&path=${encodeURIComponent(path)}&sb_access_token=${encodeURIComponent(accessToken)}`, { method: 'GET', headers: authHeaders, }); if (!response.ok) { const text = await response.text(); throw new Error(text || `Error ${response.status}`); } const data = await response.json(); const entries = Array.isArray(data?.entries) ? data.entries : []; const folders = entries .filter((entry) => entry.type === 'dir' || entry.type === 'directory' || entry.isDir) .map((entry) => ({ name: entry.name, filename: entry.name, path: entry.path, type: 'directory', isDir: true, size: 0, modified: entry.mtime || null })); const files = entries .filter((entry) => !(entry.type === 'dir' || entry.type === 'directory' || entry.isDir)) .map((entry) => ({ name: entry.name, filename: entry.name, path: entry.path, type: 'file', isDir: false, size: entry.size || 0, modified: entry.mtime || null })); return new Response(JSON.stringify({ folders, files }), { status: 200, headers: { 'Content-Type': 'application/json' } }); } if (op === 'download') { return downloadViaLocalFilebrowser(path, { access_token: accessToken }); } if (op === 'mkdir') { const normalized = String(path || '/'); const parts = normalized.split('/').filter(Boolean); const name = parts.pop() || ''; const parentPath = `/${parts.join('/')}`; const response = await fetch(`/api/filebrowser?action=mkdir&sb_access_token=${encodeURIComponent(accessToken)}`, { method: 'POST', headers: { ...authHeaders, 'Content-Type': 'application/json' }, body: JSON.stringify({ path: parentPath || '/', name }), }); if (!response.ok) { const text = await response.text(); throw new Error(text || `Error ${response.status}`); } return response; } if (op === 'upload') { const file = extra.body?.get?.('file'); if (!file) throw new Error('No file provided.'); const uploadResponse = await fetch(`/api/filebrowser?action=upload&path=${encodeURIComponent(path)}&sb_access_token=${encodeURIComponent(accessToken)}`, { method: 'POST', headers: { ...authHeaders, 'Content-Type': 'application/octet-stream' }, body: file, }); if (!uploadResponse.ok) { const text = await uploadResponse.text(); throw new Error(text || `Error ${uploadResponse.status}`); } return uploadResponse; } if (op === 'delete') { const files = JSON.parse(extra.headers?.['X-Files'] || '[]'); for (const filePath of files) { const response = await fetch(`/api/filebrowser?action=delete&path=${encodeURIComponent(filePath)}&sb_access_token=${encodeURIComponent(accessToken)}`, { method: 'DELETE', headers: authHeaders, }); if (!response.ok) { const text = await response.text(); throw new Error(text || `Error ${response.status}`); } } return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'Content-Type': 'application/json' } }); } if (op === 'move' || op === 'copy') { const files = JSON.parse(extra.headers?.['X-Files'] || '[]'); for (const srcPath of files) { const response = await fetch(`/api/filebrowser?action=move&sb_access_token=${encodeURIComponent(accessToken)}`, { method: 'POST', headers: { ...authHeaders, 'Content-Type': 'application/json' }, body: JSON.stringify({ srcPath, dstPath: path, mode: op }), }); if (!response.ok) { const text = await response.text(); throw new Error(text || `Error ${response.status}`); } } return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'Content-Type': 'application/json' } }); } throw new Error(`Unsupported operation: ${op}`); } function safeName(v) { return String(v || '').trim().replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-').replace(/\s+/g, ' ').replace(/^-+|-+$/g, ''); } function rootPath(user) { if (!user || user.role === 'team' || user.role === 'external') return '/'; const companies = user.companies?.filter(Boolean) ?? []; if (companies.length > 1) return '/'; const name = companies[0]?.name ?? user.company?.name; return name ? `/${safeName(name)}` : '/'; } function parsePath(path) { return path.split('/').filter(Boolean); } function buildPath(parts) { return `/${parts.join('/')}`; } const TREE_TOGGLE_W = 18; export default function FileSharing() { const { currentUser } = useAuth(); const accessScope = useMemo(() => ({ home: rootPath(currentUser), canManage: currentUser?.role === 'team', }), [currentUser]); const home = accessScope.home; const pinsStorageKey = useMemo(() => `fbq_pins:${currentUser?.id ?? 'anon'}`, [currentUser?.id]); const [path, setPath] = useState(home); const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [pins, setPins] = useState([]); const [selected, setSelected] = useState(new Set()); const [uploading, setUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState(null); const [downloadingSelection, setDownloadingSelection] = useState(false); const [downloadProgress, setDownloadProgress] = useState(null); const [ctxMenu, setCtxMenu] = useState(null); const [clipboard, setClipboard] = useState(null); const [hoveredNavPath, setHoveredNavPath] = useState(null); const [dragActive, setDragActive] = useState(false); const [sortKey, setSortKey] = useState('name'); const [sortDir, setSortDir] = useState('asc'); const [expanded, setExpanded] = useState(() => new Set([home])); const [, setNavVersion] = useState(0); const navCache = useRef(new Map()); const dragDepthRef = useRef(0); const rootRef = useRef(null); const tableScrollRef = useRef(null); const tableHeadRef = useRef(null); const firstBodyRowRef = useRef(null); const [contentHeight, setContentHeight] = useState(null); const [tableHeaderHeight, setTableHeaderHeight] = useState(0); const [tableRowHeight, setTableRowHeight] = useState(0); const [tableViewportHeight, setTableViewportHeight] = useState(0); useEffect(() => { if (!ctxMenu) return undefined; const close = () => setCtxMenu(null); const onKey = (event) => { if (event.key === 'Escape') setCtxMenu(null); }; window.addEventListener('click', close); window.addEventListener('keydown', onKey); return () => { window.removeEventListener('click', close); window.removeEventListener('keydown', onKey); }; }, [ctxMenu]); const load = useCallback(async (nextPath) => { setLoading(true); setError(''); setSelected(new Set()); try { const response = await fbqProxy('list', nextPath); const data = await response.json(); setEntries(Array.isArray(data) ? data : [...(data?.folders ?? []), ...(data?.files ?? [])]); } catch (err) { setError(err?.message || 'Failed to load files.'); } finally { setLoading(false); } }, []); useEffect(() => { load(path); }, [path, load]); useEffect(() => { try { setPins(JSON.parse(localStorage.getItem(pinsStorageKey) || '[]')); } catch { setPins([]); } }, [pinsStorageKey]); async function loadNavPath(nextPath) { if (navCache.current.has(nextPath)) return; navCache.current.set(nextPath, []); try { const response = await fbqProxy('list', nextPath); const data = await response.json(); const folders = (data?.folders ?? []).map((folder) => folder.name ?? folder.filename ?? '').filter(Boolean); navCache.current.set(nextPath, folders); setNavVersion((value) => value + 1); } catch { navCache.current.delete(nextPath); } } useEffect(() => { loadNavPath(home); }, []); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { function measure() { const root = rootRef.current; if (!root) return; const main = root.closest('.main-content'); const header = main?.querySelector('.site-header'); if (!main || !header) return; const styles = window.getComputedStyle(main); const paddingTop = parseFloat(styles.paddingTop || '0'); const paddingBottom = parseFloat(styles.paddingBottom || '0'); setContentHeight(Math.max(320, main.clientHeight - paddingTop - paddingBottom - header.offsetHeight)); } measure(); window.addEventListener('resize', measure); return () => window.removeEventListener('resize', measure); }, []); useEffect(() => { function measureTableHeader() { setTableHeaderHeight(tableHeadRef.current?.offsetHeight ?? 0); setTableRowHeight(firstBodyRowRef.current?.offsetHeight ?? 0); } measureTableHeader(); window.addEventListener('resize', measureTableHeader); return () => window.removeEventListener('resize', measureTableHeader); }, [entries.length, loading, sortDir, sortKey]); useEffect(() => { const node = tableScrollRef.current; if (!node) return undefined; const measureViewport = () => setTableViewportHeight(node.clientHeight); measureViewport(); const observer = new ResizeObserver(measureViewport); observer.observe(node); window.addEventListener('resize', measureViewport); return () => { observer.disconnect(); window.removeEventListener('resize', measureViewport); }; }, [contentHeight, entries.length, loading]); useEffect(() => { const segments = parsePath(path); const homeParts = parsePath(home); const toExpand = [home]; for (let index = homeParts.length; index < segments.length; index += 1) { toExpand.push(buildPath(segments.slice(0, index + 1))); } setExpanded((prev) => { const next = new Set(prev); toExpand.forEach((value) => next.add(value)); return next; }); toExpand.forEach(loadNavPath); loadNavPath(path); }, [path]); // eslint-disable-line react-hooks/exhaustive-deps function toggleNav(nextPath) { setExpanded((prev) => { const next = new Set(prev); if (next.has(nextPath)) { next.delete(nextPath); } else { next.add(nextPath); loadNavPath(nextPath); } return next; }); } function renderNavTree(nodePath, depth) { // eslint-disable-line no-unused-vars const folders = navCache.current.get(nodePath) ?? []; if (!folders.length) return null; return folders.map((folderName) => { const childPath = nodePath === '/' ? `/${folderName}` : `${nodePath}/${folderName}`; const isExpanded = expanded.has(childPath); return (
{isExpanded && (
{renderNavTree(childPath, depth + 1)}
)}
); }); } function navigate(nextPath) { setPath(nextPath); } function breadcrumbs() { const segments = parsePath(path); const homeParts = parsePath(home); const crumbs = [{ label: 'Home', path: home }]; for (let index = homeParts.length; index < segments.length; index += 1) { crumbs.push({ label: segments[index], path: buildPath(segments.slice(0, index + 1)) }); } return crumbs; } function toggleSelect(name) { setSelected((prev) => { const next = new Set(prev); if (next.has(name)) next.delete(name); else next.add(name); return next; }); } function toggleSort(column) { if (sortKey === column) { setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc')); return; } setSortKey(column); setSortDir(column === 'modified' ? 'desc' : 'asc'); } function pin(label, nextPath) { const next = [...pins.filter((item) => item.path !== nextPath), { label, path: nextPath }]; setPins(next); localStorage.setItem(pinsStorageKey, JSON.stringify(next)); } function unpin(nextPath) { const next = pins.filter((item) => item.path !== nextPath); setPins(next); localStorage.setItem(pinsStorageKey, JSON.stringify(next)); } function triggerDownload(url, filename) { const link = document.createElement('a'); link.href = url; link.download = filename; link.style.display = 'none'; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); } async function handleDownload(entry) { const nextPath = entry.path ?? (path === '/' ? `/${entry.name}` : `${path}/${entry.name}`); try { await downloadViaApiToken(nextPath, entry.name); } catch (err) { setError(err?.message || 'Download failed.'); } } async function addPathToZip(zip, targetPath, zipPath = '') { const response = await fbqProxy('list', targetPath); const data = await response.json(); const folders = data?.folders ?? []; const files = data?.files ?? []; for (const folder of folders) { const folderName = folder.name ?? folder.filename ?? ''; if (!folderName) continue; const childPath = targetPath === '/' ? `/${folderName}` : `${targetPath}/${folderName}`; const childZipPath = zipPath ? `${zipPath}/${folderName}` : folderName; await addPathToZip(zip, childPath, childZipPath); } for (const file of files) { const fileName = file.name ?? file.filename ?? ''; if (!fileName) continue; const filePath = file.path ?? (targetPath === '/' ? `/${fileName}` : `${targetPath}/${fileName}`); const blob = await downloadBlobViaLocalFilebrowser(filePath); zip.file(zipPath ? `${zipPath}/${fileName}` : fileName, blob); } } async function handleDownloadSelected() { if (!selected.size) return; setDownloadingSelection(true); setDownloadProgress(0); setError(''); try { const selectedEntries = [...selected] .map((name) => entries.find((entry) => (entry.name ?? entry.filename) === name)) .filter(Boolean); if (selectedEntries.length === 1) { const [entry] = selectedEntries; const name = entry.name ?? entry.filename ?? ''; const targetPath = entry.path ?? (path === '/' ? `/${name}` : `${path}/${name}`); const isDir = entry.type === 'directory' || entry.isDir; if (!isDir) { const blob = await downloadBlobViaLocalFilebrowser(targetPath); setDownloadProgress(100); triggerDownload(URL.createObjectURL(blob), name); return; } const zip = new JSZip(); setDownloadProgress(50); await addPathToZip(zip, targetPath, name); setDownloadProgress(100); triggerDownload(URL.createObjectURL(await zip.generateAsync({ type: 'blob' })), `${name}.zip`); return; } const zip = new JSZip(); for (let i = 0; i < selectedEntries.length; i++) { const entry = selectedEntries[i]; const name = entry.name ?? entry.filename ?? ''; const targetPath = entry.path ?? (path === '/' ? `/${name}` : `${path}/${name}`); const isDir = entry.type === 'directory' || entry.isDir; if (isDir) { await addPathToZip(zip, targetPath, name); } else { const blob = await downloadBlobViaLocalFilebrowser(targetPath); zip.file(name, blob); } setDownloadProgress(Math.round(((i + 1) / selectedEntries.length) * 100)); } const folderName = parsePath(path).at(-1) || 'files'; triggerDownload(URL.createObjectURL(await zip.generateAsync({ type: 'blob' })), `${folderName}-selection.zip`); } catch (err) { setError(err?.message || 'Download failed.'); } finally { setDownloadingSelection(false); setDownloadProgress(null); } } async function handlePaste(destPath) { if (!clipboard) return; try { await fbqProxy(clipboard.mode, destPath, { headers: { 'X-Files': JSON.stringify(clipboard.items.map((i) => i.path)) }, }); if (clipboard.mode === 'move') setClipboard(null); load(path); } catch { setError('Paste failed.'); } } async function handleDelete() { if (!selected.size) return; if (!window.confirm(`Delete ${selected.size} item(s)?`)) return; const filePaths = [...selected].map((name) => ( entries.find((entry) => (entry.name ?? entry.filename) === name)?.path ?? (path === '/' ? `/${name}` : `${path}/${name}`) )); try { await fbqProxy('delete', path, { headers: { 'X-Files': JSON.stringify(filePaths) } }); load(path); } catch { setError('Delete failed.'); } } async function uploadFiles(files) { if (!files.length) return; setUploading(true); setUploadProgress(0); try { const dirSet = new Set(); files.forEach(({ relativePath }) => { const segments = relativePath.split('/').filter(Boolean); for (let index = 1; index < segments.length; index += 1) { dirSet.add(segments.slice(0, index).join('/')); } }); const dirs = [...dirSet].sort((left, right) => left.split('/').length - right.split('/').length); for (const dir of dirs) { const dirPath = `${path === '/' ? '' : path}/${dir}/`; try { await fbqProxy('mkdir', dirPath); } catch {} } for (let index = 0; index < files.length; index += 1) { const { file, relativePath } = files[index]; const formData = new FormData(); formData.append('file', file); await fbqProxy('upload', path === '/' ? `/${relativePath}` : `${path}/${relativePath}`, { body: formData }); setUploadProgress(Math.round(((index + 1) / files.length) * 100)); } load(path); } catch { setError('Upload failed.'); } finally { setUploading(false); setUploadProgress(null); } } async function collectEntry(entry, parent = '') { if (entry.isFile) { return [await new Promise((resolve, reject) => entry.file((file) => resolve({ file, relativePath: parent ? `${parent}/${file.name}` : file.name }), reject))]; } if (!entry.isDirectory) return []; const nextParent = parent ? `${parent}/${entry.name}` : entry.name; const reader = entry.createReader(); const children = []; while (true) { const batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject)); if (!batch.length) break; children.push(...batch); } return (await Promise.all(children.map((child) => collectEntry(child, nextParent)))).flat(); } async function handleDrop(event) { event.preventDefault(); event.stopPropagation(); dragDepthRef.current = 0; setDragActive(false); const entriesToRead = [...(event.dataTransfer?.items ?? [])] .filter((item) => item.kind === 'file') .map((item) => item.webkitGetAsEntry?.()) .filter(Boolean); if (entriesToRead.length) { await uploadFiles((await Promise.all(entriesToRead.map((entry) => collectEntry(entry)))).flat()); return; } await uploadFiles( [...(event.dataTransfer?.files ?? [])].map((file) => ({ file, relativePath: file.webkitRelativePath || file.name, })), ); } function handleDragEnter(event) { event.preventDefault(); event.stopPropagation(); dragDepthRef.current += 1; setDragActive(true); } function handleDragOver(event) { event.preventDefault(); event.stopPropagation(); event.dataTransfer.dropEffect = 'copy'; } function handleDragLeave(event) { event.preventDefault(); event.stopPropagation(); dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); if (dragDepthRef.current === 0) setDragActive(false); } function openCtxMenu(event, entry, childPath) { event.preventDefault(); event.stopPropagation(); setCtxMenu({ x: event.clientX, y: event.clientY, entry, childPath }); } const crumbs = breadcrumbs(); const sortedEntries = useMemo(() => { function valueOf(entry, column) { if (column === 'name') return (entry.name ?? entry.filename ?? '').toLowerCase(); if (column === 'size') return Number(entry.size ?? -1); if (column === 'modified') return new Date(entry.modified ?? entry.lastModified ?? 0).getTime(); return ''; } return [...entries].sort((left, right) => { const leftIsDir = left.type === 'directory' || left.isDir; const rightIsDir = right.type === 'directory' || right.isDir; if (leftIsDir !== rightIsDir) return leftIsDir ? -1 : 1; let result = 0; const leftValue = valueOf(left, sortKey); const rightValue = valueOf(right, sortKey); if (typeof leftValue === 'string' && typeof rightValue === 'string') { result = leftValue.localeCompare(rightValue); } else if (leftValue === rightValue) { result = 0; } else { result = leftValue > rightValue ? 1 : -1; } if (result === 0) { const leftName = (left.name ?? left.filename ?? '').toLowerCase(); const rightName = (right.name ?? right.filename ?? '').toLowerCase(); result = leftName.localeCompare(rightName); } return sortDir === 'asc' ? result : -result; }); }, [entries, sortDir, sortKey]); const fillerRowCount = useMemo(() => { if (!entries.length || !tableRowHeight || !tableViewportHeight) return 0; const availableHeight = tableViewportHeight - tableHeaderHeight; if (availableHeight <= 0) return 0; const visibleRowCount = Math.ceil(availableHeight / tableRowHeight) + 1; return Math.max(0, visibleRowCount - sortedEntries.length); }, [entries.length, sortedEntries.length, tableHeaderHeight, tableRowHeight, tableViewportHeight]); return (
Pinned
{pins.length === 0 ? (
Right-click a folder to pin it.
) : (
{pins.map((pinItem) => (
))}
)}
Navigation
{renderNavTree(home, 0)}
{selected.size > 0 && (
)}
0 ? 286 : 0 }}>
File Browser
Drag and drop files or folders here.
{crumbs.map((crumb, index) => ( {index > 0 && /} ))}
{uploading &&
Uploading {uploadProgress}%
}
{ if (e.target === e.currentTarget || e.target.tagName === 'TABLE' || e.target.closest('table')?.parentElement === e.currentTarget) { e.preventDefault(); if (clipboard) setCtxMenu({ x: e.clientX, y: e.clientY, entry: null, childPath: null }); } }} > {error &&
{error}
} {loading ? (
Loading...
) : entries.length === 0 ? (
This folder is empty.
) : ( Name Size Modified {sortedEntries.map((entry, index) => { const name = entry.name ?? entry.filename ?? ''; const isDir = entry.type === 'directory' || entry.isDir; const childPath = entry.path ?? (path === '/' ? `/${name}` : `${path}/${name}`); const rowBg = index % 2 === 0 ? 'var(--file-row-alt-bg)' : 'transparent'; return ( isDir && navigate(childPath)} onContextMenu={(event) => openCtxMenu(event, entry, childPath)} > ); })}
0} onChange={(event) => setSelected(event.target.checked ? new Set(entries.map((entry) => entry.name ?? entry.filename)) : new Set())} />
{ event.stopPropagation(); toggleSelect(name); }}> toggleSelect(name)} onClick={(event) => event.stopPropagation()} />
{isDir ? ( ) : ( {name} )}
{isDir ? '—' : formatBytes(entry.size ?? null)} {formatDate(entry.modified ?? entry.lastModified ?? null)}
)}
{(loading || uploading || downloadingSelection) && ( )}
{ctxMenu && (() => { const { x, y, entry, childPath } = ctxMenu; const isDir = entry ? (entry.type === 'directory' || entry.isDir) : false; const name = entry ? (entry.name ?? entry.filename ?? '') : ''; const isPinned = entry ? pins.some((pinItem) => pinItem.path === childPath) : false; const clipboardItems = entry ? (selected.has(name) ? [...selected].map((n) => { const e = entries.find((en) => (en.name ?? en.filename) === n); return { name: n, path: e?.path ?? (path === '/' ? `/${n}` : `${path}/${n}`) }; }) : [{ name, path: childPath }]) : []; const itemStyle = { 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 separatorStyle = { height: 1, background: 'var(--border)', margin: '4px 0' }; return (
event.stopPropagation()} style={{ ...popupMenuStyle, position: 'fixed', left: x, top: y, zIndex: 1000, padding: '4px 0', minWidth: 160 }}> {entry && isDir && ( )} {entry && !isDir && ( )} {entry && isDir && ( <>
)} {entry && ( <>
)} {clipboard && ( <>
)} {accessScope.canManage && entry && ( <>
)}
); })()} ); }