Files
siftlode/frontend/src/components/Files.tsx
T
peter 96509fe356 fix(files): a file is added when it is done, not when it was asked for
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.
2026-10-12 22:19:37 +02:00

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>
)}
</>
);
}