feat(files): the Files page — the library in one aligned list, its folders in a rail

Files is the one library: every done file, the ones shared with me, or one
folder's, picked in a side rail (sources, own folders, the storage meter) and
mirrored to ?folder=. One table with every kind of value in its own column;
Copy link, Share and Save in sight, every action on a right click: play,
cut, rename in the row, move to an own folder or back home, keep, details,
re-download, delete.

The Downloads page keeps only the queue until the tray replaces it. The old
/api/downloads library routes are gone: list shared, upload, the details
PATCH, delete, the file and the usage now live under /api/files, and a
listed file says which source it came from. The job's "expired" flag is gone
with the state it described.
This commit is contained in:
2026-10-11 05:34:25 +02:00
parent 1a1665b8ec
commit 85aa917b38
65 changed files with 2295 additions and 1044 deletions
+478
View File
@@ -0,0 +1,478 @@
import { lazy, Suspense, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Clock,
Download,
Link,
Pin,
Scissors,
Share2,
Smartphone,
UploadCloud,
} from "lucide-react";
import { api, type FileFolder, type FileListParams, type LibraryFile } from "../lib/api";
import { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
import { formatBytes, formatDateTime, relativeTime } from "../lib/format";
import { qk } from "../lib/queryKeys";
import { LS } from "../lib/storage";
import { useLiveQuery } from "../lib/useLiveQuery";
import { JobThumb } from "./downloadRow";
import { MetaEditModal } from "./downloadModals";
import { jobTitle } from "./downloadTitle";
import { folderLabel, sourceIcon, sourceLabel } from "./fileFolders";
import { ALL_FILES, useFiles, useFilesActions, type FilesView } from "./filesContext";
import { useFileActions } from "./useFileActions";
import { Badge } from "./ui/Badge";
import { Button } from "./ui/Button";
import { ContextMenu } from "./ui/ContextMenu";
import DataTable, { type Column } from "./ui/DataTable";
import { IconButton } from "./ui/IconButton";
import { Input } from "./ui/Input";
import { IconLink } from "./ui/LinkButton";
import { MoreButton } from "./ui/MoreButton";
import { PageToolbar } from "./ui/PageShell";
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 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").
const DAY_MS = 24 * 60 * 60 * 1000;
// A file this close to leaving says so in the warning colour.
const SOON_DAYS = 7;
const fileKey = (f: LibraryFile) => f.id;
/** 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 daysLeft(iso: string): number {
return Math.max(0, Math.ceil((new Date(iso).getTime() - Date.now()) / DAY_MS));
}
function RetentionCell({ file }: { file: LibraryFile }) {
const { t, i18n } = useTranslation();
if (file.kept)
return (
<Badge>
<Pin />
{t("files.retention.kept")}
</Badge>
);
if (!file.expires_at)
return (
<span className="text-xs text-muted" title={t("files.retention.never")}>
—
</span>
);
const days = daysLeft(file.expires_at);
const when = formatDateTime(file.expires_at, i18n.language);
return days <= SOON_DAYS ? (
<Badge tone="warn" title={t("files.retention.until", { when })}>
<Clock />
{t("files.retention.days", { count: days })}
</Badge>
) : (
<span className="text-xs tabular-nums text-muted" title={t("files.retention.until", { when })}>
{t("files.retention.days", { count: days })}
</span>
);
}
function NameCell({
file,
renaming,
onPlay,
onRenamed,
}: {
file: LibraryFile;
renaming: boolean;
onPlay: (() => void) | null;
onRenamed: (name: string | null) => void;
}) {
const { t } = useTranslation();
const [draft, setDraft] = useState(() => jobTitle(file));
const channel = file.display_uploader || file.asset?.uploader || null;
const clip = file.job_kind === "edit" || file.job_kind === "clip";
if (renaming)
return (
<Input
size="sm"
className="w-full"
autoFocus
value={draft}
aria-label={t("files.actions.rename")}
onFocus={(e) => e.currentTarget.select()}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => onRenamed(draft)}
onKeyDown={(e) => {
// preventDefault: a real Enter's own activation would otherwise press what focus lands on.
if (e.key === "Enter") {
e.preventDefault();
onRenamed(draft);
}
// Unmounting the input fires no blur in React, so Escape really does cancel.
if (e.key === "Escape") {
e.preventDefault();
onRenamed(null);
}
}}
/>
);
const title = <span className="block truncate text-sm font-medium">{jobTitle(file)}</span>;
return (
<div className="flex min-w-0 flex-col">
{onPlay ? (
<Pressable
selectable
data-nav-open=""
onClick={onPlay}
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 } = useFiles();
const { setView } = 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 [renamingId, setRenamingId] = useState<number | null>(null);
const [upload, setUpload] = useState(false);
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 });
const actions = useFileActions({
folders,
onPlay: play,
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;
// 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 iconSlot = <span className="size-9 shrink-0" aria-hidden />;
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) => {
const a = f.shared ? sharedRowActions(f) : libraryRowActions(f);
return (
<NameCell
key={renamingId === f.id ? "renaming" : "shown"}
file={f}
renaming={renamingId === f.id}
onPlay={a.play ? () => play(f) : null}
onRenamed={(name) => {
setRenamingId(null);
if (name !== null) actions.rename(f, name);
}}
/>
);
},
},
{
key: "source",
header: t("files.columns.source"),
width: "8rem",
tabletHidden: true,
render: (f) => {
const Icon = sourceIcon(f.source_key);
return (
<Badge>
<Icon />
{sourceLabel(f.source_key, t)}
</Badge>
);
},
},
{
key: "phone",
header: t("files.columns.phone"),
width: "5.5rem",
narrowHidden: true,
// Every library video and sound is phone-safe by construction (backend downloads/playable.py);
// a plain file is not a thing a phone plays.
render: (f) =>
f.asset?.media_kind === "file" ? (
<span className="text-xs text-muted">—</span>
) : (
<Badge title={t("files.phoneOk")}>
<Smartphone />
<span className="text-success">✓</span>
</Badge>
),
},
{
key: "retention",
header: t("files.columns.retention"),
width: "7.5rem",
narrowHidden: true,
render: (f) => <RetentionCell 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.created_at ?? "",
render: (f) => {
const at = f.shared_at ?? f.created_at;
return (
<span className="text-xs text-muted" title={formatDateTime(at, lang)}>
{relativeTime(at, lang, t)}
</span>
);
},
},
{
key: "actions",
header: "",
width: "10rem",
align: "right",
cardLabel: false,
render: (f) => {
const a = f.shared ? sharedRowActions(f) : libraryRowActions(f);
const mine = rowMenu.open && rowMenu.target?.id === f.id;
return (
<span className="flex items-center justify-end gap-0.5">
{!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
)}
<MoreButton
label={t("files.rowMenu")}
size="md"
expanded={mine}
menuProps={rowMenu.buttonProps(f)}
/>
</span>
);
},
},
];
const body =
filesQ.isError && !filesQ.data ? (
<StateMessage tone="error" onRetry={() => filesQ.refetch()}>
{t("common.loadError")}
</StateMessage>
) : filesQ.isLoading ? (
<LoadingState shape="list" />
) : (
<DataTable
rows={rows}
columns={columns}
rowKey={(f) => String(f.id)}
persistKey={LS.filesTable}
pageSize={50}
keyboardNav
fixedLayout
rowProps={(f) => rowMenu.triggerProps(f)}
emptyText={
q
? t("files.empty.search", { q })
: t(
shown.kind === "shared"
? "files.empty.shared"
: shown.kind === "folder"
? "files.empty.folder"
: "files.empty.all",
)
}
/>
);
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">
<h2 className="min-w-0 truncate text-lg font-bold">{title}</h2>
{filesQ.data && (
<span className="text-xs tabular-nums text-muted">
{t("files.count", { count: rows.length })}
</span>
)}
{canUpload && (
<Button
variant="outline"
size="sm"
icon={<UploadCloud className="w-4 h-4" />}
onClick={() => setUpload(true)}
className="ml-auto shrink-0"
>
{t("files.actions.upload")}
</Button>
)}
</div>
</PageToolbar>
<div className="mx-auto w-full max-w-7xl px-4 pt-3 pb-4">
{body}
<ContextMenu menu={rowMenu} ariaLabel={t("files.rowMenu")} items={menuItems} />
<Suspense fallback={null}>
{upload && (
<UploadDialog
folderId={uploadFolder(shown, folders)}
onClose={() => setUpload(false)}
/>
)}
{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)} />}
</Suspense>
</div>
</>
);
}