A download that failed for days and then went through sat days down the library, under its request date, and read as missing. A job now records when it became done (`ready_at`), stamped where the expiry already is (`folders.stamp_done`, `finalize_if_running`) and on an insert that is already done; a check constraint holds every path to done to it. The library sorts and dates its "Added" by it; the queue keeps the request order. The migration dates a file from before by its "ready to watch" notice where the inbox still holds one, else by its request date, where it already sat.
697 lines
28 KiB
TypeScript
697 lines
28 KiB
TypeScript
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<string, FilesLayout> {
|
|
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<HTMLElement>("[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<HTMLElement>(`[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 (
|
|
<RenameField initial={jobTitle(file)} label={t("files.actions.rename")} onDone={onRenamed} />
|
|
);
|
|
const title = <span className="block truncate text-sm font-medium">{jobTitle(file)}</span>;
|
|
return (
|
|
<div className="flex min-w-0 flex-col">
|
|
{onOpen ? (
|
|
<Pressable
|
|
selectable
|
|
data-nav-open=""
|
|
onClick={onOpen}
|
|
title={jobTitle(file)}
|
|
className="min-w-0 rounded-sm hover:text-accent"
|
|
>
|
|
{title}
|
|
</Pressable>
|
|
) : (
|
|
<span title={jobTitle(file)}>{title}</span>
|
|
)}
|
|
<span className="flex min-w-0 items-center gap-1.5 text-xs text-muted">
|
|
<span className="truncate">{channel ?? "—"}</span>
|
|
{clip && (
|
|
<Badge>
|
|
<Scissors />
|
|
{t("downloads.clipBadge")}
|
|
</Badge>
|
|
)}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<HTMLDivElement>(null);
|
|
const [renamingId, setRenamingId] = useState<number | null>(null);
|
|
// A rename left by Enter or Escape hands the keyboard back to its row (RenameField).
|
|
const refocusFile = useRef<number | null>(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<LibraryFile | null>(null);
|
|
const [shareFile, setShareFile] = useState<LibraryFile | null>(null);
|
|
const [editFile, setEditFile] = useState<LibraryFile | null>(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<number | null>(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<LibraryFile>({ 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<FileFolder>({ keyOf: folderKey });
|
|
// Its rename left by a key hands the keyboard back to the "…" it was opened from.
|
|
const folderMore = useRef<HTMLButtonElement>(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<Record<string, FilesLayout>>(
|
|
"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<SortState>("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 = <span className="size-9 shrink-0" aria-hidden />;
|
|
// 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<LibraryFile>[] = [
|
|
{
|
|
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) => (
|
|
<JobThumb
|
|
job={f}
|
|
box="w-20 aspect-video shrink-0 rounded-md overflow-hidden bg-surface border border-border"
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
key: "name",
|
|
header: t("files.columns.name"),
|
|
cardPrimary: true,
|
|
sortable: true,
|
|
sortValue: (f) => jobTitle(f),
|
|
render: (f) => (
|
|
<NameCell
|
|
key={renamingId === f.id ? "renaming" : "shown"}
|
|
file={f}
|
|
renaming={renamingId === f.id}
|
|
onOpen={openOf(f)}
|
|
onRenamed={(name, byKey) => {
|
|
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) => <SourceBadge file={f} />,
|
|
},
|
|
{
|
|
key: "phone",
|
|
header: t("files.columns.phone"),
|
|
width: "5.5rem",
|
|
narrowHidden: true,
|
|
render: (f) =>
|
|
f.asset?.media_kind === "file" ? (
|
|
<span className="text-xs text-muted">—</span>
|
|
) : (
|
|
<PhoneBadge file={f} />
|
|
),
|
|
},
|
|
{
|
|
key: "retention",
|
|
header: t("files.columns.retention"),
|
|
width: "6.75rem",
|
|
narrowHidden: true,
|
|
render: (f) => <RetentionBadge file={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) => (
|
|
<span className="text-xs tabular-nums text-muted">
|
|
{formatBytes(f.asset?.size_bytes, lang)}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
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 (
|
|
<span className="text-xs text-muted" title={formatDateTime(at, lang)}>
|
|
{relativeTime(at, lang, t)}
|
|
</span>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
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 (
|
|
<span className="relative flex items-center justify-end gap-0.5">
|
|
<span
|
|
data-quick-actions
|
|
className="flex items-center gap-0.5 md:absolute md:right-full md:top-1/2 md:-translate-y-1/2 md:rounded-md md:bg-bg md:pointer-events-none md:opacity-0 md:transition-opacity md:group-hover:pointer-events-auto md:group-hover:opacity-100 md:group-focus-within:pointer-events-auto md:group-focus-within:opacity-100"
|
|
>
|
|
{!f.shared && a.play ? (
|
|
<IconButton
|
|
variant="ghost"
|
|
size="md"
|
|
label={t("files.actions.copyLink")}
|
|
disabled={actions.sharing !== null}
|
|
onClick={() => actions.share(f)}
|
|
>
|
|
<Link />
|
|
</IconButton>
|
|
) : (
|
|
iconSlot
|
|
)}
|
|
{!f.shared ? (
|
|
<IconButton
|
|
variant="ghost"
|
|
size="md"
|
|
label={t("files.actions.share")}
|
|
onClick={() => setShareFile(f)}
|
|
>
|
|
<Share2 />
|
|
</IconButton>
|
|
) : (
|
|
iconSlot
|
|
)}
|
|
{f.can_download ? (
|
|
<IconLink
|
|
href={api.fileUrl(f.id)}
|
|
label={t("downloads.actions.download")}
|
|
size="md"
|
|
>
|
|
<Download />
|
|
</IconLink>
|
|
) : (
|
|
iconSlot
|
|
)}
|
|
</span>
|
|
<MoreButton
|
|
label={t("files.rowMenu")}
|
|
size="md"
|
|
expanded={mine}
|
|
menuProps={rowMenu.buttonProps(f)}
|
|
/>
|
|
</span>
|
|
);
|
|
},
|
|
},
|
|
];
|
|
|
|
// 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 ? (
|
|
<StateMessage>{emptyText}</StateMessage>
|
|
) : (
|
|
<>
|
|
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-3">
|
|
{paged.map((f, i) => (
|
|
<FileCard
|
|
key={f.id}
|
|
file={f}
|
|
index={i}
|
|
onOpen={openOf(f)}
|
|
menuProps={rowMenu.triggerProps(f)}
|
|
moreProps={rowMenu.buttonProps(f)}
|
|
menuOpen={rowMenu.open && rowMenu.target?.id === f.id}
|
|
arrived={ringed === f.id}
|
|
/>
|
|
))}
|
|
</NavGrid>
|
|
<div className="flex justify-end pt-3">
|
|
<Pager {...pagerProps} />
|
|
</div>
|
|
</>
|
|
);
|
|
|
|
const body =
|
|
filesQ.isError && !filesQ.data ? (
|
|
<StateMessage tone="error" onRetry={() => filesQ.refetch()}>
|
|
{t("common.loadError")}
|
|
</StateMessage>
|
|
) : filesQ.isLoading ? (
|
|
<LoadingState shape={isGrid ? "grid" : "list"} />
|
|
) : isGrid ? (
|
|
grid
|
|
) : (
|
|
<DataTable
|
|
rows={rows}
|
|
columns={columns}
|
|
rowKey={(f) => 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<FilesLayout>[] = LAYOUTS.map((id) => ({
|
|
id,
|
|
label: t(`files.layout.${id}`),
|
|
icon: LAYOUT_ICON[id],
|
|
}));
|
|
|
|
return (
|
|
<>
|
|
<PageToolbar>
|
|
<div className="mx-auto flex w-full max-w-7xl flex-wrap items-center gap-x-3 gap-y-2 px-4 pt-2 pb-2">
|
|
{renamingFolder && open?.kind === "own" ? (
|
|
<RenameField
|
|
initial={open.name ?? ""}
|
|
label={t("files.folder.rename")}
|
|
className="w-64 max-w-full"
|
|
onDone={(name, byKey) => {
|
|
refocusFolderMore.current = byKey;
|
|
setRenamingFolder(false);
|
|
if (name !== null) folderActions.rename(open, name);
|
|
}}
|
|
/>
|
|
) : (
|
|
<h2 className="min-w-0 truncate text-lg font-bold">{title}</h2>
|
|
)}
|
|
{open && !renamingFolder && (
|
|
<MoreButton
|
|
ref={folderMore}
|
|
label={t("files.folder.menu")}
|
|
size="sm"
|
|
expanded={folderMenu.open}
|
|
menuProps={folderMenu.buttonProps(open)}
|
|
/>
|
|
)}
|
|
{filesQ.data && (
|
|
<span className="text-xs tabular-nums text-muted">
|
|
{t("files.count", { count: rows.length })}
|
|
</span>
|
|
)}
|
|
{isGrid && (
|
|
<span className="ml-auto">
|
|
<ColumnSortControl
|
|
columns={columns}
|
|
sort={sort}
|
|
onChange={setSort}
|
|
label={t("files.sort.label")}
|
|
defaultLabel={t("files.sort.default")}
|
|
ascLabel={t("files.sort.asc")}
|
|
descLabel={t("files.sort.desc")}
|
|
/>
|
|
</span>
|
|
)}
|
|
{/* 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. */}
|
|
<span className="flex w-full flex-wrap items-center gap-2">
|
|
<LinkBox className="flex-[1_1_18rem]" />
|
|
<span className="ml-auto flex items-center gap-2">
|
|
{canUpload && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
icon={<UploadCloud className="w-4 h-4" />}
|
|
onClick={() => setUpload({ file: null })}
|
|
>
|
|
{t("files.actions.upload")}
|
|
</Button>
|
|
)}
|
|
<ViewSwitcher
|
|
value={layout}
|
|
options={layoutOptions}
|
|
onChange={(next) => setLayouts({ ...layouts, [viewKey]: next })}
|
|
label={t("files.layout.label")}
|
|
/>
|
|
</span>
|
|
</span>
|
|
</div>
|
|
</PageToolbar>
|
|
|
|
{/* 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. */}
|
|
<div ref={listRef} className={`mx-auto w-full max-w-7xl px-4 pb-4${isGrid ? " pt-3" : ""}`}>
|
|
{body}
|
|
<ContextMenu menu={rowMenu} ariaLabel={t("files.rowMenu")} items={menuItems} />
|
|
<ContextMenu
|
|
menu={folderMenu}
|
|
ariaLabel={t("files.folder.menu")}
|
|
items={folderMenu.target ? folderActions.items(folderMenu.target) : []}
|
|
/>
|
|
<Suspense fallback={null}>
|
|
{upload && (
|
|
<UploadDialog
|
|
folderId={uploadTarget}
|
|
initialFile={upload.file}
|
|
onClose={() => setUpload(null)}
|
|
/>
|
|
)}
|
|
{metaFile && <MetaEditModal job={metaFile} onClose={() => setMetaFile(null)} />}
|
|
{shareFile && <ShareDialog job={shareFile} onClose={() => setShareFile(null)} />}
|
|
{playing && (
|
|
<LibraryPlayer
|
|
job={playing.file}
|
|
fromStart={playing.fromStart}
|
|
onClose={() => setPlaying(null)}
|
|
/>
|
|
)}
|
|
{editFile && <VideoEditor job={editFile} onClose={() => setEditFile(null)} />}
|
|
{viewing !== null && (
|
|
<FilePreview
|
|
// The viewer pages the list as it reads (its sort), through the files it can open.
|
|
files={sortRows(rows, columns, sort, lang).filter(
|
|
(f) => (f.shared ? sharedRowActions(f) : libraryRowActions(f)).view,
|
|
)}
|
|
fileId={viewing}
|
|
onFileChange={setViewing}
|
|
onClose={() => setViewing(null)}
|
|
menuItems={(f) => actions.items(f, true)}
|
|
/>
|
|
)}
|
|
</Suspense>
|
|
</div>
|
|
{dragging && (
|
|
// Through <Overlay>: declared in the page, a `fixed` layer is trapped in its stacking
|
|
// context and the rail paints over it.
|
|
<Overlay>
|
|
<div
|
|
aria-hidden
|
|
className="pointer-events-none fixed inset-3 z-overlay grid place-items-center rounded-2xl border-2 border-dashed border-accent bg-accent/10 backdrop-blur-sm"
|
|
>
|
|
<span className="flex items-center gap-2 rounded-xl bg-card px-4 py-3 text-base font-semibold shadow-float">
|
|
<UploadCloud className="size-5 text-accent" />
|
|
{t("files.drop.here", { folder: uploadTargetName })}
|
|
</span>
|
|
</div>
|
|
</Overlay>
|
|
)}
|
|
</>
|
|
);
|
|
}
|