Repository Public
1// The file explorer: one component for the folder view and the recent, starred, trash, search and duplicates views.2import { useQuery, useQueryClient } from '@tanstack/react-query';3import { type DragEvent, type KeyboardEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';4import { Link, useLocation, useSearch } from 'wouter';5import { ApiError, get, patch, post, qs } from '../api.ts';6import { Glyph } from '../glyphs.tsx';7import { errorText, useT } from '../i18n.ts';8import { Viewer } from '../preview/Viewer.tsx';9import { RequestDialog } from '../sharing/RequestDialog.tsx';10import { ShareDialog } from '../sharing/ShareDialog.tsx';11import { ownerSource } from '../sources.ts';12import type { Crumb, FileDetail, FileItem, Folder, Item, Listing, Tag } from '../types.ts';13import { Button, ConfirmDialog, cx, Dialog, EmptyState, ErrorState, IconButton, Menu, type MenuItem, useToast } from '../ui.tsx';14import { DetailsBody, fileKey } from './Details.tsx';15import { FolderPicker, NameDialog, TagsDialog, useTags } from './dialogs.tsx';16import { Duplicates } from './Duplicates.tsx';17import { ItemRow, ListHeader, type RowApi, Skeleton, type ViewMode } from './Items.tsx';18import { filesFromDrop, isTouchDevice, pickedFromInput, type Sel, toSel, triggerDownload, useMedia, usePref, XKEY, zipUrl } from './lib.ts';19import { UploadPanel, useUploadPanelVisible } from './UploadPanel.tsx';20import { uploads } from './uploads.ts';2122export type ExplorerView = 'folder' | 'recent' | 'starred' | 'trash' | 'search' | 'duplicates';23type SortKey = 'name' | 'updated' | 'size' | 'created';24type Dir = 'asc' | 'desc';25const SORTS: readonly SortKey[] = ['name', 'updated', 'size', 'created'];26const KINDS = ['any', 'folder', 'image', 'pdf', 'document', 'sheet', 'video', 'audio', 'archive', 'text'] as const;27const DRAG = 'application/x-spd-items';28const EMPTY = new Set<string>();29/** Items being dragged inside the page (a DataTransfer cannot be read while hovering). */30let dragSel: Sel | null = null;31const isInternalDrag = (e: DragEvent) => e.dataTransfer.types.includes(DRAG);32const isFileDrag = (e: DragEvent) => e.dataTransfer.types.includes('Files') && !isInternalDrag(e);3334type Dlg =35 | { type: 'newFolder' } | { type: 'rename'; item: Item } | { type: 'move' | 'copy'; items: Item[] } | { type: 'tags'; file: FileItem } | { type: 'share'; item: Item }36 | { type: 'request'; folder: Folder } | { type: 'purge'; items: Item[] } | { type: 'emptyTrash' } | { type: 'infected'; file: FileItem }37 | { type: 'moveConflict'; sel: Sel; dest: Crumb; name: string };3839export function Explorer({ view, folderId }: { view: ExplorerView; folderId?: string }) {40 const qc = useQueryClient();41 useEffect(() => { uploads.bind(qc); }, [qc]);42 return (43 <Screen key={`${view}:${folderId ?? ''}`} view={view} folderId={folderId} />44 );45}4647// --- breadcrumbs: every crumb is a link and a drop target; the middle collapses into a menu when space is short ---48function CrumbLink({ crumb, root, current, onDropTo }: { crumb: Crumb; root: boolean; current: boolean; onDropTo: (c: Crumb) => void }) {49 const t = useT();50 const [over, setOver] = useState(false);51 const name = root ? t('explorer.root') : crumb.name;52 const can = () => !!dragSel && !current && !dragSel.folderIds.includes(crumb.id);53 return (54 <Link href={root ? '/' : `/folder/${crumb.id}`} aria-current={current ? 'page' : undefined} draggable={false}55 onDragOver={(e) => { if (!can()) return; e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = 'move'; setOver(true); }}56 onDragLeave={() => setOver(false)}57 onDrop={(e) => { if (!can()) return; e.preventDefault(); e.stopPropagation(); setOver(false); onDropTo({ id: crumb.id, name }); }}58 className={cx('flex min-w-0 items-center gap-1.5 rounded-xl px-2 transition', current ? 'min-h-11 font-bold text-ink' : 'h-9 text-[15px] font-medium text-accent hover:bg-hover sm:h-10 sm:text-sm sm:text-ink-2 sm:hover:text-ink', over && 'bg-accent-soft ring-2 ring-accent')}>59 {root ? <Glyph name="home" size={18} className={cx('shrink-0', current && 'max-sm:hidden')} /> : null}60 <span className={cx('truncate', current ? 'text-[30px] leading-[1.15] tracking-[-0.03em] transition-[font-size] duration-300 group-data-[scrolled=true]/head:text-[19px] sm:text-xl sm:tracking-tight' : 'max-w-40')}>{name}</span>61 </Link>62 );63}64function Crumbs({ crumbs, onDropTo }: { crumbs: Crumb[]; onDropTo: (c: Crumb) => void }) {65 const t = useT();66 const [, navigate] = useLocation();67 const narrow = useMedia('(max-width: 639px)');68 const [menu, setMenu] = useState<DOMRect | null>(null);69 const tail = narrow ? 1 : 3;70 const collapse = crumbs.length > tail + 1;71 const hidden = collapse ? crumbs.slice(1, crumbs.length - tail) : [];72 const shown = collapse ? [crumbs[0]!, ...crumbs.slice(crumbs.length - tail)] : crumbs;73 return (74 <nav aria-label={t('explorer.breadcrumbs')} className="min-w-0">75 <ol className="-ml-2 flex min-w-0 flex-wrap items-center sm:flex-nowrap">76 {shown.map((c, i) => {77 const current = c === crumbs[crumbs.length - 1];78 return (79 <li key={c.id} className={cx('flex items-center', current ? 'min-w-0 max-sm:basis-full' : 'shrink-0')}>80 {i > 0 ? <Glyph name="chevronRight" size={14} className={cx('shrink-0 text-ink-3', current && !(i === 1 && hidden.length) && 'max-sm:hidden')} /> : null}81 {i === 1 && hidden.length ? (82 <>83 <button type="button" aria-label={t('explorer.crumbsHidden', { n: hidden.length })} aria-haspopup="menu" onClick={(e) => setMenu(e.currentTarget.getBoundingClientRect())}84 className="flex h-10 w-9 items-center justify-center rounded-xl text-ink-2 hover:bg-hover hover:text-ink"><Glyph name="dots" size={18} strokeWidth={2.4} /></button>85 <Glyph name="chevronRight" size={14} className={cx('shrink-0 text-ink-3', current && 'max-sm:hidden')} />86 </>87 ) : null}88 {current ? <h1 className="min-w-0"><CrumbLink crumb={c} root={c === crumbs[0]} current onDropTo={onDropTo} /></h1> : <CrumbLink crumb={c} root={c === crumbs[0]} current={false} onDropTo={onDropTo} />}89 </li>90 );91 })}92 </ol>93 {menu ? <Menu label={t('explorer.breadcrumbs')} anchor={{ x: menu.left, y: menu.bottom + 4 }} onClose={() => setMenu(null)} items={hidden.map((c) => ({ label: c.name, icon: 'folder', onSelect: () => navigate(`/folder/${c.id}`) }))} /> : null}94 </nav>95 );96}9798const sectionCls = 'mb-2.5 px-1 text-[13px] font-semibold tracking-wide text-ink-3 uppercase';99const selectCls = 'h-11 w-full min-w-0 rounded-[14px] border border-line-strong bg-surface px-3 text-base sm:text-sm text-ink focus:border-accent focus:outline-none focus:ring-4 focus:ring-accent/15';100function Filters({ params, tags, open, onChange }: { params: URLSearchParams; tags: Tag[]; open: boolean; onChange: (patch: Record<string, string | undefined>) => void }) {101 const t = useT();102 const [q, setQ] = useState(params.get('q') ?? '');103 const urlQ = params.get('q') ?? '';104 const typed = useRef(urlQ);105 useEffect(() => { if (urlQ !== typed.current) { typed.current = urlQ; setQ(urlQ); } }, [urlQ]); // the query changed from outside (top bar)106 useEffect(() => {107 if (q.trim() === typed.current) return;108 const id = setTimeout(() => { typed.current = q.trim(); onChange({ q: q.trim() || undefined }); }, 250);109 return () => clearTimeout(id);110 }, [q]); // eslint-disable-line react-hooks/exhaustive-deps111 const starred = params.get('starred') === '1';112 const field = (label: string, node: ReactNode, cls = '') => <label className={cx('flex min-w-0 flex-col gap-1 text-xs font-medium text-ink-3', cls)}>{label}{node}</label>;113 return (114 <form role="search" aria-label={t('explorer.filters.label')} onSubmit={(e) => e.preventDefault()} className={cx('mt-2 grid-cols-2 items-end gap-2 sm:grid sm:grid-cols-[minmax(10rem,2fr)_1fr_1fr_1fr_1fr_auto]', open ? 'grid' : 'hidden')}>115 {field(t('explorer.filters.q'), <input type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder={t('explorer.filters.qPlaceholder')} maxLength={200} className={selectCls} />, 'col-span-2 sm:col-span-1')}116 {field(t('explorer.filters.kind'), <select value={params.get('kind') ?? 'any'} onChange={(e) => onChange({ kind: e.target.value === 'any' ? undefined : e.target.value })} className={selectCls}>{KINDS.map((k) => <option key={k} value={k}>{t(`explorer.filters.kind.${k}`)}</option>)}</select>)}117 {field(t('explorer.filters.tag'), <select value={params.get('tag') ?? ''} onChange={(e) => onChange({ tag: e.target.value || undefined })} className={selectCls}><option value="">{t('explorer.filters.anyTag')}</option>{tags.map((g) => <option key={g.id} value={g.id}>{g.name}</option>)}</select>)}118 {field(t('explorer.filters.from'), <input type="date" value={params.get('from') ?? ''} max={params.get('to') ?? undefined} onChange={(e) => onChange({ from: e.target.value || undefined })} className={selectCls} />)}119 {field(t('explorer.filters.to'), <input type="date" value={params.get('to') ?? ''} min={params.get('from') ?? undefined} onChange={(e) => onChange({ to: e.target.value || undefined })} className={selectCls} />)}120 <button type="button" aria-pressed={starred} onClick={() => onChange({ starred: starred ? undefined : '1' })}121 className={cx('press col-span-2 inline-flex h-11 items-center justify-center gap-2 rounded-[14px] border px-3 text-sm font-medium sm:col-span-1', starred ? 'border-accent bg-accent-soft text-accent' : 'border-line-strong bg-surface text-ink-2 hover:bg-hover')}>122 <Glyph name="star" size={16} fill={starred ? 'currentColor' : 'none'} />{t('explorer.filters.starred')}123 </button>124 </form>125 );126}127128function Screen({ view, folderId }: { view: ExplorerView; folderId?: string }) {129 const t = useT();130 const qc = useQueryClient();131 const toast = useToast();132 const [location, navigate] = useLocation();133 const search = useSearch();134 const params = useMemo(() => new URLSearchParams(search), [search]);135 const wide = useMedia('(min-width: 1024px)');136 const touch = useMemo(isTouchDevice, []);137 const panelUp = useUploadPanelVisible();138139 // --- preferences, sort ---140 const [mode, setMode] = usePref<ViewMode>('spd.explorer.view', window.matchMedia('(max-width: 639px)').matches ? 'list' : 'grid', ['grid', 'list']);141 const [prefSort, setPrefSort] = usePref<SortKey>('spd.explorer.sort', 'name', SORTS);142 const [prefDir, setPrefDir] = usePref<Dir>('spd.explorer.dir', 'asc', ['asc', 'desc']);143 const sortable = view === 'folder' || view === 'search';144 const urlSort = params.get('sort') as SortKey | null, urlDir = params.get('dir');145 const sort: SortKey = view === 'search' ? (urlSort && SORTS.includes(urlSort) ? urlSort : 'name') : prefSort;146 const dir: Dir = view === 'search' ? (urlDir === 'desc' ? 'desc' : 'asc') : prefDir;147 const setSearch = useCallback((p: Record<string, string | undefined>) => {148 const cur = Object.fromEntries(new URLSearchParams(window.location.search));149 navigate(`/search${qs({ ...cur, ...p })}`, { replace: true });150 }, [navigate]);151 const applySort = (s: SortKey, d: Dir) => { if (view === 'search') setSearch({ sort: s === 'name' ? undefined : s, dir: d === 'asc' ? undefined : d }); else { setPrefSort(s); setPrefDir(d); } };152153 // --- data ---154 const path = view === 'folder' ? `/folders/${folderId ?? 'root'}${qs({ sort, dir })}`155 : view === 'search' ? `/search${qs({ q: params.get('q'), kind: params.get('kind'), tag: params.get('tag'), starred: params.get('starred') === '1' ? 1 : undefined, from: params.get('from'), to: params.get('to'), sort, dir })}`156 : `/${view}`;157 const listing = useQuery({158 queryKey: [XKEY, 'listing', path], queryFn: () => get<Listing>(path), enabled: view !== 'duplicates', placeholderData: (prev) => prev,159 // a file just uploaded gets its antivirus verdict and its thumbnail a moment later160 refetchInterval: (q) => (q.state.data?.files.some((f) => (f.scan === 'pending' || (!f.thumb && ['image', 'heic', 'pdf'].includes(f.kind))) && Date.now() - new Date(f.updatedAt).getTime() < 90_000) ? 4000 : false),161 });162 const tagsQ = useTags();163 const tags = useMemo(() => new Map((tagsQ.data ?? []).map((g) => [g.id, g])), [tagsQ.data]);164 const data = listing.data;165 const folder = view === 'folder' ? data?.folder : undefined;166 const crumbs = data?.breadcrumbs;167 const items = useMemo<Item[]>(() => (data ? [...data.folders, ...data.files] : []), [data]);168 const refresh = useCallback(() => qc.invalidateQueries({ queryKey: [XKEY] }), [qc]);169170 // --- state ---171 const [selected, setSelected] = useState<Set<string>>(EMPTY);172 const [focusId, setFocusId] = useState<string | null>(null);173 const anchor = useRef<string | null>(null);174 const wantFocus = useRef(false); // the keyboard focus belongs to the listing: give it back when a row moves or disappears175 const [menu, setMenu] = useState<{ items: MenuItem[]; anchor: { x: number; y: number } | DOMRect; label: string; rowId?: string } | null>(null);176 const [dlg, setDlg] = useState<Dlg | null>(null);177 const [detailsId, setDetailsId] = useState<string | null>(null);178 const [viewer, setViewer] = useState<FileItem | null>(null);179 const [dropping, setDropping] = useState(false);180 const dragDepth = useRef(0);181 const [scrolled, setScrolled] = useState(false); // the large title folds once the listing has moved182 const grid = useRef<HTMLDivElement>(null);183 const fileInput = useRef<HTMLInputElement>(null), dirInput = useRef<HTMLInputElement>(null), cameraInput = useRef<HTMLInputElement>(null);184185 const chosen = useMemo(() => items.filter((i) => selected.has(i.id)), [items, selected]);186 const byId = (id: string | null | undefined) => (id ? items.find((i) => i.id === id) : undefined);187 const tabId = focusId && items.some((i) => i.id === focusId) ? focusId : items[0]?.id ?? null;188 const focusRow = (id: string | null | undefined) => { if (id) grid.current?.querySelector<HTMLElement>(`[data-id="${id}"]`)?.focus(); };189 const fail = (e: unknown) => toast(errorText(e), { tone: 'error' });190 const detailsItem = byId(detailsId);191 useEffect(() => {192 if (wantFocus.current && !dlg && !menu && !viewer && tabId && document.activeElement === document.body) grid.current?.querySelector<HTMLElement>(`[data-id="${tabId}"]`)?.focus({ preventScroll: true });193 });194 useEffect(() => { if (detailsId && chosen.length === 1 && chosen[0]!.id !== detailsId) setDetailsId(chosen[0]!.id); }, [chosen, detailsId]); // the panel follows the selection195196 // --- deep link: /folder/<id>?file=<fileId> opens that file's preview ---197 const fileParam = view === 'folder' ? params.get('file') : null;198 const linked = useQuery({ queryKey: fileKey(fileParam ?? ''), queryFn: () => get<FileDetail>(`/files/${fileParam}`), enabled: !!fileParam });199 useEffect(() => {200 if (!fileParam) return;201 if (linked.data) { if (linked.data.file.scan === 'infected') toast(t('common.error.infected'), { tone: 'error' }); else setViewer(linked.data.file); }202 else if (linked.error) { fail(linked.error); navigate(location, { replace: true }); }203 }, [fileParam, linked.data, linked.error]); // eslint-disable-line react-hooks/exhaustive-deps204 const closeViewer = () => { setViewer(null); if (fileParam) navigate(location, { replace: true }); };205206 // --- actions ---207 const openItem = (it: Item) => {208 if (it.type === 'folder') { if (view === 'trash') toast(t('explorer.trash.restoreFirst')); else navigate(`/folder/${it.id}`); return; }209 if (it.scan === 'infected') { toast(t('common.error.infected'), { tone: 'error' }); return; }210 setViewer(it);211 };212 const downloadItems = (list: Item[]) => {213 const one = list.length === 1 ? list[0]! : null;214 if (one?.type === 'file') { if (one.scan === 'infected') setDlg({ type: 'infected', file: one }); else triggerDownload(ownerSource(one).contentUrl({ download: true })); return; }215 const sel = toSel(list.filter((i) => i.type === 'folder' || i.scan !== 'infected'));216 if (!sel.fileIds.length && !sel.folderIds.length) { toast(t('common.error.empty_selection'), { tone: 'error' }); return; }217 if (sel.fileIds.length + sel.folderIds.length < list.length) toast(t('explorer.zipSkipsInfected'));218 triggerDownload(zipUrl(sel));219 };220 const star = async (list: Item[], on: boolean) => {221 try { await Promise.all(list.map((i) => patch(`/${i.type === 'file' ? 'files' : 'folders'}/${i.id}`, { starred: on }))); toast(on ? t('explorer.starredDone', { n: list.length }) : t('explorer.unstarredDone', { n: list.length }), { tone: 'ok' }); } catch (e) { fail(e); }222 await refresh();223 };224 const restore = async (sel: Sel, quiet = false) => {225 try { await post('/restore', sel); if (!quiet) toast(t('explorer.restored', { n: sel.fileIds.length + sel.folderIds.length }), { tone: 'ok' }); setSelected(EMPTY); } catch (e) { fail(e); }226 await refresh();227 };228 const trash = async (list: Item[]) => {229 if (!list.length) return;230 const sel = toSel(list);231 try {232 await post('/trash', sel);233 setSelected(EMPTY);234 if (detailsId && list.some((i) => i.id === detailsId)) setDetailsId(null);235 toast(list.length === 1 ? t('explorer.trashedOne', { name: list[0]!.name }) : t('explorer.trashedN', { n: list.length }), { ms: 8000, action: { label: t('common.cancel'), run: () => void restore(sel, true) } });236 } catch (e) { fail(e); }237 await refresh();238 };239 const moveTo = async (sel: Sel, dest: Crumb, onConflict: 'fail' | 'rename' = 'fail') => {240 await post('/move', { ...sel, destinationId: dest.id, onConflict });241 setSelected(EMPTY);242 await refresh();243 toast(t('explorer.moved', { n: sel.fileIds.length + sel.folderIds.length, name: dest.name }), { tone: 'ok' });244 };245 const dropMove = (dest: Crumb) => {246 const sel = dragSel;247 dragSel = null;248 if (!sel) return;249 moveTo(sel, dest).catch((e) => { if (e instanceof ApiError && e.code === 'name_conflict') setDlg({ type: 'moveConflict', sel, dest, name: String(e.data.name ?? '') }); else fail(e); });250 };251 const upload = (list: FileList | null) => { if (folder && list?.length) uploads.enqueue(pickedFromInput(list), folder.id); };252 const single = (list: Item[]) => (list.length === 1 ? list[0]! : null);253254 const menuFor = (list: Item[]): MenuItem[] => {255 const one = single(list);256 const infected = one?.type === 'file' && one.scan === 'infected';257 const out: MenuItem[] = [];258 const add = (cond: unknown, m: MenuItem) => { if (cond) out.push(m); };259 if (view === 'trash') {260 add(true, { label: t('explorer.a.restore'), icon: 'restore', onSelect: () => void restore(toSel(list)) });261 add(one?.type === 'file' && !infected, { label: t('common.open'), icon: 'eye', onSelect: () => openItem(one!) });262 add(one?.type === 'file', { label: t('common.download'), icon: 'download', onSelect: () => downloadItems(list) });263 add(one, { label: t('explorer.a.details'), icon: 'info', onSelect: () => setDetailsId(one!.id) });264 add(true, { label: t('explorer.a.purge'), icon: 'trash', danger: true, separatorBefore: true, onSelect: () => setDlg({ type: 'purge', items: list }) });265 return out;266 }267 const allStarred = list.every((i) => i.starred);268 add(one && !infected, { label: t('common.open'), icon: one?.type === 'folder' ? 'folder' : 'eye', onSelect: () => openItem(one!), shortcut: '↵' });269 add(one && view !== 'folder', { label: t('explorer.a.reveal'), icon: 'external', onSelect: () => { const id = one!.type === 'file' ? one!.folderId : one!.parentId; navigate(id ? `/folder/${id}` : '/'); } });270 add(true, { label: list.length > 1 || one?.type === 'folder' ? t('explorer.a.downloadZip') : t('common.download'), icon: 'download', onSelect: () => downloadItems(list) });271 add(one && !infected, { label: t('explorer.a.share'), icon: 'share', onSelect: () => setDlg({ type: 'share', item: one! }) });272 add(one?.type === 'folder', { label: t('explorer.a.request'), icon: 'inbox', onSelect: () => setDlg({ type: 'request', folder: one as Folder }) });273 add(one, { label: t('explorer.a.rename'), icon: 'pencil', separatorBefore: true, onSelect: () => setDlg({ type: 'rename', item: one! }), shortcut: 'F2' });274 add(true, { label: t('explorer.a.move'), icon: 'move', separatorBefore: !one, onSelect: () => setDlg({ type: 'move', items: list }) });275 add(true, { label: t('explorer.a.copy'), icon: 'copy', onSelect: () => setDlg({ type: 'copy', items: list }) });276 add(true, { label: allStarred ? t('explorer.a.unstar') : t('explorer.a.star'), icon: 'star', onSelect: () => void star(list, !allStarred) });277 add(one?.type === 'file', { label: t('explorer.a.tags'), icon: 'tag', onSelect: () => setDlg({ type: 'tags', file: one as FileItem }) });278 add(one, { label: t('explorer.a.details'), icon: 'info', onSelect: () => setDetailsId(one!.id) });279 add(true, { label: t('explorer.a.trash'), icon: 'trash', danger: true, separatorBefore: true, onSelect: () => void trash(list), shortcut: '⌫' });280 return out;281 };282 const openMenu = (list: Item[], at: { x: number; y: number } | DOMRect, rowId?: string) => {283 if (list.length) setMenu({ items: menuFor(list), anchor: at, rowId, label: list.length === 1 ? t('explorer.actionsFor', { name: list[0]!.name }) : t('explorer.actionsForN', { n: list.length }) });284 };285 const closeMenu = useCallback(() => setMenu((m) => { if (m?.rowId) focusRow(m.rowId); return null; }), []);286287 // --- rows talk to the latest state through one stable object ---288 const live = useRef({ items, selected, chosen, openItem, openMenu, dropMove });289 live.current = { items, selected, chosen, openItem, openMenu, dropMove };290 const api = useMemo<RowApi>(() => {291 const item = (id: string) => live.current.items.find((i) => i.id === id);292 const range = (a: string, b: string) => { const ids = live.current.items.map((i) => i.id); const [x, y] = [ids.indexOf(a), ids.indexOf(b)].sort((m, n) => m - n); return x! < 0 ? [b] : ids.slice(x, y! + 1); };293 const toggle = (id: string) => setSelected((s) => { const n = new Set(s); if (n.has(id)) n.delete(id); else n.add(id); return n; });294 return {295 click(e, id, viaTouch) {296 const it = item(id);297 if (!it) return;298 e.stopPropagation();299 if (viaTouch) { if (live.current.selected.size) toggle(id); else live.current.openItem(it); return; }300 if (e.shiftKey && anchor.current) setSelected(new Set(range(anchor.current, id)));301 else if (e.metaKey || e.ctrlKey) { toggle(id); anchor.current = id; } else { setSelected(new Set([id])); anchor.current = id; }302 setFocusId(id);303 },304 open(id) { const it = item(id); if (it) live.current.openItem(it); },305 menu(id, at) {306 const it = item(id);307 if (!it) return;308 const inSel = live.current.selected.has(id);309 if (!inSel) { setSelected(new Set([id])); anchor.current = id; }310 live.current.openMenu(inSel ? live.current.chosen : [it], at, id);311 },312 longPress(id) { toggle(id); anchor.current = id; },313 focus(id) { wantFocus.current = true; setFocusId(id); },314 dragStart(e, id) {315 const it = item(id);316 if (!it) return;317 const list = live.current.selected.has(id) ? live.current.chosen : [it];318 if (!live.current.selected.has(id)) { setSelected(new Set([id])); anchor.current = id; }319 dragSel = toSel(list);320 e.dataTransfer.setData(DRAG, String(list.length));321 e.dataTransfer.effectAllowed = 'move';322 },323 dragEnd() { dragSel = null; },324 canDrop: (id) => !!dragSel && !dragSel.folderIds.includes(id),325 drop(id) { const it = item(id); if (it) live.current.dropMove({ id, name: it.name }); },326 };327 }, []);328329 // --- keyboard: roving focus, selection, shortcuts ---330 const vertical = (el: HTMLElement, step: 1 | -1): HTMLElement | null => {331 const rows = [...(grid.current?.querySelectorAll<HTMLElement>('[role=row][data-id]') ?? [])];332 const r = el.getBoundingClientRect(), mid = r.left + r.width / 2;333 let best: HTMLElement | null = null, bestD = Infinity, rowTop: number | null = null;334 for (let j = rows.indexOf(el) + step; j >= 0 && j < rows.length; j += step) {335 const b = rows[j]!.getBoundingClientRect();336 if (step > 0 ? b.top < r.bottom - 1 : b.bottom > r.top + 1) continue; // still on the same visual line337 if (rowTop === null) rowTop = b.top; else if (Math.abs(b.top - rowTop) > 1) break;338 const d = Math.abs(b.left + b.width / 2 - mid);339 if (d < bestD) { bestD = d; best = rows[j]!; }340 }341 return best;342 };343 const onKey = (e: KeyboardEvent) => {344 const el = e.target as HTMLElement;345 if (el.getAttribute('role') !== 'row' || !el.dataset.id) return; // keys typed on a row itself, not in one of its buttons346 const id = el.dataset.id, ids = items.map((i) => i.id), i = ids.indexOf(id), mod = e.metaKey || e.ctrlKey;347 const targets = chosen.length ? chosen : items.filter((x) => x.id === id);348 const go = (to: string | undefined) => {349 if (!to) return;350 e.preventDefault();351 if (e.shiftKey) { const a = anchor.current ?? id; anchor.current = a; const [x, y] = [ids.indexOf(a), ids.indexOf(to)].sort((m, n) => m - n); setSelected(new Set(ids.slice(Math.max(0, x!), y! + 1))); }352 focusRow(to);353 grid.current?.querySelector<HTMLElement>(`[data-id="${to}"]`)?.scrollIntoView({ block: 'nearest' });354 };355 if (e.key === 'ArrowDown') go(mode === 'list' ? ids[i + 1] : vertical(el, 1)?.dataset.id);356 else if (e.key === 'ArrowUp') go(mode === 'list' ? ids[i - 1] : vertical(el, -1)?.dataset.id);357 else if (e.key === 'ArrowRight' && mode === 'grid') go(ids[i + 1]);358 else if (e.key === 'ArrowLeft' && mode === 'grid') go(ids[i - 1]);359 else if (e.key === 'Home') go(ids[0]);360 else if (e.key === 'End') go(ids[ids.length - 1]);361 else if (e.key === ' ') { e.preventDefault(); anchor.current = id; setSelected((s) => { const n = new Set(s); if (n.has(id)) n.delete(id); else n.add(id); return n; }); }362 else if (e.key === 'Enter') { e.preventDefault(); const it = byId(id); if (it) openItem(it); }363 else if (e.key === 'a' && mod) { e.preventDefault(); setSelected(new Set(ids)); }364 else if (e.key === 'Escape') { if (selected.size) { e.preventDefault(); setSelected(EMPTY); } }365 else if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); if (view === 'trash') setDlg({ type: 'purge', items: targets }); else void trash(targets); }366 else if (e.key === 'F2' && view !== 'trash') { e.preventDefault(); if (targets.length === 1) setDlg({ type: 'rename', item: targets[0]! }); }367 else if (e.key === 'ContextMenu' || (e.key === 'F10' && e.shiftKey)) { e.preventDefault(); api.menu(id, el.getBoundingClientRect()); }368 };369370 // --- drop from the desktop ---371 const drag = {372 onDragEnter: (e: DragEvent) => { if (!isFileDrag(e)) return; dragDepth.current++; if (folder) setDropping(true); },373 onDragOver: (e: DragEvent) => { if (!isFileDrag(e)) return; e.preventDefault(); e.dataTransfer.dropEffect = folder ? 'copy' : 'none'; },374 onDragLeave: (e: DragEvent) => { if (!isFileDrag(e)) return; dragDepth.current = Math.max(0, dragDepth.current - 1); if (!dragDepth.current) setDropping(false); },375 onDrop: (e: DragEvent) => {376 if (!isFileDrag(e)) return;377 e.preventDefault(); dragDepth.current = 0; setDropping(false);378 if (!folder) return;379 const target = folder.id;380 void filesFromDrop(e.dataTransfer).then((picked) => { if (picked.length) uploads.enqueue(picked, target); else toast(t('explorer.dropNothing'), { tone: 'error' }); });381 },382 };383384 // --- header pieces ---385 const title = view === 'folder' ? (folder && !folder.parentId ? t('explorer.root') : folder?.name ?? '') : t(`explorer.view.${view}`);386 useEffect(() => { if (title) document.title = `${title} · ${t('common.appName')}`; }, [title, t]);387 const [filtersOpen, setFiltersOpen] = useState(false); // phones: the filters fold away, the top bar already holds the query388 const nFilters = ['kind', 'tag', 'from', 'to', 'starred'].filter((k) => params.get(k)).length;389 const [toolMenu, setToolMenu] = useState<{ kind: 'new' | 'sort'; rect: DOMRect } | null>(null);390 const newItems: MenuItem[] = [391 { label: t('explorer.newFolder'), icon: 'folderPlus', onSelect: () => setDlg({ type: 'newFolder' }) },392 { label: t('explorer.uploadFiles'), icon: 'upload', separatorBefore: true, onSelect: () => fileInput.current?.click() },393 { label: t('explorer.uploadFolder'), icon: 'folder', onSelect: () => dirInput.current?.click() },394 ...(touch ? [{ label: t('explorer.takePhoto'), icon: 'camera', onSelect: () => cameraInput.current?.click() }] : []),395 ];396 const sortItems: MenuItem[] = [397 ...SORTS.map((s) => ({ label: t(`explorer.sort.${s}`), icon: s === sort ? 'check' : 'blank', onSelect: () => applySort(s, dir) })),398 { label: t('explorer.sort.asc'), icon: dir === 'asc' ? 'check' : 'blank', separatorBefore: true, onSelect: () => applySort(sort, 'asc') },399 { label: t('explorer.sort.desc'), icon: dir === 'desc' ? 'check' : 'blank', onSelect: () => applySort(sort, 'desc') },400 ];401 const allStarred = chosen.length > 0 && chosen.every((i) => i.starred);402 const toolbar = chosen.length ? (403 <div role="toolbar" aria-label={t('explorer.selection.label')} className="anim-pop flex w-full items-center gap-0.5 rounded-full bg-accent-soft pr-1 ring-1 ring-accent/20 ring-inset max-sm:[&>button]:w-9 sm:w-auto" data-testid="selection-bar">404 <IconButton icon="x" label={t('explorer.selection.clear')} onClick={() => setSelected(EMPTY)} />405 <span className="mr-auto min-w-0 truncate px-1 text-[15px] font-semibold whitespace-nowrap text-ink tabular-nums sm:mr-2 sm:text-sm" aria-live="polite">{t('explorer.selection.n', { n: chosen.length })}</span>406 {view === 'trash' ? (407 <>408 <IconButton icon="restore" label={t('explorer.a.restore')} onClick={() => void restore(toSel(chosen))} />409 <IconButton icon="trash" label={t('explorer.a.purge')} className="text-danger" onClick={() => setDlg({ type: 'purge', items: chosen })} />410 </>411 ) : (412 <>413 <IconButton icon="download" label={chosen.length > 1 || chosen[0]!.type === 'folder' ? t('explorer.a.downloadZip') : t('common.download')} onClick={() => downloadItems(chosen)} />414 <IconButton icon="move" label={t('explorer.a.move')} onClick={() => setDlg({ type: 'move', items: chosen })} />415 <IconButton icon="star" label={allStarred ? t('explorer.a.unstar') : t('explorer.a.star')} onClick={() => void star(chosen, !allStarred)} />416 <IconButton icon="trash" label={t('explorer.a.trash')} onClick={() => void trash(chosen)} />417 </>418 )}419 <IconButton icon="dots" label={t('common.more')} aria-haspopup="menu" onClick={(e) => openMenu(chosen, e.currentTarget.getBoundingClientRect())} />420 </div>421 ) : (422 <div className="flex w-full items-center gap-1 sm:w-auto">423 {view === 'folder' ? <Button variant="primary" icon="plus" className="order-last ml-1 max-sm:rounded-full" disabled={!folder} aria-haspopup="menu" onClick={(e) => setToolMenu({ kind: 'new', rect: e.currentTarget.getBoundingClientRect() })}>{t('explorer.new')}</Button> : null}424 {view === 'trash' ? <Button icon="trash" className="sm:order-last sm:ml-1" disabled={!items.length} onClick={() => setDlg({ type: 'emptyTrash' })}>{t('explorer.trash.empty')}</Button> : null}425 {view === 'search' ? <Button icon="filter" className="sm:hidden" aria-expanded={filtersOpen} onClick={() => setFiltersOpen(!filtersOpen)}>{nFilters ? t('explorer.filters.toggleN', { n: nFilters }) : t('explorer.filters.toggle')}</Button> : null}426 <span className="flex-1" />427 {sortable ? (428 <button type="button" aria-haspopup="menu" aria-label={t('explorer.sort.label', { by: t(`explorer.sort.${sort}`) })} onClick={(e) => setToolMenu({ kind: 'sort', rect: e.currentTarget.getBoundingClientRect() })}429 className="press inline-flex h-10 items-center gap-1.5 rounded-full bg-surface px-3.5 text-sm font-medium text-ink-2 shadow-(--shadow-card) hover:text-ink max-sm:order-first">430 <Glyph name="sort" size={17} /><span>{t(`explorer.sort.${sort}`)}</span><Glyph name={dir === 'asc' ? 'arrowUp' : 'arrowDown'} size={14} />431 </button>432 ) : null}433 {view !== 'duplicates' ? <IconButton icon={mode === 'grid' ? 'list' : 'grid'} label={mode === 'grid' ? t('explorer.toList') : t('explorer.toGrid')} data-testid="view-toggle" data-mode={mode} onClick={() => setMode(mode === 'grid' ? 'list' : 'grid')} /> : null}434 </div>435 );436437 // --- body ---438 const notFound = listing.error instanceof ApiError && listing.error.status === 404;439 let body: ReactNode;440 if (view === 'duplicates') body = <Duplicates onOpen={openItem} onTrash={(files) => void trash(files)} />;441 else if (listing.isLoading) body = <Skeleton mode={mode} />;442 else if (view === 'folder' && (notFound || (listing.error instanceof ApiError && listing.error.code === 'invalid_request'))) {443 body = <EmptyState icon="folder" title={t('explorer.notFound.title')} text={t('explorer.notFound.text')} action={<Link href="/" className="btn-grad press inline-flex h-11 items-center gap-2 rounded-[14px] px-5 text-[15px] font-semibold"><Glyph name="home" size={18} />{t('explorer.notFound.home')}</Link>} />;444 } else if (listing.error && !data) body = <ErrorState error={listing.error} onRetry={() => void listing.refetch()} />;445 else if (!items.length) {446 body = view === 'folder' ? <EmptyState icon="upload" title={t('explorer.empty.folder')} text={touch ? t('explorer.empty.folderTextTouch') : t('explorer.empty.folderText')} action={<Button variant="primary" icon="upload" onClick={() => fileInput.current?.click()}>{t('explorer.uploadFiles')}</Button>} />447 : view === 'trash' ? <EmptyState icon="trash" title={t('explorer.empty.trash')} text={t('explorer.empty.trashText')} />448 : view === 'search' ? <EmptyState icon="search" title={t('explorer.empty.search')} text={t('explorer.empty.searchText')} />449 : view === 'starred' ? <EmptyState icon="star" title={t('explorer.empty.starred')} text={t('explorer.empty.starredText')} />450 : <EmptyState icon="clock" title={t('explorer.empty.recent')} text={t('explorer.empty.recentText')} />;451 } else {452 const row = (it: Item, index: number) => <ItemRow key={it.id} item={it} index={index} mode={mode} selected={selected.has(it.id)} tabbable={it.id === tabId} tags={tags} api={api} />;453 const cols = 'stagger grid grid-cols-2 gap-3 sm:grid-cols-[repeat(auto-fill,minmax(12.5rem,1fr))] sm:gap-4';454 const nFolders = data!.folders.length;455 body = (456 <div ref={grid} role="grid" aria-label={t('explorer.listLabel', { name: title })} aria-multiselectable="true" aria-rowcount={items.length} onKeyDown={onKey}457 onBlur={(e) => { const to = e.relatedTarget as HTMLElement | null; if (to && !e.currentTarget.contains(to) && !to.closest('[role=dialog],[role=menu]')) wantFocus.current = false; }} data-testid="listing" data-mode={mode} className={cx(mode === 'list' && 'stagger card overflow-hidden')}>458 {mode === 'list' ? (459 <>460 <ListHeader sort={sortable ? sort : undefined} dir={dir} onSort={sortable ? (k) => applySort(k, sort === k && dir === 'asc' ? 'desc' : 'asc') : undefined} />461 {items.map(row)}462 </>463 ) : (464 <>465 {nFolders ? <><p className={sectionCls}>{t('explorer.section.folders')}</p><div role="rowgroup" className={cx(cols, 'mb-6')}>{data!.folders.map(row)}</div></> : null}466 {data!.files.length ? <><p className={sectionCls}>{t('explorer.section.files')}</p><div role="rowgroup" className={cols}>{data!.files.map((f, i) => row(f, nFolders + i))}</div></> : null}467 </>468 )}469 {view === 'search' && (data!.files.length >= 500 || nFolders >= 300) ? <p className="py-3 text-center text-xs text-ink-3">{t('explorer.searchCapped')}</p> : null}470 </div>471 );472 }473474 const detailsProps = detailsItem ? { item: detailsItem, tags, folderPath: detailsItem.type === 'folder' && view === 'folder' ? crumbs : undefined, onTags: (f: FileItem) => setDlg({ type: 'tags', file: f }), onShare: (i: Item) => setDlg({ type: 'share', item: i }), onClose: () => setDetailsId(null) } : null;475 const previewable = useMemo(() => (data?.files ?? []).filter((f) => f.scan !== 'infected'), [data]);476 const vi = viewer ? previewable.findIndex((f) => f.id === viewer.id) : -1;477 const viewerFile = viewer ? previewable[vi] ?? viewer : null;478 const moving = dlg?.type === 'move' || dlg?.type === 'copy' ? dlg : null;479480 return (481 <div className="flex h-full min-h-0 flex-col" {...drag}>482 <header data-scrolled={scrolled} className={cx('group/head z-10 shrink-0 px-4 pb-2.5 transition-shadow duration-300 sm:px-6 sm:pt-1', scrolled && 'hairline-b')}>483 <div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">484 <div className="min-w-0 basis-full sm:flex-1 sm:basis-0">485 {view === 'folder' && crumbs?.length ? <Crumbs crumbs={crumbs} onDropTo={dropMove} />486 : <h1 className="flex min-h-11 items-center truncate text-[30px] leading-[1.15] font-bold tracking-[-0.03em] text-ink transition-[font-size] duration-300 group-data-[scrolled=true]/head:text-[19px] sm:text-xl sm:tracking-tight">{view === 'folder' ? (listing.isLoading ? ' ' : t('explorer.notFound.title')) : title}</h1>}487 </div>488 {toolbar}489 </div>490 {view === 'search' ? <Filters params={params} tags={tagsQ.data ?? []} open={filtersOpen} onChange={setSearch} /> : null}491 {view === 'trash' && items.length ? <p className="px-1 pt-1.5 text-[13px] text-ink-3">{t('explorer.trash.note')}</p> : null}492 </header>493 <div className="relative flex min-h-0 flex-1">494 <div className="relative flex min-w-0 flex-1 flex-col">495 <div className={cx('min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain px-4 pt-2 sm:px-6', panelUp ? 'pb-[calc(var(--dock)+7.5rem)] lg:pb-8' : 'pb-dock')}496 onScroll={(e) => { const y = e.currentTarget.scrollTop; if (y > 28 && !scrolled) setScrolled(true); else if (y < 4 && scrolled) setScrolled(false); }} onClick={(e) => { if (!(e.target as HTMLElement).closest('[role=row],button,a,input')) { setSelected(EMPTY); wantFocus.current = false; } }}>497 {body}498 </div>499 <UploadPanel />500 </div>501 {detailsProps && wide ? (502 <aside aria-label={t('explorer.a.details')} className="card anim-pop mr-4 mb-4 w-[21rem] shrink-0 overflow-y-auto p-5" data-testid="details">503 <div className="mb-3 flex items-center justify-between"><h2 className="text-[17px] font-bold tracking-tight text-ink">{t('explorer.a.details')}</h2><IconButton icon="x" label={t('common.close')} className="-my-2 -mr-2" onClick={() => setDetailsId(null)} /></div>504 <DetailsBody key={detailsProps.item.id} {...detailsProps} />505 </aside>506 ) : null}507 {dropping && folder ? (508 <div className="anim-pop pointer-events-none absolute inset-3 z-30 flex flex-col items-center justify-center gap-4 rounded-[28px] border-2 border-dashed border-accent bg-accent-soft/85 text-accent backdrop-blur-md" data-testid="drop-overlay">509 <span className="btn-grad anim-float flex h-20 w-20 items-center justify-center rounded-[26px]"><Glyph name="upload" size={38} /></span>510 <p className="px-6 text-center text-xl font-bold tracking-tight">{t('explorer.dropHere', { name: title })}</p>511 </div>512 ) : null}513 </div>514515 <input ref={fileInput} type="file" multiple hidden data-testid="upload-input" onChange={(e) => { upload(e.target.files); e.target.value = ''; }} />516 <input ref={dirInput} type="file" multiple hidden data-testid="upload-dir-input" {...{ webkitdirectory: '' }} onChange={(e) => { upload(e.target.files); e.target.value = ''; }} />517 <input ref={cameraInput} type="file" accept="image/*" capture="environment" hidden data-testid="camera-input" onChange={(e) => { upload(e.target.files); e.target.value = ''; }} />518519 {menu ? <Menu items={menu.items} anchor={menu.anchor} label={menu.label} onClose={closeMenu} /> : null}520 {toolMenu ? <Menu items={toolMenu.kind === 'new' ? newItems : sortItems} label={toolMenu.kind === 'new' ? t('explorer.new') : t('explorer.sort.menu')} anchor={toolMenu.kind === 'new' && !wide ? { x: toolMenu.rect.left, y: toolMenu.rect.bottom + 4 } : toolMenu.rect} onClose={() => setToolMenu(null)} /> : null}521 {detailsProps && !wide ? <Dialog title={t('explorer.a.details')} onClose={() => setDetailsId(null)}><div data-testid="details"><DetailsBody key={detailsProps.item.id} {...detailsProps} /></div></Dialog> : null}522523 {dlg?.type === 'newFolder' && folder ? <NameDialog title={t('explorer.newFolder')} label={t('explorer.name.folder')} submitLabel={t('common.create')} onClose={() => setDlg(null)}524 onSubmit={async (name) => { await post('/folders', { parentId: folder.id, name }); await refresh(); }} /> : null}525 {dlg?.type === 'rename' ? <NameDialog title={t('explorer.a.rename')} label={t('explorer.name.new')} initial={dlg.item.name} selectStem={dlg.item.type === 'file'} submitLabel={t('explorer.a.rename')} onClose={() => setDlg(null)}526 onSubmit={async (name) => { await patch(`/${dlg.item.type === 'file' ? 'files' : 'folders'}/${dlg.item.id}`, { name }); await refresh(); }} /> : null}527 {moving ? <FolderPicker title={moving.type === 'move' ? t('explorer.pick.moveTitle', { n: moving.items.length }) : t('explorer.pick.copyTitle', { n: moving.items.length })}528 confirmLabel={moving.type === 'move' ? t('explorer.pick.moveHere') : t('explorer.pick.copyHere')} allowKeepBoth={moving.type === 'move'} blockedIds={moving.type === 'move' ? toSel(moving.items).folderIds : []} onClose={() => setDlg(null)}529 onConfirm={async (dest, onConflict) => {530 const sel = toSel(moving.items);531 if (moving.type === 'move') { await moveTo(sel, dest, onConflict); return; }532 await post('/copy', { ...sel, destinationId: dest.id });533 setSelected(EMPTY); await refresh();534 toast(t('explorer.copied', { n: moving.items.length, name: dest.name }), { tone: 'ok' });535 }} /> : null}536 {dlg?.type === 'tags' ? <TagsDialog file={dlg.file} onClose={() => setDlg(null)} /> : null}537 {dlg?.type === 'share' ? <ShareDialog target={{ type: dlg.item.type, id: dlg.item.id, name: dlg.item.name }} onClose={() => { setDlg(null); void refresh(); }} /> : null}538 {dlg?.type === 'request' ? <RequestDialog folder={{ id: dlg.folder.id, name: dlg.folder.name }} onClose={() => setDlg(null)} /> : null}539 {dlg?.type === 'purge' ? <ConfirmDialog danger title={t('explorer.purge.title')} text={dlg.items.length === 1 ? t('explorer.purge.textOne', { name: dlg.items[0]!.name }) : t('explorer.purge.textN', { n: dlg.items.length })} confirmLabel={t('explorer.a.purge')} onClose={() => setDlg(null)}540 onConfirm={async () => { await post('/purge', toSel(dlg.items)); setSelected(EMPTY); setDetailsId(null); await refresh(); toast(t('explorer.purged', { n: dlg.items.length }), { tone: 'ok' }); }} /> : null}541 {dlg?.type === 'emptyTrash' ? <ConfirmDialog danger title={t('explorer.trash.empty')} text={t('explorer.trash.emptyText', { n: items.length })} confirmLabel={t('explorer.trash.empty')} onClose={() => setDlg(null)}542 onConfirm={async () => { await post('/purge', { all: true }); setSelected(EMPTY); setDetailsId(null); await refresh(); toast(t('explorer.trash.emptied'), { tone: 'ok' }); }} /> : null}543 {dlg?.type === 'infected' ? <ConfirmDialog danger title={t('explorer.infected')} text={t('explorer.infectedDownload', { name: dlg.file.name })} confirmLabel={t('explorer.infectedConfirm')} onClose={() => setDlg(null)}544 onConfirm={() => triggerDownload(ownerSource(dlg.file).contentUrl({ download: true }))} /> : null}545 {dlg?.type === 'moveConflict' ? <ConfirmDialog title={t('explorer.a.move')} text={`${t('explorer.pick.conflict', { name: dlg.name })} ${t('explorer.pick.keepBothHint')}`} confirmLabel={t('explorer.keepBoth')} onClose={() => setDlg(null)}546 onConfirm={() => moveTo(dlg.sel, dlg.dest, 'rename')} /> : null}547548 {viewerFile ? <Viewer source={ownerSource(viewerFile)} onClose={closeViewer}549 onPrev={vi > 0 ? () => setViewer(previewable[vi - 1]!) : undefined} onNext={vi >= 0 && vi < previewable.length - 1 ? () => setViewer(previewable[vi + 1]!) : undefined}550 actions={view !== 'trash' ? <IconButton icon="share" label={t('explorer.a.share')} onClick={() => setDlg({ type: 'share', item: viewerFile })} /> : undefined} /> : null}551 </div>552 );553}554