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.
) : (
)}
{(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 && (
<>
>
)}
);
})()}
);
}