import { lazy, Suspense, useEffect, useEffectEvent, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Download, LayoutGrid, Link, List, Scissors, Share2, UploadCloud } from "lucide-react"; import { api, type FileFolder, type FileListParams, type LibraryFile } from "../lib/api"; import { type SortState, sortRows } from "../lib/columnSort"; import { libraryRowActions, sharedRowActions } from "../lib/downloadActions"; import { formatBytes, formatDateTime, relativeTime } from "../lib/format"; import { focusNavItem } from "../lib/listNav"; import { notify } from "../lib/notifications"; import { qk } from "../lib/queryKeys"; import { LS, accountKey } from "../lib/storage"; import { useCardPager } from "../lib/useCardPager"; import { useLiveQuery } from "../lib/useLiveQuery"; import { usePref } from "../lib/usePref"; import { JobThumb } from "./downloadRow"; import { MetaEditModal } from "./downloadModals"; import { jobTitle } from "./downloadTitle"; import { FileCard } from "./FileCard"; import { PhoneBadge, RetentionBadge, SourceBadge } from "./fileBadges"; import { folderLabel } from "./fileFolders"; import { ALL_FILES, useFiles, useFilesActions, viewParam, type FilesView } from "./filesContext"; import { useFileActions } from "./useFileActions"; import { useFolderActions } from "./useFolderActions"; import { RenameField } from "./RenameField"; import ViewSwitcher, { type ViewOption } from "./ViewSwitcher"; import { Badge } from "./ui/Badge"; import { Button } from "./ui/Button"; import ColumnSortControl from "./ui/ColumnSortControl"; import { ContextMenu } from "./ui/ContextMenu"; import DataTable, { type Column } from "./ui/DataTable"; import { IconButton } from "./ui/IconButton"; import { IconLink } from "./ui/LinkButton"; import { MoreButton } from "./ui/MoreButton"; import NavGrid from "./ui/NavGrid"; import Overlay from "./ui/Overlay"; import { PageToolbar } from "./ui/PageShell"; import { LinkBox } from "./LinkBox"; import Pager from "./ui/Pager"; import { Pressable } from "./ui/Pressable"; import { LoadingState, StateMessage } from "./ui/QueryState"; import { useContextMenu } from "./ui/useContextMenu"; // Lazy: these open on demand, so they stay out of the Files chunk until used (the video editor in // particular is heavy — filmstrip + scrubber). const UploadDialog = lazy(() => import("./UploadDialog")); const ShareDialog = lazy(() => import("./ShareDialog")); const LibraryPlayer = lazy(() => import("./LibraryPlayer")); const FilePreview = lazy(() => import("./FilePreview")); const VideoEditor = lazy(() => import("./VideoEditor")); // The Files library: every done file of mine, the ones shared with me, or one folder's (the rail // picks — FilesProvider holds it), searched by the header's box. One aligned table: every kind of // value in its own column, an action that does not apply leaving its slot empty, the same menu on a // right click and on the "…" (DESIGN.md "Files page", "Library list columns"). The grid is the same // rows' other paint — a toggle remembered per folder, sharing the table's sort. // // A link or a notice can open the page on ONE file (`openFile`, `?item=`): the page turns to it, puts // the keyboard on it and rings it until the view changes. A file dropped anywhere on the page goes // into the Upload dialog, bound for the folder on screen. const fileKey = (f: LibraryFile) => f.id; const folderKey = (f: FileFolder) => f.id; type FilesLayout = "list" | "grid"; const LAYOUTS: readonly FilesLayout[] = ["list", "grid"]; const LAYOUT_ICON = { list: List, grid: LayoutGrid } as const; /** A stored per-view layout map: the pref round-trips the server, so it is checked, not trusted. */ function parseLayouts(raw: unknown): Record { if (!raw || typeof raw !== "object") return {}; return Object.fromEntries( Object.entries(raw).filter((e): e is [string, FilesLayout] => LAYOUTS.includes(e[1])), ); } /** Put the keyboard on a file's row or card (lib/listNav), whichever paint is on screen. */ function focusFile(within: HTMLElement | null, id: number): void { const item = within && shownFileElement(within, id)?.closest("[data-nav-index]"); if (within && item) focusNavItem(within, Number(item.getAttribute("data-nav-index"))); } /** The element a file is painted as on screen — its table row, the row's narrow card twin, or its * grid card, whichever is shown (DataTable keeps both row shapes mounted). */ function shownFileElement(within: HTMLElement, id: number): HTMLElement | null { const all = [...within.querySelectorAll(`[data-file-id="${id}"]`)]; // Nothing laid out at all (no layout engine, as under test): the first is the only one there is. return all.find((el) => el.getClientRects().length > 0) ?? all[0] ?? null; } /** The list a view reads. The search is applied here, on the rows (the list is the whole library, * capped server-side): a request per keystroke would buy nothing the rows do not already hold. */ function listParams(view: FilesView): FileListParams { if (view.kind === "shared") return { shared: true }; if (view.kind === "folder") return { folderId: view.id }; return {}; } function matches(file: LibraryFile, q: string, lang: string): boolean { if (!q) return true; const hay = `${jobTitle(file)} ${file.display_uploader ?? file.asset?.uploader ?? ""}`; return hay.toLocaleLowerCase(lang).includes(q.toLocaleLowerCase(lang)); } /** Where an upload from this view lands: the folder itself when it may take one (an own folder, the * Uploads folder — `folders.upload_target`), else the Uploads folder. */ function uploadFolder(view: FilesView, folders: readonly FileFolder[]): number | null { if (view.kind !== "folder") return null; const f = folders.find((x) => x.id === view.id); return f && (f.kind === "own" || f.source_key === "upload") ? f.id : null; } function NameCell({ file, renaming, onOpen, onRenamed, }: { file: LibraryFile; renaming: boolean; onOpen: (() => void) | null; onRenamed: (name: string | null, byKey: boolean) => void; }) { const { t } = useTranslation(); const channel = file.display_uploader || file.asset?.uploader || null; const clip = file.job_kind === "edit" || file.job_kind === "clip"; if (renaming) return ( ); const title = {jobTitle(file)}; return (
{onOpen ? ( {title} ) : ( {title} )} {channel ?? "—"} {clip && ( {t("downloads.clipBadge")} )}
); } export default function Files() { const { t, i18n } = useTranslation(); const lang = i18n.language; const { search, view, focus, arrived } = useFiles(); const { setView, consumeFocus } = useFilesActions(); const foldersQ = useLiveQuery(qk.fileFolders(), api.fileFolders, { intervalMs: 15000 }); const folders = foldersQ.data ?? []; // A remembered or bookmarked folder that is gone (deleted, or another account's id) opens the whole // library instead of an empty page that names nothing; the provider is told, so the rail agrees. const gone = view.kind === "folder" && !!foldersQ.data && !folders.some((f) => f.id === view.id); const shown = gone ? ALL_FILES : view; useEffect(() => { if (gone) setView(ALL_FILES); }, [gone, setView]); const params = listParams(shown); // Live at rest: a download finishing elsewhere (the queue, a clip, another tab) lands here on its own. const filesQ = useLiveQuery(qk.files(params), () => api.files(params), { intervalMs: 15000 }); const all = filesQ.data ?? []; const q = search.trim(); const rows = all.filter((f) => matches(f, q, lang)); const listRef = useRef(null); const [renamingId, setRenamingId] = useState(null); // A rename left by Enter or Escape hands the keyboard back to its row (RenameField). const refocusFile = useRef(null); useEffect(() => { const id = refocusFile.current; if (renamingId !== null || id === null) return; refocusFile.current = null; focusFile(listRef.current, id); }, [renamingId]); // The Upload dialog while open, with the file a drop on the page brought (null: pick one in it). const [upload, setUpload] = useState<{ file: File | null } | null>(null); const [metaFile, setMetaFile] = useState(null); const [shareFile, setShareFile] = useState(null); const [editFile, setEditFile] = useState(null); const [playing, setPlaying] = useState<{ file: LibraryFile; fromStart: boolean } | null>(null); const play = (file: LibraryFile, fromStart = false) => setPlaying({ file, fromStart }); // The file open in the viewer (one that is not media), by id: the list under it keeps re-reading. const [viewing, setViewing] = useState(null); const actions = useFileActions({ folders, onPlay: play, onView: (f) => setViewing(f.id), onEdit: setEditFile, onRename: (f) => setRenamingId(f.id), onEditDetails: setMetaFile, onManageShares: setShareFile, }); // Keyed by id: the list re-reads every 15 s, replacing the file under an open menu, and its "…" // must still close it. const rowMenu = useContextMenu({ keyOf: fileKey }); const menuItems = rowMenu.target ? actions.items(rowMenu.target) : []; const open = shown.kind === "folder" ? folders.find((f) => f.id === shown.id) : undefined; // The open folder's menu, on the "…" beside its name — the one its rail entry's right click opens. const [renamingFolder, setRenamingFolder] = useState(false); const folderActions = useFolderActions({ onRename: () => setRenamingFolder(true) }); const folderMenu = useContextMenu({ keyOf: folderKey }); // Its rename left by a key hands the keyboard back to the "…" it was opened from. const folderMore = useRef(null); const refocusFolderMore = useRef(false); useEffect(() => { if (renamingFolder || !refocusFolderMore.current) return; refocusFolderMore.current = false; folderMore.current?.focus(); }, [renamingFolder]); // A folder view names its folder once the folder list is in (blank until then, never a guess). const title = shown.kind === "shared" ? t("files.rail.shared") : shown.kind === "folder" ? open ? folderLabel(open, t) : "" : t("files.rail.all"); const canUpload = shown.kind !== "shared"; const uploadTarget = uploadFolder(shown, folders); const uploadTargetName = uploadTarget !== null && open ? folderLabel(open, t) : t("files.source.upload"); // List or grid, per view (DESIGN.md "Files page": the grid a toggle remembered per folder). const [layouts, setLayouts] = usePref>( "filesLayouts", {}, { localKey: LS.filesLayouts, parse: parseLayouts, }, ); const viewKey = viewParam(shown); const layout: FilesLayout = layouts[viewKey] ?? "list"; const isGrid = layout === "grid"; // One sort for both paints: the table's headers and the grid's dropdown move the same state. const [sort, setSort] = usePref("filesSort", null, { localKey: LS.filesSort }); // ── Arrival at one file ───────────────────────────────────────────────────────────────────────── // A pending arrival (`focus`) is looked for once the list is in: found here, it becomes `arrived` // (FilesProvider), which turns the table's or the grid's page to the file (`reveal`), puts the // keyboard on it and rings it while this view is open. Not in this view, it is looked for among // every file; nowhere (gone since the link was made), it leaves nothing to arrive at, and is spent. const here = !!focus && !!filesQ.data?.some((f) => f.id === focus.id); // "Not here" only once the list is fresh: a just-finished upload is not in the cached rows yet. const missing = !!focus && !!filesQ.data && !here && !filesQ.isFetching; const lookEverywhere = missing && shown.kind !== "all"; useEffect(() => { if (lookEverywhere) setView(ALL_FILES); else if (here) consumeFocus(viewKey); else if (missing) consumeFocus(null); }, [here, missing, lookEverywhere, viewKey, setView, consumeFocus]); const arrival = arrived && arrived.view === viewKey ? arrived : null; const ringed = arrival?.id ?? null; const reveal = arrival && { key: String(arrival.id), seq: arrival.token }; // The keyboard half, once the page that holds the file has painted. const focusArrived = useEffectEvent(() => { if (ringed !== null) focusFile(listRef.current, ringed); }); // Once per arrival (its token), not on every list re-read or layout switch. const arrivalToken = arrival?.token; useEffect(() => { if (arrivalToken !== undefined) focusArrived(); }, [arrivalToken]); // ── A file dropped anywhere on the page ───────────────────────────────────────────────────────── // The page is the target, not a box on it: a short folder leaves most of the screen empty. Window // listeners, since the drag starts outside the app. While the dialog is open its own zone takes the // drop; a drag that carries no files (a link, selected text) is not ours. const [dragging, setDragging] = useState(false); const dragDepth = useRef(0); const dropReady = canUpload && upload === null; const onDrag = useEffectEvent((e: DragEvent) => { if (!dropReady || !e.dataTransfer?.types.includes("Files")) return; e.preventDefault(); if (e.type === "dragenter") dragDepth.current += 1; if (e.type === "dragleave") dragDepth.current = Math.max(0, dragDepth.current - 1); if (e.type === "drop") { dragDepth.current = 0; const dropped = e.dataTransfer.files; const first = dropped.item(0); if (first) setUpload({ file: first }); if (dropped.length > 1) notify({ level: "info", message: t("files.drop.oneAtATime") }); } setDragging(dragDepth.current > 0); }); useEffect(() => { const on = (e: DragEvent) => onDrag(e); const kinds = ["dragenter", "dragover", "dragleave", "drop"] as const; for (const k of kinds) window.addEventListener(k, on); return () => { for (const k of kinds) window.removeEventListener(k, on); }; }, []); const iconSlot = ; // What a row's name or a card's picture does: plays it, or opens it in the viewer. const openOf = (f: LibraryFile) => { const a = f.shared ? sharedRowActions(f) : libraryRowActions(f); return a.play ? () => play(f) : a.view ? () => setViewing(f.id) : null; }; const columns: Column[] = [ { key: "thumb", header: "", width: "5.5rem", hideInCard: true, // A narrow table (a pinned rail beside it) gives its fixed columns' room to the name: with the // picture and the date in, the name had none left. narrowHidden: true, render: (f) => ( ), }, { key: "name", header: t("files.columns.name"), cardPrimary: true, sortable: true, sortValue: (f) => jobTitle(f), render: (f) => ( { if (byKey) refocusFile.current = f.id; setRenamingId(null); if (name !== null) actions.rename(f, name); }} /> ), }, { key: "source", header: t("files.columns.source"), width: "8rem", tabletHidden: true, render: (f) => , }, { key: "phone", header: t("files.columns.phone"), width: "5.5rem", narrowHidden: true, render: (f) => f.asset?.media_kind === "file" ? ( — ) : ( ), }, { key: "retention", header: t("files.columns.retention"), width: "6.75rem", narrowHidden: true, render: (f) => , }, { key: "size", header: t("files.columns.size"), width: "5.5rem", align: "right", nowrap: true, sortable: true, sortValue: (f) => f.asset?.size_bytes ?? 0, render: (f) => ( {formatBytes(f.asset?.size_bytes, lang)} ), }, { key: "added", header: t("files.columns.added"), width: "6.5rem", narrowHidden: true, align: "right", nowrap: true, sortable: true, sortValue: (f) => f.shared_at ?? f.ready_at ?? f.created_at ?? "", render: (f) => { const at = f.shared_at ?? f.ready_at ?? f.created_at; return ( {relativeTime(at, lang, t)} ); }, }, { key: "actions", header: "", // Only the "…" holds a place: the quick icons come up over the date while the pointer or the // keyboard is on the row (the mail-list pattern), so the name gets their room. A card keeps // them in its own line — a phone has no hover — and the menu holds them all anyway. width: "3rem", align: "right", cardLabel: false, cardRow: true, render: (f) => { const a = f.shared ? sharedRowActions(f) : libraryRowActions(f); const mine = rowMenu.open && rowMenu.target?.id === f.id; return ( {!f.shared && a.play ? ( actions.share(f)} > ) : ( iconSlot )} {!f.shared ? ( setShareFile(f)} > ) : ( iconSlot )} {f.can_download ? ( ) : ( iconSlot )} ); }, }, ]; // The grid sorts and pages here; the table does both itself (from the same sort). const cards = isGrid ? sortRows(rows, columns, sort, lang) : rows; const { paged, pagerProps } = useCardPager( cards, LS.filesCardSize, isGrid, reveal && { seq: reveal.seq, match: (f) => String(f.id) === reveal.key }, t("files.perPage"), ); const emptyText = q ? t("files.empty.search", { q }) : t( shown.kind === "shared" ? "files.empty.shared" : shown.kind === "folder" ? "files.empty.folder" : "files.empty.all", ); const grid = cards.length === 0 ? ( {emptyText} ) : ( <> {paged.map((f, i) => ( ))}
); const body = filesQ.isError && !filesQ.data ? ( filesQ.refetch()}> {t("common.loadError")} ) : filesQ.isLoading ? ( ) : isGrid ? ( grid ) : ( String(f.id)} persistKey={accountKey(LS.filesTable) ?? undefined} pageSize={50} keyboardNav fixedLayout sort={sort} onSortChange={setSort} reveal={reveal} narrowedBy={q} rowProps={(f) => ({ ...rowMenu.triggerProps(f), "data-file-id": f.id })} // `group`: the row's quick icons show while it is hovered or holds the focus. rowClassName={(f) => ringed === f.id ? "group bg-accent/8 ring-2 ring-inset ring-accent/60" : "group" } emptyText={emptyText} /> ); const layoutOptions: ViewOption[] = LAYOUTS.map((id) => ({ id, label: t(`files.layout.${id}`), icon: LAYOUT_ICON[id], })); return ( <>
{renamingFolder && open?.kind === "own" ? ( { refocusFolderMore.current = byKey; setRenamingFolder(false); if (name !== null) folderActions.rename(open, name); }} /> ) : (

{title}

)} {open && !renamingFolder && ( )} {filesQ.data && ( {t("files.count", { count: rows.length })} )} {isGrid && ( )} {/* The intake row (DESIGN.md "Files page"): paste a link, or upload; then the paint. On a phone the link box takes the line and the rest wraps under it. A link is downloaded into its site's folder whatever is open, so the box stays even in Shared with me. */} {canUpload && ( )} setLayouts({ ...layouts, [viewKey]: next })} label={t("files.layout.label")} />
{/* The table starts at the scroller's top: a padding above it let its sticky header ride up that far on the first scroll, then stop. The cards have no header, so they keep the gap. */}
{body} {upload && ( setUpload(null)} /> )} {metaFile && setMetaFile(null)} />} {shareFile && setShareFile(null)} />} {playing && ( setPlaying(null)} /> )} {editFile && setEditFile(null)} />} {viewing !== null && ( (f.shared ? sharedRowActions(f) : libraryRowActions(f)).view, )} fileId={viewing} onFileChange={setViewing} onClose={() => setViewing(null)} menuItems={(f) => actions.items(f, true)} /> )}
{dragging && ( // Through : declared in the page, a `fixed` layer is trapped in its stacking // context and the rail paints over it.
{t("files.drop.here", { folder: uploadTargetName })}
)} ); }