1248 lines
50 KiB
React
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>
|
|
);
|
|
}
|