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
@@ -0,0 +1,397 @@
// @vitest-environment jsdom
//
// The Files page: a row keeps Copy link, Share, Save and its "…" in sight — a slot an action does not
// apply to stays empty, so the icons line up down the list — and its every action is its ONE menu,
// opened by the "…" or a right-click. Renaming happens in the row; moving goes to an own folder or
// back home; a shared file is the owner's, so its menu only plays, saves, cuts or drops it from my
// list. i18n is key-passthrough, so labels read as keys.
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { api, type FileFolder, type LibraryFile } from "../lib/api";
import { ALL_FILES, FilesActionsContext, FilesStateContext, type FilesView } from "./filesContext";
const mocks = vi.hoisted(() => ({
files: [] as unknown[],
folders: [] as unknown[],
confirm: vi.fn(async (_o?: unknown) => true),
keys: [] as unknown[],
}));
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
i18n: { language: "en" },
t: (key: string, o?: Record<string, unknown>) =>
o && "folder" in o ? `${key}:${String(o.folder)}` : key,
}),
};
});
vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm }));
vi.mock("../lib/useLiveQuery", () => ({
useLiveQuery: (key: readonly unknown[]) => {
mocks.keys.push(key);
return {
data:
key[0] === "files" ? mocks.files : key[0] === "file-folders" ? mocks.folders : undefined,
isError: false,
isLoading: false,
refetch: vi.fn(),
};
},
}));
vi.mock("./ui/PageShell", async (importOriginal) => ({
...(await importOriginal<typeof import("./ui/PageShell")>()),
PageToolbar: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
import Files from "./Files";
const file = (id: number, extra: Partial<LibraryFile> = {}): LibraryFile =>
({
id,
status: "done",
progress: 100,
created_at: "2026-10-10T10:00:00+00:00",
source_kind: "youtube",
source_ref: `v${id}`,
source_url: null,
poster_url: null,
display_name: `Clip ${id}`,
display_uploader: null,
extra_links: [],
can_download: true,
job_kind: "download",
folder_id: 1,
kept: false,
expires_at: null,
shared: false,
source_key: "youtube",
asset: { media_kind: "video", duration_s: 60, size_bytes: 2048 },
...extra,
}) as unknown as LibraryFile;
const folder = (id: number, extra: Partial<FileFolder>): FileFolder => ({
id,
kind: "source",
source_key: null,
name: null,
retention_days: 30,
count: 1,
size_bytes: 0,
created_at: null,
...extra,
});
const YT = folder(1, { source_key: "youtube" });
const OWN = folder(2, { kind: "own", name: "Cimboráknak", retention_days: null });
afterEach(() => {
cleanup();
vi.restoreAllMocks();
mocks.confirm.mockClear();
mocks.keys = [];
});
function mount(view: FilesView = ALL_FILES, setView = vi.fn(), search = "") {
render(
<QueryClientProvider client={new QueryClient()}>
<FilesActionsContext.Provider value={{ setSearch: vi.fn(), setView }}>
<FilesStateContext.Provider value={{ search, view }}>
<Files />
</FilesStateContext.Provider>
</FilesActionsContext.Provider>
</QueryClientProvider>,
);
return setView;
}
const rowOf = (text: string) => screen.getByText(text).closest<HTMLElement>("[data-nav-index]")!;
const moreOf = (text: string) =>
rowOf(text).querySelector<HTMLElement>('button[aria-label="files.rowMenu"]')!;
// A folder in the Move submenu is a choice of one (menuitemradio), the rest plain rows.
const menuEls = () => [
...screen.getAllByRole("menuitem", { hidden: true }),
...screen.queryAllByRole("menuitemradio", { hidden: true }),
];
const menuRows = () => menuEls().map((b) => b.textContent ?? "");
const menuItem = (label: string) => menuEls().find((b) => b.textContent === label)!;
async function openMenu(open: () => void) {
act(open);
await waitFor(() =>
expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0),
);
}
const rowButtons = (text: string) =>
[...rowOf(text).querySelectorAll<HTMLElement>("button[aria-label], a[aria-label]")].map((b) =>
b.getAttribute("aria-label"),
);
describe("Files · my file's row", () => {
it("shows Copy link, Share, Save and the '…'; the menu holds every action", async () => {
mocks.files = [file(2, { source_url: "https://youtu.be/x" })];
mocks.folders = [YT, OWN];
mount();
expect(rowButtons("Clip 2")).toEqual([
"files.actions.copyLink",
"files.actions.share",
"downloads.actions.download",
"files.rowMenu",
]);
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
expect(menuRows()).toEqual([
"downloads.actions.play",
"files.actions.copyLink",
"files.actions.share",
"downloads.actions.download",
"downloads.actions.edit",
"files.actions.rename",
"files.actions.move",
"files.actions.keep",
"files.actions.details",
"downloads.actions.redownload",
"common.copy.title",
"files.actions.copySourceLink",
"downloads.actions.delete",
]);
});
it("keeps the slot of an action a plain file lacks, so the icons line up", () => {
mocks.files = [file(3, { job_kind: "upload", asset: { media_kind: "file" } } as never)];
mocks.folders = [YT];
mount();
expect(rowButtons("Clip 3")).toEqual([
"files.actions.share",
"downloads.actions.download",
"files.rowMenu",
]);
// The empty slot holds the Copy link's place, the same width as an icon.
const actions = rowOf("Clip 3").querySelector(
'button[aria-label="files.actions.share"]',
)!.parentElement!;
expect(actions.firstElementChild!.getAttribute("aria-hidden")).toBe("true");
});
it("copies the link from the row's own icon", async () => {
mocks.files = [file(2)];
mocks.folders = [YT];
vi.spyOn(api, "downloadLinks").mockResolvedValue([] as never);
const create = vi
.spyOn(api, "createDownloadLink")
.mockResolvedValue({ id: 1, url: "/s/tok" } as never);
mount();
fireEvent.click(
rowOf("Clip 2").querySelector<HTMLElement>('button[aria-label="files.actions.copyLink"]')!,
);
await waitFor(() => expect(create).toHaveBeenCalledWith(2, {}));
});
it("saves through the file's own address", async () => {
mocks.files = [file(2)];
mocks.folders = [YT];
const followed: string[] = [];
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (
this: HTMLAnchorElement,
) {
followed.push(new URL(this.href).pathname);
});
mount();
await openMenu(() => fireEvent.click(moreOf("Clip 2")));
fireEvent.click(menuItem("downloads.actions.download"));
expect(followed).toEqual(["/api/files/2/file"]);
const icon = rowOf("Clip 2").querySelector<HTMLAnchorElement>(
'a[aria-label="downloads.actions.download"]',
)!;
expect(new URL(icon.href).pathname).toBe("/api/files/2/file");
});
it("renames in the row: Enter saves, an emptied name gives the title back", async () => {
mocks.files = [file(2)];
mocks.folders = [YT];
const update = vi.spyOn(api, "updateFile").mockResolvedValue({} as never);
mount();
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
fireEvent.click(menuItem("files.actions.rename"));
const field = await screen.findByRole("textbox", { name: "files.actions.rename" });
fireEvent.change(field, { target: { value: " Új név " } });
fireEvent.keyDown(field, { key: "Enter" });
await waitFor(() => expect(update).toHaveBeenCalledWith(2, { name: "Új név" }));
expect(screen.queryByRole("textbox", { name: "files.actions.rename" })).toBeNull();
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
fireEvent.click(menuItem("files.actions.rename"));
const again = await screen.findByRole("textbox", { name: "files.actions.rename" });
fireEvent.change(again, { target: { value: "" } });
fireEvent.keyDown(again, { key: "Enter" });
await waitFor(() => expect(update).toHaveBeenLastCalledWith(2, { name: null }));
});
it("Escape leaves the name as it was", async () => {
mocks.files = [file(2)];
mocks.folders = [YT];
const update = vi.spyOn(api, "updateFile").mockResolvedValue({} as never);
mount();
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
fireEvent.click(menuItem("files.actions.rename"));
const field = await screen.findByRole("textbox", { name: "files.actions.rename" });
fireEvent.change(field, { target: { value: "Más" } });
fireEvent.keyDown(field, { key: "Escape" });
expect(screen.queryByRole("textbox")).toBeNull();
expect(update).not.toHaveBeenCalled();
});
it("moves to an own folder, and from there back to its source folder only", async () => {
mocks.files = [file(2, { folder_id: 2 })];
// Another source folder first, so "its own" is chosen by its key, not by its place in the list.
mocks.folders = [folder(3, { source_key: "site:tiktok.com" }), YT, OWN];
const update = vi.spyOn(api, "updateFile").mockResolvedValue({} as never);
mount();
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
fireEvent.keyDown(menuItem("files.actions.move"), { key: "ArrowRight" });
await waitFor(() => expect(menuRows()).toContain("Cimboráknak"));
// The folder it is in, ticked; home is its own source folder, never another site's.
expect(menuRows()).toContain("files.actions.moveHome:files.source.youtube");
expect(menuRows().some((r) => r.includes("tiktok"))).toBe(false);
expect(menuItem("Cimboráknak").getAttribute("aria-checked")).toBe("true");
fireEvent.click(menuItem("files.actions.moveHome:files.source.youtube"));
await waitFor(() => expect(update).toHaveBeenCalledWith(2, { folder_id: 1 }));
});
it("offers no move home while it is home, and says so when there is nowhere to go", async () => {
mocks.files = [file(2, { folder_id: 1 })];
mocks.folders = [YT];
mount();
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
fireEvent.keyDown(menuItem("files.actions.move"), { key: "ArrowRight" });
await waitFor(() => expect(menuRows()).toContain("files.actions.noOwnFolders"));
expect(menuItem("files.actions.noOwnFolders").hasAttribute("disabled")).toBe(true);
});
it("keeps a file, and a kept one is handed back to its folder", async () => {
mocks.files = [file(2), file(3, { kept: true })];
mocks.folders = [YT];
const update = vi.spyOn(api, "updateFile").mockResolvedValue({} as never);
mount();
expect(rowOf("Clip 3").textContent).toContain("files.retention.kept");
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
fireEvent.click(menuItem("files.actions.keep"));
await waitFor(() => expect(update).toHaveBeenCalledWith(2, { kept: true }));
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 3")));
fireEvent.click(menuItem("files.actions.unkeep"));
await waitFor(() => expect(update).toHaveBeenLastCalledWith(3, { kept: false }));
});
it("deletes only through the confirm", async () => {
mocks.files = [file(2)];
mocks.folders = [YT];
const del = vi.spyOn(api, "deleteFile").mockResolvedValue({} as never);
mocks.confirm.mockResolvedValueOnce(false);
mount();
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
fireEvent.click(menuItem("downloads.actions.delete"));
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
expect(del).not.toHaveBeenCalled();
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
fireEvent.click(menuItem("downloads.actions.delete"));
await waitFor(() => expect(del).toHaveBeenCalledWith(2));
});
});
describe("Files · the confirms", () => {
it("label the confirm button with the action, not the dialog's question", async () => {
mocks.files = [file(2)];
mocks.folders = [YT];
mocks.confirm.mockResolvedValue(false);
mount();
await openMenu(() => fireEvent.click(moreOf("Clip 2")));
fireEvent.click(menuItem("downloads.actions.redownload"));
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
await openMenu(() => fireEvent.click(moreOf("Clip 2")));
fireEvent.click(menuItem("downloads.actions.delete"));
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(2));
expect(
mocks.confirm.mock.calls.map(([o]) => {
const opts = o as { title: string; confirmLabel?: string };
return [opts.title, opts.confirmLabel];
}),
).toEqual([
["downloads.confirm.redownloadTitle", "downloads.confirm.redownloadAction"],
["downloads.confirm.deleteTitle", "downloads.actions.delete"],
]);
mocks.confirm.mockResolvedValue(true);
});
it.each([
["upload", "downloads.confirm.deleteUploadTitle", "downloads.confirm.deleteUploadBody"],
["edit", "downloads.confirm.deleteEditTitle", "downloads.confirm.deleteEditBody"],
["download", "downloads.confirm.deleteTitle", "downloads.confirm.deleteBody"],
] as const)("deleting a %s says what happens to its file", async (kind, title, body) => {
// Only a download's file may stay cached for other users; an upload's or a clip's is theirs alone.
mocks.files = [file(5, { job_kind: kind })];
mocks.folders = [YT];
mocks.confirm.mockResolvedValueOnce(false);
mount();
await openMenu(() => fireEvent.click(moreOf("Clip 5")));
fireEvent.click(menuItem("downloads.actions.delete"));
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
const opts = mocks.confirm.mock.calls[0]![0] as { title: string; message?: string };
expect([opts.title, opts.message]).toEqual([title, body]);
});
});
describe("Files · a shared file's row", () => {
it("plays, saves, cuts, copies and drops it from my list — nothing of the owner's", async () => {
mocks.files = [file(4, { shared: true, shared_at: "2026-10-11T08:00:00+00:00" })];
mocks.folders = [YT];
const remove = vi.spyOn(api, "removeSharedFile").mockResolvedValue({} as never);
mount({ kind: "shared" });
expect(rowButtons("Clip 4")).toEqual(["downloads.actions.download", "files.rowMenu"]);
await openMenu(() => fireEvent.click(moreOf("Clip 4")));
expect(menuRows()).toEqual([
"downloads.actions.play",
"downloads.actions.download",
"downloads.actions.edit",
"common.copy.title",
"downloads.actions.removeShared",
]);
fireEvent.click(menuItem("downloads.actions.removeShared"));
await waitFor(() => expect(remove).toHaveBeenCalledWith(4));
// The shared list is its own read, and nothing can be uploaded into it.
expect(mocks.keys).toContainEqual(["files", { shared: true }]);
expect(screen.queryByText("files.actions.upload")).toBeNull();
});
});
describe("Files · the view", () => {
it("reads one folder's files and names the folder", () => {
mocks.files = [file(2, { folder_id: 2 })];
mocks.folders = [YT, OWN];
mount({ kind: "folder", id: 2 });
expect(mocks.keys).toContainEqual(["files", { folderId: 2 }]);
expect(screen.getByRole("heading", { name: "Cimboráknak" })).toBeTruthy();
});
it("opens the whole library when the folder it was given is gone", async () => {
mocks.files = [];
mocks.folders = [YT];
const setView = mount({ kind: "folder", id: 99 });
expect(mocks.keys).toContainEqual(["files", {}]);
expect(mocks.keys).not.toContainEqual(["files", { folderId: 99 }]);
await waitFor(() => expect(setView).toHaveBeenCalledWith(ALL_FILES));
});
it("searches the rows it holds by name and channel", () => {
mocks.files = [
file(2, { display_name: "Szméagol szompolyog" }),
file(3, { display_name: "Más", display_uploader: "Szméagol rajongó" }),
file(4, { display_name: "Harmadik" }),
];
mocks.folders = [YT];
mount(ALL_FILES, vi.fn(), "szméagol");
expect(screen.queryByText("Szméagol szompolyog")).not.toBeNull();
expect(screen.queryByText("Más")).not.toBeNull();
expect(screen.queryByText("Harmadik")).toBeNull();
});
});