SPB Git

Repository Public

Explorer.tsx 44.0 KB 554 lines tsx

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
Files main
Menu

Theme