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
@@ -53,7 +53,6 @@ const baseJob: DownloadJob = {
display_uploader_url: null,
extra_links: [],
can_download: true,
expired: false,
folder_id: null,
kept: false,
expires_at: null,
@@ -112,7 +111,7 @@ describe("LibraryPlayer", () => {
);
const video = document.body.querySelector("video");
expect(video).not.toBeNull();
expect(video!.getAttribute("src")).toBe("/api/downloads/7/file");
expect(video!.getAttribute("src")).toBe("/api/files/7/file");
// No <audio> element for a video asset.
expect(document.body.querySelector("audio")).toBeNull();
});
@@ -131,7 +130,7 @@ describe("LibraryPlayer", () => {
);
const audio = document.body.querySelector("audio");
expect(audio).not.toBeNull();
expect(audio!.getAttribute("src")).toBe("/api/downloads/7/file");
expect(audio!.getAttribute("src")).toBe("/api/files/7/file");
expect(document.body.querySelector("video")).toBeNull();
});
@@ -179,7 +178,7 @@ describe("LibraryPlayer", () => {
expect(loaded().currentTime).toBe(0);
});
it("keeps where it stopped: on the server, and on the library row at once", () => {
it("keeps where it stopped: on the server, and on its row in every library list at once", () => {
const save = vi.spyOn(api, "saveDownloadProgress").mockResolvedValue({
ok: true,
job_id: 7,
@@ -188,7 +187,9 @@ describe("LibraryPlayer", () => {
const beacon = vi.spyOn(api, "saveDownloadProgressBeacon").mockImplementation(() => {});
const client = new QueryClient();
const job = withAsset({ width: 1280, height: 720 });
client.setQueryData(qk.downloads(), [job, { ...job, id: 8 }]);
// The file sits in two lists at once: its folder's and the whole library's.
client.setQueryData(qk.files({ folderId: 1 }), [job, { ...job, id: 8 }]);
client.setQueryData(qk.files({}), [job]);
const { unmount } = render(<LibraryPlayer job={job} onClose={() => {}} />, client);
const video = loaded();
video.dispatchEvent(new Event("playing"));
@@ -196,8 +197,9 @@ describe("LibraryPlayer", () => {
video.dispatchEvent(new Event("pause"));
expect(save).toHaveBeenCalledWith(7, 125.4, 600);
const rows = client.getQueryData<DownloadJob[]>(qk.downloads())!;
const rows = client.getQueryData<DownloadJob[]>(qk.files({ folderId: 1 }))!;
expect(rows.map((j) => j.position_seconds)).toEqual([125, undefined]);
expect(client.getQueryData<DownloadJob[]>(qk.files({}))![0]!.position_seconds).toBe(125);
// Closing the player sends the point the way that survives the page going away.
unmount();
expect(beacon).toHaveBeenCalledWith(7, 125.4, 600, expect.any(Function));
@@ -213,10 +215,10 @@ describe("LibraryPlayer", () => {
});
const client = new QueryClient();
const job = withAsset({ width: 1280, height: 720 });
client.setQueryData(qk.downloads(), [{ ...job, position_seconds: 10 }]);
client.setQueryData(qk.files({}), [{ ...job, position_seconds: 10 }]);
let answer: (jobs: DownloadJob[]) => void = () => {};
const poll = client.fetchQuery({
queryKey: qk.downloads(),
queryKey: qk.files({}),
queryFn: () => new Promise<DownloadJob[]>((r) => (answer = r)),
});
const { unmount } = render(<LibraryPlayer job={job} onClose={() => {}} />, client);
@@ -226,10 +228,10 @@ describe("LibraryPlayer", () => {
unmount();
answer([{ ...job, position_seconds: 10 }]);
await poll;
expect(client.getQueryData<DownloadJob[]>(qk.downloads())![0]!.position_seconds).toBe(300);
expect(client.getQueryData<DownloadJob[]>(qk.files({}))![0]!.position_seconds).toBe(300);
// Once the point has landed, the listing asks the server again.
const invalidate = vi.spyOn(client, "invalidateQueries");
settled?.();
expect(invalidate).toHaveBeenCalledWith({ queryKey: qk.downloads(), exact: true });
expect(invalidate).toHaveBeenCalledWith({ queryKey: qk.files() });
});
});