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
+187
View File
@@ -0,0 +1,187 @@
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import { FolderOpen, HardDrive, Users } from "lucide-react";
import { api, type FileFolder } from "../lib/api";
import { compareText } from "../lib/collate";
import { formatBytes } from "../lib/format";
import { type PanelLayout } from "../lib/panelLayout";
import { qk } from "../lib/queryKeys";
import { useLiveQuery } from "../lib/useLiveQuery";
import FilterRail from "./FilterRail";
import { folderIcon, folderLabel } from "./fileFolders";
import { sameView, useFiles, useFilesActions, type FilesView } from "./filesContext";
import { Pressable } from "./ui/Pressable";
import { type PanelItem } from "./ui/PanelGroups";
// The Files module's left rail: the library's places — every file, the ones shared with me, the
// source folders the library fills by itself, the user's own folders — and the storage meter. Lifted
// to App level like the other rails (the SidePanel's collapse and customise come with it); the view it
// picks lives in FilesProvider, which the Files page reads.
function Entry({
icon: Icon,
label,
count,
view,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
count?: number;
view: FilesView;
}) {
const { view: current } = useFiles();
const { setView } = useFilesActions();
const active = sameView(view, current);
return (
<Pressable
selectable
layer={!active}
aria-current={active ? "page" : undefined}
onClick={() => setView(view)}
className={clsx(
"flex w-full min-h-8 items-center gap-2 rounded-lg px-2 text-sm",
active ? "bg-accent/12 font-medium text-accent" : "text-fg",
)}
>
<Icon className={clsx("size-4 shrink-0", !active && "text-muted")} />
<span className="min-w-0 flex-1 truncate text-left">{label}</span>
{count !== undefined && <span className="text-xs tabular-nums text-muted">{count}</span>}
</Pressable>
);
}
function StorageMeter() {
const { t, i18n } = useTranslation();
const lang = i18n.language;
// Live while something is in flight (a download's bytes land when the worker finishes it), at rest
// otherwise — the old Download Center's meter, moved to where the files are.
const usage = useLiveQuery(qk.storageUsage(), api.filesUsage, {
intervalMs: (u) => (u?.running_jobs ? 2000 : 15000),
});
const u = usage.data;
if (!u) return null;
const pct =
u.unlimited || !u.max_bytes ? 0 : Math.min(100, (u.footprint_bytes / u.max_bytes) * 100);
return (
<div className="mt-3 flex flex-col gap-1 px-2 text-xs text-muted" data-testid="storage-meter">
<span className="flex justify-between gap-2">
<span>{t("files.usage.title")}</span>
<span className="tabular-nums">
{formatBytes(u.footprint_bytes, lang)}
{u.unlimited ? "" : ` / ${formatBytes(u.max_bytes, lang)}`}
</span>
</span>
{!u.unlimited && (
<span className="h-1 overflow-hidden rounded-full bg-fg/10">
<span
className={clsx("block h-full rounded-full", pct > 90 ? "bg-danger" : "bg-accent")}
style={{ width: `${pct}%` }}
/>
</span>
)}
</div>
);
}
export default function FilesRail({
layout,
setLayout,
collapsed,
onToggleCollapse,
}: {
layout: PanelLayout;
setLayout: (l: PanelLayout) => void;
collapsed: boolean;
onToggleCollapse: () => void;
}) {
const { t, i18n } = useTranslation();
const { view } = useFiles();
const foldersQ = useQuery({ queryKey: qk.fileFolders(), queryFn: api.fileFolders });
const folders = foldersQ.data ?? [];
const total = folders.reduce((n, f) => n + f.count, 0);
// A source folder its files have all left stays on the server; here it shows only while it holds
// something, or while it is the one open (so the page never points at a folder the rail hides).
const shown = (f: FileFolder) => f.count > 0 || sameView(view, { kind: "folder", id: f.id });
const byName = (a: FileFolder, b: FileFolder) =>
compareText(folderLabel(a, t), folderLabel(b, t), i18n.language);
const sources = folders.filter((f) => f.kind === "source" && shown(f)).sort(byName);
const own = folders.filter((f) => f.kind === "own").sort(byName);
const failed = foldersQ.isError && !foldersQ.data && (
<Pressable
onClick={() => foldersQ.refetch()}
className="px-2 py-1 text-xs text-danger hover:underline"
>
{t("common.loadError")} — {t("common.retry")}
</Pressable>
);
const entry = (f: FileFolder) => (
<Entry
key={f.id}
icon={folderIcon(f)}
label={folderLabel(f, t)}
count={f.count}
view={{ kind: "folder", id: f.id }}
/>
);
const items: PanelItem[] = [
{
id: "library",
title: t("files.rail.library"),
body: (
<div className="flex flex-col gap-0.5">
<Entry
icon={HardDrive}
label={t("files.rail.all")}
count={foldersQ.data ? total : undefined}
view={{ kind: "all" }}
/>
<Entry icon={Users} label={t("files.rail.shared")} view={{ kind: "shared" }} />
</div>
),
},
{
id: "sources",
title: t("files.rail.sources"),
body: (
<div className="flex flex-col gap-0.5">
{failed}
{sources.map(entry)}
{foldersQ.data && sources.length === 0 && (
<span className="px-2 py-1 text-xs text-muted">{t("files.rail.noSources")}</span>
)}
</div>
),
},
{
id: "own",
title: t("files.rail.own"),
body: (
<div className="flex flex-col gap-0.5">
{failed}
{own.map(entry)}
{foldersQ.data && own.length === 0 && (
<span className="px-2 py-1 text-xs text-muted">{t("files.rail.noOwn")}</span>
)}
</div>
),
},
];
return (
<FilterRail
kind="files"
title={t("files.navLabel")}
icon={<FolderOpen className="w-4 h-4" />}
collapsed={collapsed}
onToggleCollapse={onToggleCollapse}
layout={layout}
setLayout={setLayout}
items={items}
>
<StorageMeter />
</FilterRail>
);
}