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
+15 -4
View File
@@ -24,6 +24,7 @@ vi.mock("../activeAccount", () => ({
import { setDebugSession } from "../log";
import { downloadsApi } from "./downloads";
import { filesApi } from "./files";
import {
beacon,
req,
@@ -48,10 +49,13 @@ class FakeXHR {
onabort: (() => void) | null = null;
sent = false;
body: unknown = null;
url = "";
constructor() {
FakeXHR.last = this;
}
open() {}
open(_method: string, url: string) {
this.url = url;
}
setRequestHeader(k: string, v: string) {
// A real XHR COMBINES values for a name it already has, case-insensitively, rather than
// overwriting — so a plain assignment here would hide exactly the duplicate-header bug the
@@ -470,10 +474,10 @@ describe("the debug session a tab records into", () => {
// body, which the server reads and spools BEFORE its auth dependency runs; the raw body is what lets
// `current_user` resolve first. A revert to FormData here would silently re-open that, and the
// backend's own test only sees the shape the client actually sends.
describe("uploadDownload's wire shape", () => {
describe("uploadFile's wire shape", () => {
it("sends the File as the raw body, with the names as percent-encoded headers", () => {
const file = new File(["abc"], "nyaralás videó.mp4", { type: "video/mp4" });
void downloadsApi.uploadDownload(file, { displayName: "Nyaralás 2026" });
void filesApi.uploadFile(file, { displayName: "Nyaralás 2026" });
const x = FakeXHR.last;
expect(x.body).toBe(file);
expect(x.body).not.toBeInstanceOf(FormData);
@@ -484,8 +488,15 @@ describe("uploadDownload's wire shape", () => {
});
it("omits the display-name header when there is no display name", () => {
void downloadsApi.uploadDownload(new File(["abc"], "clip.mp4"));
void filesApi.uploadFile(new File(["abc"], "clip.mp4"));
expect(FakeXHR.last.headers["X-Upload-Display-Name"]).toBeUndefined();
expect(FakeXHR.last.headers["X-Upload-Folder"]).toBeUndefined();
});
it("names the folder it goes into, when it has one", () => {
void filesApi.uploadFile(new File(["abc"], "clip.mp4"), { folderId: 7 });
expect(FakeXHR.last.headers["X-Upload-Folder"]).toBe("7");
expect(FakeXHR.last.url).toBe("/api/files/upload");
});
});