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:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user