- 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.
108 lines
3.1 KiB
TypeScript
108 lines
3.1 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// 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, cleanup, fireEvent } from "@testing-library/react";
|
|
|
|
vi.mock("react-i18next", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
|
return {
|
|
...actual,
|
|
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
|
};
|
|
});
|
|
|
|
import { JobThumb } from "./downloadRow";
|
|
import type { DownloadJob } from "../lib/api";
|
|
|
|
const baseJob: DownloadJob = {
|
|
id: 1,
|
|
status: "done",
|
|
progress: 100,
|
|
speed_bps: null,
|
|
eta_s: null,
|
|
phase: null,
|
|
error: null,
|
|
error_code: null,
|
|
error_detail: null,
|
|
queue_pos: 0,
|
|
created_at: null,
|
|
source_kind: "youtube",
|
|
source_ref: "abc",
|
|
source_url: null,
|
|
poster_url: null,
|
|
profile_id: null,
|
|
spec: {
|
|
mode: "av",
|
|
max_height: null,
|
|
container: null,
|
|
audio_format: null,
|
|
vcodec: null,
|
|
embed_subs: false,
|
|
embed_chapters: false,
|
|
embed_thumbnail: false,
|
|
sponsorblock: false,
|
|
},
|
|
display_name: "Clip",
|
|
display_uploader: null,
|
|
display_uploader_url: null,
|
|
extra_links: [],
|
|
can_download: true,
|
|
folder_id: null,
|
|
kept: false,
|
|
expires_at: null,
|
|
asset: null,
|
|
};
|
|
|
|
const withAsset = (
|
|
container: string | null,
|
|
over: Partial<NonNullable<DownloadJob["asset"]>> = {},
|
|
): DownloadJob => ({
|
|
...baseJob,
|
|
asset: {
|
|
id: 10,
|
|
status: "ready",
|
|
title: "Clip",
|
|
uploader: null,
|
|
uploader_url: null,
|
|
upload_date: null,
|
|
duration_s: 90,
|
|
size_bytes: 148_800_000,
|
|
width: 1920,
|
|
height: 1080,
|
|
container,
|
|
media_kind: "video",
|
|
thumbnail_url: null,
|
|
...over,
|
|
},
|
|
});
|
|
|
|
afterEach(cleanup);
|
|
|
|
// No picture at all: a quiet glyph of what it is instead of an empty box (the owner, 2026-09-27).
|
|
describe("JobThumb · no picture", () => {
|
|
it("draws a film glyph for a video, a note for audio", () => {
|
|
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(
|
|
<JobThumb job={withAsset("m4a", { media_kind: "audio" })} />,
|
|
);
|
|
expect(audio.querySelector("svg.lucide-music")).not.toBeNull();
|
|
});
|
|
// The remote thumbnail was the last resort with no fallback of its own — when it
|
|
// 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(
|
|
<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");
|
|
fireEvent.error(img);
|
|
expect(container.querySelector("img")).toBeNull();
|
|
expect(container.querySelector("svg.lucide-film")).not.toBeNull();
|
|
});
|
|
});
|