Files
fourge-portal/src/pages/FileSharing.jsx
T

1248 lines
50 KiB
React

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 (
<div style={{ width: 24, height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" />
</svg>
</div>
);
}
const ext = extOf(name);
const { bg, color } = fileIconStyle(ext);
return (
<div style={{ width: 24, height: 24, borderRadius: 4, background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<span style={{ color, fontSize: 7, fontWeight: 600, letterSpacing: 0.3, fontFamily: 'monospace' }}>{ext.slice(0, 4)}</span>
</div>
);
}
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 (
<div key={childPath}>
<div style={{ display: 'flex', alignItems: 'center', position: 'relative' }}>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
toggleNav(childPath);
}}
style={{ width: 18, height: 28, flexShrink: 0, background: 'none', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-muted)', padding: 0 }}
>
<svg width="8" height="8" viewBox="0 0 8 8" fill="currentColor" style={{ transform: isExpanded ? 'rotate(90deg)' : 'none', transition: 'transform 0.15s' }}>
<path d="M2 1l4 3-4 3V1z" />
</svg>
</button>
<button
type="button"
onClick={() => navigate(childPath)}
onContextMenu={(event) => {
event.preventDefault();
event.stopPropagation();
pin(folderName, childPath);
}}
onMouseEnter={() => setHoveredNavPath(childPath)}
onMouseLeave={() => setHoveredNavPath((prev) => (prev === childPath ? null : prev))}
style={{
flex: 1,
background: 'none',
border: 'none',
cursor: 'pointer',
textAlign: 'left',
padding: '4px 6px 4px 2px',
borderRadius: 4,
fontSize: 13,
fontWeight: 500,
color: path === childPath || hoveredNavPath === childPath ? 'var(--accent)' : 'var(--text-primary)',
display: 'flex',
alignItems: 'center',
overflow: 'hidden',
transition: 'color 140ms ease',
}}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{folderName}</span>
</button>
</div>
{isExpanded && (
<div style={{ marginLeft: TREE_TOGGLE_W, position: 'relative' }}>
<span aria-hidden style={{ position: 'absolute', left: -(TREE_TOGGLE_W / 2), top: 0, bottom: 0, width: 1, background: 'var(--border)' }} />
{renderNavTree(childPath, depth + 1)}
</div>
)}
</div>
);
});
}
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 (
<Layout>
<div
ref={rootRef}
style={{
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 3fr)',
gap: 24,
height: contentHeight ?? 'calc(100vh - 160px)',
alignItems: 'stretch',
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 24, minHeight: 0 }}>
<div style={{ ...CARD, padding: '14px 16px', flexShrink: 0 }}>
<div style={LABEL}>Pinned</div>
{pins.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>Right-click a folder to pin it.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{pins.map((pinItem) => (
<div key={pinItem.path} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<button
type="button"
onClick={() => navigate(pinItem.path)}
onMouseEnter={() => setHoveredNavPath(pinItem.path)}
onMouseLeave={() => setHoveredNavPath((prev) => (prev === pinItem.path ? null : prev))}
style={{
flex: 1,
background: 'none',
border: 'none',
cursor: 'pointer',
textAlign: 'left',
padding: '4px 6px',
borderRadius: 4,
fontSize: 13,
fontWeight: 500,
color: path === pinItem.path || hoveredNavPath === pinItem.path ? 'var(--accent)' : 'var(--text-primary)',
display: 'flex',
alignItems: 'center',
overflow: 'hidden',
transition: 'color 140ms ease',
}}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{pinItem.label}</span>
</button>
<button type="button" onClick={() => unpin(pinItem.path)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, color: 'var(--text-muted)', flexShrink: 0 }}>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
))}
</div>
)}
</div>
<div style={{ ...CARD, padding: '14px 16px', flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ ...LABEL, flexShrink: 0 }}>Navigation</div>
<div className="table-scroll-hidden" style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden' }}>
<button
type="button"
onClick={() => navigate(home)}
onMouseEnter={() => setHoveredNavPath(home)}
onMouseLeave={() => setHoveredNavPath((prev) => (prev === home ? null : prev))}
style={{ width: '100%', background: 'none', border: 'none', cursor: 'pointer', textAlign: 'left', padding: '4px 6px', borderRadius: 4, fontSize: 13, fontWeight: 500, color: path === home || hoveredNavPath === home ? 'var(--accent)' : 'var(--text-primary)', display: 'flex', alignItems: 'center', marginBottom: 2, transition: 'color 140ms ease' }}
>
Home
</button>
{renderNavTree(home, 0)}
</div>
</div>
</div>
<div
style={{
...CARD,
padding: 0,
position: 'relative',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
borderColor: dragActive ? 'var(--accent)' : 'var(--border)',
boxShadow: dragActive ? '0 0 0 1px rgba(245,165,35,0.25) inset' : 'none',
transition: 'border-color 140ms ease, box-shadow 140ms ease',
}}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<div style={{ padding: '18px 21px', borderBottom: '1px solid var(--border)', flexShrink: 0, position: 'relative' }}>
{selected.size > 0 && (
<div style={{ position: 'absolute', top: 18, right: 21, display: 'flex', gap: 8 }}>
<button
type="button"
className="btn btn-outline"
onClick={handleDownloadSelected}
disabled={downloadingSelection}
>
{downloadingSelection ? 'Downloading...' : `Download (${selected.size})`}
</button>
<button type="button" className="btn btn-danger" onClick={handleDelete}>
Delete ({selected.size})
</button>
</div>
)}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', paddingRight: selected.size > 0 ? 286 : 0 }}>
<div>
<div style={LABEL}>File Browser</div>
<div style={{ fontSize: 12, color: dragActive ? 'var(--accent)' : 'var(--text-muted)', marginTop: -2, marginBottom: 14, transition: 'color 140ms ease' }}>
Drag and drop files or folders here.
</div>
<div style={{ ...LABEL, marginBottom: 0 }}>
{crumbs.map((crumb, index) => (
<span key={crumb.path}>
{index > 0 && <span style={{ margin: '0 4px', opacity: 0.4 }}>/</span>}
<button
type="button"
className="dashboard-inline-link"
onClick={() => navigate(crumb.path)}
style={{
background: 'none',
border: 'none',
cursor: index < crumbs.length - 1 ? 'pointer' : 'default',
padding: 0,
color: index === crumbs.length - 1 ? 'var(--accent)' : 'var(--text-primary)',
fontSize: 'inherit',
fontWeight: 'inherit',
letterSpacing: 'inherit',
textTransform: 'inherit',
fontFamily: 'inherit',
lineHeight: 'inherit',
}}
>
{crumb.label}
</button>
</span>
))}
</div>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
{uploading && <div style={{ fontSize: 12, color: 'var(--text-muted)' }}>Uploading {uploadProgress}%</div>}
</div>
</div>
</div>
<div
className="table-scroll-hidden"
ref={tableScrollRef}
style={{
flex: 1,
overflowY: 'auto',
minHeight: 0,
padding: '18px 21px',
}}
onContextMenu={(e) => {
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 && <div style={{ fontSize: 13, color: 'var(--danger)', paddingBottom: 12 }}>{error}</div>}
{loading ? (
<div className="card-empty-center">Loading...</div>
) : entries.length === 0 ? (
<div className="card-empty-center">This folder is empty.</div>
) : (
<table className="table-sticky-head" style={{ width: '100%', tableLayout: 'fixed', borderCollapse: 'collapse' }}>
<thead ref={tableHeadRef}>
<tr>
<th style={{ ...TABLE_TH, width: 32, textAlign: 'center', padding: '0 5px 12px 5px', cursor: 'default' }}>
<input type="checkbox" checked={selected.size === entries.length && entries.length > 0} onChange={(event) => setSelected(event.target.checked ? new Set(entries.map((entry) => entry.name ?? entry.filename)) : new Set())} />
</th>
<SortTh col="name" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} style={{ ...TABLE_TH, textAlign: 'left', paddingLeft: 5 }}>
Name
</SortTh>
<SortTh col="size" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} style={{ ...TABLE_TH, textAlign: 'right', width: 100, padding: '0 5px 12px 5px' }}>
Size
</SortTh>
<SortTh col="modified" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} style={{ ...TABLE_TH, textAlign: 'right', width: 185, padding: '0 5px 12px 5px' }}>
Modified
</SortTh>
</tr>
</thead>
<tbody>
{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 (
<tr
key={name}
ref={index === 0 ? firstBodyRowRef : undefined}
style={{ cursor: isDir ? 'pointer' : 'default' }}
onClick={() => isDir && navigate(childPath)}
onContextMenu={(event) => openCtxMenu(event, entry, childPath)}
>
<td style={{ ...TABLE_TD, width: 32, textAlign: 'center', background: rowBg }} onClick={(event) => { event.stopPropagation(); toggleSelect(name); }}>
<input type="checkbox" checked={selected.has(name)} onChange={() => toggleSelect(name)} onClick={(event) => event.stopPropagation()} />
</td>
<td style={{ ...TABLE_TD, textAlign: 'left', background: rowBg }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<FileIcon name={name} isDir={isDir} />
{isDir ? (
<button type="button" className="dashboard-inline-link" onClick={(event) => { event.stopPropagation(); navigate(childPath); }} style={{ fontSize: 13, fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'left' }}>
{name}
</button>
) : (
<span style={{ fontSize: 13, color: 'var(--text-primary)', fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{name}</span>
)}
</div>
</td>
<td style={{ ...TABLE_TD, textAlign: 'right', fontSize: 12, color: 'var(--text-primary)', background: rowBg }}>{isDir ? '—' : formatBytes(entry.size ?? null)}</td>
<td style={{ ...TABLE_TD, textAlign: 'right', fontSize: 12, color: 'var(--text-primary)', background: rowBg }}>{formatDate(entry.modified ?? entry.lastModified ?? null)}</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
{(loading || uploading || downloadingSelection) && (
<PageLoader
scope="container"
label={uploading ? 'Uploading' : downloadingSelection ? 'Downloading' : 'Loading'}
progress={uploading ? uploadProgress : downloadingSelection ? downloadProgress : null}
/>
)}
</div>
</div>
{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 (
<div onClick={(event) => event.stopPropagation()} style={{ ...popupMenuStyle, position: 'fixed', left: x, top: y, zIndex: 1000, padding: '4px 0', minWidth: 160 }}>
{entry && isDir && (
<button
style={itemStyle}
onMouseEnter={(event) => { event.currentTarget.style.background = 'var(--interactive-row-hover)'; }}
onMouseLeave={(event) => { event.currentTarget.style.background = 'none'; }}
onClick={() => {
navigate(childPath);
setCtxMenu(null);
}}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" />
</svg>
Open
</button>
)}
{entry && !isDir && (
<button
style={itemStyle}
onMouseEnter={(event) => { event.currentTarget.style.background = 'var(--interactive-row-hover)'; }}
onMouseLeave={(event) => { event.currentTarget.style.background = 'none'; }}
onClick={() => {
handleDownload(entry);
setCtxMenu(null);
}}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
<polyline points="7,10 12,15 17,10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
Download
</button>
)}
{entry && isDir && (
<>
<div style={separatorStyle} />
<button
style={itemStyle}
onMouseEnter={(event) => { event.currentTarget.style.background = 'var(--interactive-row-hover)'; }}
onMouseLeave={(event) => { event.currentTarget.style.background = 'none'; }}
onClick={() => {
if (isPinned) unpin(childPath);
else pin(name, childPath);
setCtxMenu(null);
}}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
{isPinned ? 'Unpin' : 'Pin folder'}
</button>
</>
)}
{entry && (
<>
<div style={separatorStyle} />
<button
style={itemStyle}
onMouseEnter={(event) => { event.currentTarget.style.background = 'var(--interactive-row-hover)'; }}
onMouseLeave={(event) => { event.currentTarget.style.background = 'none'; }}
onClick={() => {
setClipboard({ items: clipboardItems, mode: 'copy' });
setCtxMenu(null);
}}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
</svg>
Copy{clipboardItems.length > 1 ? ` (${clipboardItems.length})` : ''}
</button>
<button
style={itemStyle}
onMouseEnter={(event) => { event.currentTarget.style.background = 'var(--interactive-row-hover)'; }}
onMouseLeave={(event) => { event.currentTarget.style.background = 'none'; }}
onClick={() => {
setClipboard({ items: clipboardItems, mode: 'move' });
setCtxMenu(null);
}}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="6" cy="6" r="3" /><circle cx="6" cy="18" r="3" />
<line x1="20" y1="4" x2="8.12" y2="15.88" /><line x1="14.47" y1="14.48" x2="20" y2="20" /><line x1="8.12" y1="8.12" x2="12" y2="12" />
</svg>
Cut{clipboardItems.length > 1 ? ` (${clipboardItems.length})` : ''}
</button>
</>
)}
{clipboard && (
<>
<div style={separatorStyle} />
<button
style={itemStyle}
onMouseEnter={(event) => { event.currentTarget.style.background = 'var(--interactive-row-hover)'; }}
onMouseLeave={(event) => { event.currentTarget.style.background = 'none'; }}
onClick={() => {
handlePaste(path);
setCtxMenu(null);
}}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 4h2a2 2 0 012 2v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6a2 2 0 012-2h2" />
<rect x="8" y="2" width="8" height="4" rx="1" />
</svg>
Paste here{clipboard.items.length > 1 ? ` (${clipboard.items.length})` : ''}
</button>
</>
)}
{accessScope.canManage && entry && (
<>
<div style={separatorStyle} />
<button
style={{ ...itemStyle, color: 'var(--danger)' }}
onMouseEnter={(event) => { event.currentTarget.style.background = 'rgba(255,80,80,0.08)'; }}
onMouseLeave={(event) => { event.currentTarget.style.background = 'none'; }}
onClick={() => {
setCtxMenu(null);
if (!window.confirm(`Delete "${name}"?`)) return;
fbqProxy('delete', path, { headers: { 'X-Files': JSON.stringify([childPath]) } })
.then(() => load(path))
.catch(() => setError('Delete failed.'));
}}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3,6 5,6 21,6" />
<path d="M19 6l-1 14H6L5 6" />
<path d="M10 11v6" />
<path d="M14 11v6" />
<path d="M9 6V4h6v2" />
</svg>
Delete
</button>
</>
)}
</div>
);
})()}
</Layout>
);
}