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,46 @@
// @vitest-environment jsdom
//
// Below `md` a table is a list of cards, its columns flowing one after another. A column marked
// `cardRow` takes a line of its own there — a row's lone "…" otherwise landed at the end of one
// card's line and on the next line of another.
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, cleanup, screen } from "@testing-library/react";
import DataTable, { type Column } from "./DataTable";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
}));
const narrow = (matches: boolean) =>
vi.stubGlobal(
"matchMedia",
(query: string) =>
({
matches,
media: query,
addEventListener: () => {},
removeEventListener: () => {},
}) as unknown as MediaQueryList,
);
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
type Row = { id: string };
const columns: Column<Row>[] = [
{ key: "name", header: "Name", cardPrimary: true, render: (r) => r.id },
{ key: "size", header: "Size", render: () => "1 MB" },
{ key: "menu", header: "", cardLabel: false, cardRow: true, render: () => <button>more</button> },
];
describe("DataTable cards", () => {
it("gives a cardRow column a line of its own; the others flow", () => {
narrow(false);
render(<DataTable rows={[{ id: "a" }]} columns={columns} rowKey={(r) => r.id} />);
expect(screen.getByText("more").parentElement!.className).toContain("basis-full");
expect(screen.getByText("1 MB").parentElement!.className).not.toContain("basis-full");
});
});
@@ -0,0 +1,39 @@
// @vitest-environment jsdom
//
// A short list that never outgrows a screen opts out of paging: every row, and no pager — not even
// the rows-per-page choice, which a paged table shows whenever it has rows. A size remembered for
// the same table does not bring paging back.
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, cleanup, screen } from "@testing-library/react";
import DataTable, { type Column } from "./DataTable";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
}));
afterEach(() => {
cleanup();
localStorage.clear();
});
type Row = { id: string };
const columns: Column<Row>[] = [{ key: "id", header: "Id", render: (r) => r.id }];
const rows: Row[] = Array.from({ length: 12 }, (_, i) => ({ id: `row ${i + 1}` }));
describe("DataTable pager", () => {
it("pages by default, rows-per-page shown", () => {
render(<DataTable rows={rows} columns={columns} rowKey={(r) => r.id} pageSize={10} />);
expect(screen.queryByText("row 11")).toBeNull();
expect(screen.getByText("datatable.rowsPerPage")).toBeTruthy();
});
it("pager={false} shows every row and no pager, whatever size was remembered", () => {
localStorage.setItem("t", JSON.stringify({ size: 10, page: 0 }));
render(
<DataTable rows={rows} columns={columns} rowKey={(r) => r.id} pager={false} persistKey="t" />,
);
expect(screen.getByText("row 12")).toBeTruthy();
expect(screen.queryByText("datatable.rowsPerPage")).toBeNull();
});
});
+26 -16
View File
@@ -94,6 +94,7 @@ export default function DataTable<T>({
rowKey,
pageSize = 10,
pageSizeOptions = [10, 25, 50, 100],
pager = true,
persistKey,
emptyText,
rowClassName,
@@ -119,6 +120,10 @@ export default function DataTable<T>({
rowKey: (row: T) => string;
pageSize?: number;
pageSizeOptions?: number[];
// A short list that never outgrows a screen (a user's download formats) has nothing to page: false
// shows every row and no pager — not even the rows-per-page choice, which there would only be
// clutter. It overrides `pageSize` and a persisted size, so the two cannot disagree.
pager?: boolean;
persistKey?: string;
emptyText?: string;
rowClassName?: (row: T) => string;
@@ -191,7 +196,8 @@ export default function DataTable<T>({
const [internalFilters, setInternalFilters] = useState<FilterMap>(initial.filters);
const filters = controlledFilters ? (filtersProp ?? EMPTY_FILTERS) : internalFilters;
const [page, setPage] = useState(initial.page);
const [size, setSize] = useState(initial.size ?? pageSize);
// 0 = "All": an unpaged table is one page of every row.
const [size, setSize] = useState(pager ? (initial.size ?? pageSize) : 0);
const wide = useMediaQuery("(min-width: 48rem)");
const bodyRef = useRef<HTMLTableSectionElement>(null);
const cardsRef = useRef<HTMLDivElement>(null);
@@ -315,20 +321,21 @@ export default function DataTable<T>({
// Stays NULLABLE: the wrappers below decide whether to render a controls row at all, and an always
// -truthy element would leave an empty `my-3` band (and an empty in-band PageToolbar) on a table
// with nothing to page. The emptiness rule itself lives in Pager, so the two can't drift apart.
const pagerNode = hasPagerContent(sorted.length, size) ? (
<Pager
page={safePage}
size={size}
total={sorted.length}
pageSizeOptions={pageSizeOptions}
onPageChange={setPage}
onSizeChange={(s) => {
setSize(s);
setPage(0);
}}
sizeLabel={t("datatable.rowsPerPage")}
/>
) : null;
const pagerNode =
pager && hasPagerContent(sorted.length, size) ? (
<Pager
page={safePage}
size={size}
total={sorted.length}
pageSizeOptions={pageSizeOptions}
onPageChange={setPage}
onSizeChange={(s) => {
setSize(s);
setPage(0);
}}
sizeLabel={t("datatable.rowsPerPage")}
/>
) : null;
const controls =
controlsLeading != null || pagerNode ? (
@@ -424,7 +431,10 @@ export default function DataTable<T>({
{columns
.filter((c) => !c.hideInCard && !c.cardPrimary)
.map((col) => (
<span key={col.key} className="inline-flex items-center gap-1">
<span
key={col.key}
className={`inline-flex items-center gap-1${col.cardRow ? " basis-full" : ""}`}
>
{col.cardLabel !== false && (
<span className="text-muted text-xs">{col.header}</span>
)}
@@ -45,7 +45,6 @@ const PLAIN: Record<string, [number, string]> = {
"UploadDialog.tsx": [1, "the drop zone"],
"VideoEditor.tsx": [2, "timeline glyphs"],
"Welcome.tsx": [1, "a screenshot (zoom)"],
"downloadRow.tsx": [1, "the error-details link"],
"playlistColumns.tsx": [1, "the thumbnail"],
"plexDetailUi.tsx": [2, "a switch row and the poster"],
"plexViews.tsx": [1, "the rating filter (a number)"],