feat(files): the Downloads page goes — the tray, a Storage tab and a Downloads settings tab take its place

- The Downloads module, its rail icon and badge are gone. A bookmark, the remembered page and an old
  history entry that name it land on Files (urlState.resolvePage); `g d` goes to Files too.
- Users page: a Storage tab — the disk in numbers, every user's use against their own limit with
  their file count, every user's downloads; aligned tables, one menu per row (edit / reset quota,
  open source, copy, the owner's quota). The admin storage rows carry quota + file count, the admin
  job list its owner.
- Settings: a Downloads tab — the download formats in one list (duplicate a built-in, edit or delete
  your own) and the site sign-ins, moved from Account.
- "View" on an added download, a clip or a converting upload opens the tray on that row (unfolded,
  back if closed; a finished one opens as a file; under a player it opens without taking focus).
- The download dialog opens on the format used last.
- DataTable: `pager={false}` for a short list, `cardRow` to give a lone "…" its own card line.
- Welcome and README speak of Files; a new Files screenshot.
This commit is contained in:
2026-10-11 19:37:13 +02:00
parent 1c4f0042d2
commit 078194731b
69 changed files with 1553 additions and 1147 deletions
@@ -0,0 +1,139 @@
// @vitest-environment jsdom
//
// The download formats (Settings → Downloads, and the download dialog's "Manage formats"): one aligned
// list, built-ins first, each row saying what it saves. A row's one menu: a built-in's only
// Duplicate; the user's own Edit, Duplicate and Delete (behind a confirm). A duplicate is a NEW format
// with the original's settings and a name of its own. i18n is key-passthrough.
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 DownloadProfile } from "../lib/api";
const mocks = vi.hoisted(() => ({ confirm: vi.fn(async (_o?: unknown) => true) }));
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
}));
vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm }));
import { DownloadFormats } from "./ProfileEditor";
const SPEC = {
mode: "av",
max_height: 480,
container: "mp4",
audio_format: "m4a",
vcodec: null,
embed_subs: true,
embed_chapters: false,
embed_thumbnail: false,
sponsorblock: true,
};
const PROFILES = [
{ id: 9, name: "Phone 480", is_builtin: false, spec: SPEC },
{ id: 1, name: "best", is_builtin: true, spec: { mode: "a", audio_format: "mp3" } },
] as unknown as DownloadProfile[];
afterEach(() => {
cleanup();
vi.restoreAllMocks();
mocks.confirm.mockClear();
});
async function mount() {
vi.spyOn(api, "downloadProfiles").mockResolvedValue(PROFILES);
render(
<QueryClientProvider client={new QueryClient()}>
<DownloadFormats />
</QueryClientProvider>,
);
await screen.findByText("Phone 480");
}
const rows = () => [...document.querySelectorAll<HTMLElement>("[data-nav-index]")];
const rowOf = (text: string) => screen.getByText(text).closest<HTMLElement>("[data-nav-index]")!;
const menuRows = () =>
screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent ?? "");
const pick = (label: string) =>
fireEvent.click(
screen.getAllByRole("menuitem", { hidden: true }).find((b) => b.textContent === label)!,
);
async function openMenu(row: HTMLElement) {
act(() => {
fireEvent.contextMenu(row);
});
await waitFor(() =>
expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0),
);
}
describe("DownloadFormats", () => {
it("lists the built-ins first, each saying what it saves", async () => {
await mount();
expect(rows().map((r) => r.textContent)).toEqual([
expect.stringContaining("downloads.profiles.modeA · MP3"),
expect.stringContaining(
"downloads.profiles.modeAv · ≤480p · downloads.profiles.short.subs · downloads.profiles.short.sponsorblock",
),
]);
});
it("a built-in's menu only duplicates; the user's own edits, duplicates and deletes", async () => {
await mount();
await openMenu(rows()[0]!);
expect(menuRows()).toEqual(["downloads.profiles.duplicate"]);
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
await openMenu(rowOf("Phone 480"));
expect(menuRows()).toEqual([
"downloads.profiles.edit",
"downloads.profiles.duplicate",
"downloads.profiles.delete",
]);
});
it("deletes only through the confirm", async () => {
const del = vi.spyOn(api, "deleteDownloadProfile").mockResolvedValue({} as never);
await mount();
mocks.confirm.mockResolvedValueOnce(false);
await openMenu(rowOf("Phone 480"));
pick("downloads.profiles.delete");
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
expect(del).not.toHaveBeenCalled();
await openMenu(rowOf("Phone 480"));
pick("downloads.profiles.delete");
await waitFor(() => expect(del).toHaveBeenCalledWith(9));
});
it("a duplicate is a new format with the original's settings and a name of its own", async () => {
const create = vi.spyOn(api, "createDownloadProfile").mockResolvedValue({} as never);
const update = vi.spyOn(api, "updateDownloadProfile").mockResolvedValue({} as never);
await mount();
await openMenu(rowOf("Phone 480"));
pick("downloads.profiles.duplicate");
const name = await screen.findByPlaceholderText("downloads.profiles.namePlaceholder");
expect((name as HTMLInputElement).value).toBe("");
fireEvent.change(name, { target: { value: "Phone copy" } });
fireEvent.click(screen.getByText("downloads.profiles.create"));
await waitFor(() =>
expect(create).toHaveBeenCalledWith({
name: "Phone copy",
spec: expect.objectContaining({ max_height: 480, embed_subs: true, sponsorblock: true }),
}),
);
expect(update).not.toHaveBeenCalled();
});
it("Edit saves over the format itself", async () => {
const update = vi.spyOn(api, "updateDownloadProfile").mockResolvedValue({} as never);
await mount();
await openMenu(rowOf("Phone 480"));
pick("downloads.profiles.edit");
const name = await screen.findByDisplayValue("Phone 480");
fireEvent.change(name, { target: { value: "Phone" } });
fireEvent.click(screen.getByText("downloads.profiles.save"));
await waitFor(() =>
expect(update).toHaveBeenCalledWith(9, expect.objectContaining({ name: "Phone" })),
);
});
});