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:
@@ -1,11 +1,9 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Library-row metadata: the completed row surfaces the concrete container/format alongside the
|
||||
// already-shown size. The format chip reads the raw `asset.container` (uppercased in the UI), so it must
|
||||
// render when present and be absent when the asset has no container yet (queued/running before the worker
|
||||
// fills it). i18n is key-passthrough here.
|
||||
// A job's picture (the Files list and grid): with none at all, a quiet glyph of what it is; a remote
|
||||
// thumbnail that fails falls back to the same glyph. i18n is key-passthrough here.
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
import { render, cleanup, fireEvent } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
@@ -15,7 +13,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
import { DownloadRow, IconBtn } from "./downloadRow";
|
||||
import { JobThumb } from "./downloadRow";
|
||||
import type { DownloadJob } from "../lib/api";
|
||||
|
||||
const baseJob: DownloadJob = {
|
||||
@@ -82,201 +80,15 @@ const withAsset = (
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("DownloadRow format chip", () => {
|
||||
it("shows the uppercased container next to the size when ready", () => {
|
||||
render(<DownloadRow job={withAsset("mp4")} />);
|
||||
// Raw container is stored lowercase; the row uppercases it via CSS, so the text node stays "mp4".
|
||||
expect(screen.getByText(/·\s*mp4/i)).toBeTruthy();
|
||||
expect(screen.getByText(/142 MB/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("omits the format chip when the asset has no container yet", () => {
|
||||
const { container } = render(<DownloadRow job={withAsset(null)} />);
|
||||
expect(container.textContent).not.toMatch(/·\s*(mp4|m4a|webm)/i);
|
||||
});
|
||||
});
|
||||
|
||||
// A generic uploaded file (media_kind='file') has no video frame and no timeline: the row
|
||||
// shows a neutral file glyph instead of a thumbnail <img>, and no duration chip (it stays media info
|
||||
// like the container/size). Media rows keep their thumbnail path (an <img>, or empty until a poster).
|
||||
describe("DownloadRow file kind", () => {
|
||||
it("renders a file glyph, not a thumbnail <img>, for a generic file", () => {
|
||||
const { container } = render(
|
||||
<DownloadRow
|
||||
job={withAsset("pdf", { media_kind: "file", duration_s: null, size_bytes: 2_400_000 })}
|
||||
/>,
|
||||
);
|
||||
// The file glyph is an inline svg; no <img> element is emitted for a file.
|
||||
expect(container.querySelector("img")).toBeNull();
|
||||
expect(container.querySelector("svg")).not.toBeNull();
|
||||
// File info still reads (container + size), but there is no duration chip.
|
||||
expect(screen.getByText(/·\s*pdf/i)).toBeTruthy();
|
||||
expect(screen.getByText(/2\.3 MB/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps the thumbnail path (an <img> when a poster exists) for a media asset", () => {
|
||||
const { container } = render(
|
||||
<DownloadRow job={{ ...withAsset("mp4"), poster_url: "/api/downloads/1/poster.jpg" }} />,
|
||||
);
|
||||
expect(container.querySelector("img")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DownloadRow · keyboard", () => {
|
||||
it("is a list item only when it is given a position", () => {
|
||||
const { container, rerender } = render(<DownloadRow job={baseJob} navIndex={3} />);
|
||||
expect(container.firstElementChild?.getAttribute("data-nav-index")).toBe("3");
|
||||
rerender(<DownloadRow job={baseJob} />);
|
||||
expect(container.firstElementChild?.hasAttribute("data-nav-index")).toBe(false);
|
||||
});
|
||||
|
||||
it("marks the action that opens the row, and only that one", () => {
|
||||
render(
|
||||
<DownloadRow job={baseJob} navIndex={0}>
|
||||
<IconBtn open onClick={() => {}} title="play">
|
||||
p
|
||||
</IconBtn>
|
||||
<IconBtn onClick={() => {}} title="delete">
|
||||
d
|
||||
</IconBtn>
|
||||
</DownloadRow>,
|
||||
);
|
||||
expect(screen.getByTitle("play").hasAttribute("data-nav-open")).toBe(true);
|
||||
expect(screen.getByTitle("delete").hasAttribute("data-nav-open")).toBe(false);
|
||||
});
|
||||
|
||||
// The row once resized its actions with a `[&_button]:size-8` override, which a save LINK in the
|
||||
// same row escaped (28px beside 32px buttons). The size is the primitive's own rung, set by IconBtn.
|
||||
it("sizes its actions through the primitive's rung, not a descendant override", () => {
|
||||
const { container } = render(
|
||||
<DownloadRow job={baseJob}>
|
||||
<IconBtn onClick={() => {}} title="play">
|
||||
p
|
||||
</IconBtn>
|
||||
</DownloadRow>,
|
||||
);
|
||||
expect(screen.getByTitle("play").className).toContain("size-8");
|
||||
expect(container.innerHTML).not.toContain("[&_button]");
|
||||
});
|
||||
});
|
||||
|
||||
// The row answers to its own width (a container query) and its meta line never wraps — on
|
||||
// the iPad, zoomed in, its "18.9 / MB" pieces broke into a jumble (the owner, 2026-09-27). jsdom lays
|
||||
// nothing out, so this holds the structure the layout rests on; the widths were measured in a browser.
|
||||
describe("DownloadRow · narrow", () => {
|
||||
it("keeps the meta line one cut line of inline text, not a flex row that wraps its pieces", () => {
|
||||
const { container } = render(
|
||||
<DownloadRow job={{ ...withAsset("mp4"), created_at: "2026-09-20T10:00:00Z" }} />,
|
||||
);
|
||||
const meta = container.querySelector("[data-dl-meta]")!;
|
||||
expect(meta.className.split(/\s+/)).toContain("truncate");
|
||||
expect(meta.className).not.toMatch(/(^|\s)(inline-)?flex(\s|$)/);
|
||||
for (const el of meta.querySelectorAll("*"))
|
||||
expect(el.className.toString()).not.toMatch(/(^|\s)(inline-)?flex(\s|$)/);
|
||||
});
|
||||
|
||||
it("is its own size container and stacks below 40rem", () => {
|
||||
const { container } = render(<DownloadRow job={withAsset("mp4")} />);
|
||||
const row = container.firstElementChild as HTMLElement;
|
||||
expect(row.className).toContain("@container/dlrow");
|
||||
const grid = row.firstElementChild as HTMLElement;
|
||||
expect(grid.className).toContain(
|
||||
"@max-[40rem]/dlrow:[grid-template-areas:'thumb_head''meta_meta''foot_foot''prog_prog']",
|
||||
);
|
||||
});
|
||||
|
||||
it("stacked, puts the source link and the actions on ONE wrapping line, not a line each", () => {
|
||||
const { container } = render(
|
||||
<DownloadRow job={{ ...withAsset("mp4"), source_url: "https://example.com/v" }}>
|
||||
<IconBtn onClick={() => {}} title="play">
|
||||
x
|
||||
</IconBtn>
|
||||
</DownloadRow>,
|
||||
);
|
||||
const link = container.querySelector("a[href='https://example.com/v']")!;
|
||||
const foot = screen.getByTitle("play").closest(".contents")!;
|
||||
expect(foot.contains(link)).toBe(true);
|
||||
for (const c of ["@max-[40rem]/dlrow:[grid-area:foot]", "@max-[40rem]/dlrow:flex-wrap"])
|
||||
expect(foot.className).toContain(c);
|
||||
});
|
||||
|
||||
it("gives the failure link a real 24px line on a mouse, not the text's 20 (DESIGN B1)", () => {
|
||||
render(
|
||||
<DownloadRow job={{ ...baseJob, status: "error", error: "boom" }} onShowError={() => {}} />,
|
||||
);
|
||||
const link = screen.getByTitle("downloads.errors.fail.title");
|
||||
const cls = link.className.split(/\s+/);
|
||||
expect(cls).toEqual(expect.arrayContaining(["inline-block", "min-h-6", "leading-6"]));
|
||||
});
|
||||
|
||||
it("draws nothing for an empty link list (a bare 0 once leaked into the row)", () => {
|
||||
const { container } = render(<DownloadRow job={{ ...withAsset("mp4"), extra_links: [] }} />);
|
||||
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
|
||||
const texts: string[] = [];
|
||||
while (walker.nextNode()) texts.push(walker.currentNode.textContent ?? "");
|
||||
expect(texts).not.toContain("0");
|
||||
});
|
||||
});
|
||||
|
||||
// A link queued (or failed) before its details: the title, the channel line and the link line all
|
||||
// fell back to the same URL (the owner, 2026-09-27). Now the title IS the short link, once.
|
||||
describe("DownloadRow · a link with no details yet", () => {
|
||||
const bare: DownloadJob = {
|
||||
...baseJob,
|
||||
status: "error",
|
||||
source_kind: "url",
|
||||
source_ref: "https://www.facebook.com/watch?v=12345",
|
||||
source_url: "https://www.facebook.com/watch?v=12345",
|
||||
display_name: null,
|
||||
asset: null,
|
||||
};
|
||||
|
||||
it("shows the short link once, as the title's link, with no channel line", () => {
|
||||
const { container } = render(<DownloadRow job={bare} />);
|
||||
const links = [...container.querySelectorAll("a")].filter((a) => a.href === bare.source_url);
|
||||
expect(links.map((a) => a.textContent)).toEqual(["www.facebook.com/watch?v=12345"]);
|
||||
expect(container.textContent).not.toContain("https://");
|
||||
});
|
||||
|
||||
// Measured at 21px on the Downloads page: every link in the row stands on a 24px line on
|
||||
// a mouse (DESIGN B1), as the link line and the failure link already did.
|
||||
it("gives the title link and the channel link a 24px line", () => {
|
||||
const { container } = render(
|
||||
<DownloadRow
|
||||
job={{
|
||||
...bare,
|
||||
display_uploader: "Someone",
|
||||
display_uploader_url: "https://example.com/c",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
const title = [...container.querySelectorAll("a")].find((a) => a.href === bare.source_url)!;
|
||||
const channel = container.querySelector<HTMLAnchorElement>("a[href='https://example.com/c']")!;
|
||||
// inline-block: an inline link's box is the font's height (21px) whatever its line height.
|
||||
for (const a of [title, channel])
|
||||
expect(a.className.split(/\s+/)).toEqual(
|
||||
expect.arrayContaining(["inline-block", "leading-6"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the named row as it was: title text, and the link on its own line", () => {
|
||||
const named = { ...bare, display_name: "A clip" };
|
||||
const { container } = render(<DownloadRow job={named} />);
|
||||
expect(screen.getByText("A clip").closest("a")).toBeNull();
|
||||
const links = [...container.querySelectorAll("a")].filter((a) => a.href === bare.source_url);
|
||||
expect(links.map((a) => a.textContent)).toEqual(["www.facebook.com/watch?v=12345"]);
|
||||
});
|
||||
});
|
||||
|
||||
// No picture at all: a quiet glyph of what it is instead of an empty box (the owner, 2026-09-27).
|
||||
describe("DownloadRow · no picture", () => {
|
||||
describe("JobThumb · no picture", () => {
|
||||
it("draws a film glyph for a video, a note for audio", () => {
|
||||
const { container, unmount } = render(<DownloadRow job={withAsset("mp4")} />);
|
||||
const { container, unmount } = render(<JobThumb job={withAsset("mp4")} />);
|
||||
expect(container.querySelector("img")).toBeNull();
|
||||
expect(container.querySelector("svg.lucide-film")).not.toBeNull();
|
||||
unmount();
|
||||
const { container: audio } = render(
|
||||
<DownloadRow job={withAsset("m4a", { media_kind: "audio" })} />,
|
||||
<JobThumb job={withAsset("m4a", { media_kind: "audio" })} />,
|
||||
);
|
||||
expect(audio.querySelector("svg.lucide-music")).not.toBeNull();
|
||||
});
|
||||
@@ -284,7 +96,7 @@ describe("DownloadRow · no picture", () => {
|
||||
// failed too (a dead CDN link), the row drew the browser's broken-image mark.
|
||||
it("falls back to the glyph when the remote picture fails too", () => {
|
||||
const { container } = render(
|
||||
<DownloadRow job={withAsset("mp4", { thumbnail_url: "https://cdn.example/dead.jpg" })} />,
|
||||
<JobThumb job={withAsset("mp4", { thumbnail_url: "https://cdn.example/dead.jpg" })} />,
|
||||
);
|
||||
const img = container.querySelector("img")!;
|
||||
expect(img.getAttribute("src")).toBe("https://cdn.example/dead.jpg");
|
||||
@@ -293,27 +105,3 @@ describe("DownloadRow · no picture", () => {
|
||||
expect(container.querySelector("svg.lucide-film")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// The phone-safe conversion reports ffmpeg's real progress, so it gets the % bar a download has;
|
||||
// a yt-dlp post-step reports none and keeps the pulse.
|
||||
describe("DownloadRow · a running conversion", () => {
|
||||
const running = (phase: string): DownloadJob => ({
|
||||
...baseJob,
|
||||
status: "running",
|
||||
progress: 40,
|
||||
phase,
|
||||
});
|
||||
|
||||
it("shows its phase and its %", () => {
|
||||
const { container } = render(<DownloadRow job={running("converting")} />);
|
||||
expect(screen.getByText("downloads.phase.converting")).toBeTruthy();
|
||||
expect(screen.getByText("40%")).toBeTruthy();
|
||||
expect(container.querySelector(".animate-pulse")).toBeNull();
|
||||
});
|
||||
|
||||
it("a post-step with no % keeps the pulse", () => {
|
||||
const { container } = render(<DownloadRow job={running("merging")} />);
|
||||
expect(screen.queryByText("40%")).toBeNull();
|
||||
expect(container.querySelector(".animate-pulse")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user