diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index 37de8148..f2becf4d 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -780,3 +780,4 @@ of a full-width panel and nothing on the right.
| 2026-10-11 | Download done | The notification says whether it plays on a phone and carries Link másolása + Megnyitás (the folder, the item focused); never an automatic jump | A plain "ready" notice; auto-navigation to the item | The daily path is paste → done → copy the link; a jump would pull the user out of what they were doing |
| 2026-10-11 | Library row quick icons (owner A) | On a wide list only the ⋯ holds a place; Copy link, Share and Save come up over the date while the row is hovered or holds the keyboard; a card (phone) keeps them in its own line; Keeps narrower | Three icons always in sight (the name kept ~150px at 1200); dropping Added below 64rem | Owner: the name is what a row is read by; every action stays in the ⋯ and the right-click menu |
| 2026-10-12 | Files preview (owner ★) | A file that is not media opens from its name in the dialog shell, wide: an image, a PDF in the browser's own viewer, text as numbered plain text (HTML and SVG too) with a wrap toggle, else a no-preview card with Save; ← / → page the list's such files in its order; an image under 8 MB is its own thumbnail | A side panel beside the list; a new browser tab; pdf.js; syntax highlighting; a click that saves a file without a preview | Owner: one way to open anything, paged like the list; the browser's PDF viewer is free and isolated; a page is never rendered on our origin |
+| 2026-10-12 | A running download's step (owner ★) | The tray row names the step beside its bar ("Downloading audio · 12%", "Merging" with a pulse); a paused-only tray shows a pause mark, never the done check | A bare bar and % | A download fetches video and audio as two streams: unnamed, the second fill reads as a restart; the tray is the only place a download is followed |
diff --git a/frontend/e2e/files.spec.ts b/frontend/e2e/files.spec.ts
new file mode 100644
index 00000000..525e066f
--- /dev/null
+++ b/frontend/e2e/files.spec.ts
@@ -0,0 +1,42 @@
+import { test, expect } from "@playwright/test";
+import { gotoPage, openApp } from "./helpers";
+
+// The Files library end to end, against the real app: the page opens on the whole library, and a
+// link — typed into its box, or pasted onto any page — opens the one download dialog on it. Nothing
+// is downloaded: the dialog is closed, so the run leaves the E2E account's library as it found it.
+test.describe("Files", () => {
+ const LINK = "https://www.youtube.com/watch?v=jNQXAC9IVRw";
+ const dialog = (page: import("@playwright/test").Page) =>
+ page.getByRole("dialog", { name: "Download video" });
+
+ test("opens on the whole library, and its link box hands a link to the download dialog", async ({
+ page,
+ }) => {
+ await openApp(page);
+ await gotoPage(page, "files");
+ await expect(page.getByRole("heading", { name: "All files" })).toBeVisible();
+ const box = page.getByRole("textbox", { name: "A link to download" });
+ await box.fill(LINK);
+ await box.press("Enter");
+ await expect(dialog(page)).toBeVisible();
+ await expect(dialog(page)).toContainText("jNQXAC9IVRw");
+ await page.keyboard.press("Escape");
+ await expect(dialog(page)).toBeHidden();
+ });
+
+ test("a link pasted onto a page (not into a field) opens the download dialog", async ({
+ page,
+ }) => {
+ await openApp(page);
+ await page.evaluate((text) => {
+ const e = new Event("paste", { bubbles: true, cancelable: true });
+ Object.defineProperty(e, "clipboardData", {
+ value: { getData: (type: string) => (type === "text/plain" ? text : "") },
+ });
+ document.body.dispatchEvent(e);
+ }, LINK);
+ await expect(dialog(page)).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(dialog(page)).toBeHidden();
+ });
+});
diff --git a/frontend/e2e/nav.spec.ts b/frontend/e2e/nav.spec.ts
index 70142078..027ce8d7 100644
--- a/frontend/e2e/nav.spec.ts
+++ b/frontend/e2e/nav.spec.ts
@@ -10,7 +10,7 @@ test.describe("page switching", () => {
// `nav-admin`) that opens a panel of them — the spec still listed them as rail rows of their own,
// and it failed on every run for a while without anyone seeing it, because e2e was in no
// required gate (it is now part of `release`).
- const RAIL = ["channels", "playlists", "stats", "settings", "feed"];
+ const RAIL = ["channels", "playlists", "files", "stats", "settings", "feed"];
const ADMIN = ["scheduler", "config", "users", "audit"];
test("each rail item activates its page", async ({ page }) => {
diff --git a/frontend/src/components/DownloadIntake.dom.test.tsx b/frontend/src/components/DownloadIntake.dom.test.tsx
index 3d46e595..1ec1c64f 100644
--- a/frontend/src/components/DownloadIntake.dom.test.tsx
+++ b/frontend/src/components/DownloadIntake.dom.test.tsx
@@ -24,6 +24,7 @@ import DownloadIntake from "./DownloadIntake";
import { LinkBox } from "./LinkBox";
import { clearPendingDownload, requestLinkBox } from "../lib/downloadIntake";
import { ensureNavId } from "../lib/history";
+import { pushLayer, removeLayer } from "../lib/layerStack";
afterEach(() => {
cleanup();
@@ -79,6 +80,18 @@ describe("DownloadIntake · a pasted link", () => {
expect(dialog()).toBeNull();
});
+ it("with a player or a menu on top (a layer, no aria-modal), a paste is not a download", async () => {
+ render();
+ const layer = pushLayer();
+ try {
+ paste(document.body, "https://a.example/1");
+ await act(async () => {});
+ expect(dialog()).toBeNull();
+ } finally {
+ removeLayer(layer);
+ }
+ });
+
it("closing the dialog closes it", async () => {
render();
paste(document.body, "https://a.example/1");
@@ -102,6 +115,20 @@ describe("LinkBox", () => {
expect((field as HTMLInputElement).value).toBe("");
});
+ it("a clipboard link read on focus never replaces what was typed while it was read", async () => {
+ let answer: (text: string) => void = () => {};
+ Object.defineProperty(navigator, "clipboard", {
+ configurable: true,
+ value: { readText: () => new Promise((resolve) => (answer = resolve)) },
+ });
+ render();
+ const box = screen.getByRole("textbox");
+ fireEvent.focus(box);
+ fireEvent.change(box, { target: { value: "https://typed.example/1" } });
+ await act(async () => answer("https://clipboard.example/2"));
+ expect((box as HTMLInputElement).value).toBe("https://typed.example/1");
+ });
+
it("an empty box downloads nothing", async () => {
render(
<>
diff --git a/frontend/src/components/DownloadTray.dom.test.tsx b/frontend/src/components/DownloadTray.dom.test.tsx
index 334fb7b5..162ffc54 100644
--- a/frontend/src/components/DownloadTray.dom.test.tsx
+++ b/frontend/src/components/DownloadTray.dom.test.tsx
@@ -17,6 +17,7 @@ const h = vi.hoisted(() => ({
share: vi.fn(async () => "https://x/watch/t"),
notify: vi.fn(),
prefs: new Map(),
+ intervalMs: ((_d: unknown) => 0) as (d: unknown) => number,
}));
vi.mock("react-i18next", async (importOriginal) => ({
@@ -28,13 +29,16 @@ vi.mock("react-i18next", async (importOriginal) => ({
}),
}));
vi.mock("../lib/useLiveQuery", () => ({
- useLiveQuery: () => ({
- data: h.jobs,
- dataUpdatedAt: h.updatedAt,
- isError: false,
- isLoading: false,
- refetch: vi.fn(),
- }),
+ useLiveQuery: (_key: unknown, _fn: unknown, opts: { intervalMs: (d: unknown) => number }) => (
+ (h.intervalMs = opts.intervalMs),
+ {
+ data: h.jobs,
+ dataUpdatedAt: h.updatedAt,
+ isError: false,
+ isLoading: false,
+ refetch: vi.fn(),
+ }
+ ),
}));
// The prefs as state over a map that outlives a mount — a reload, as far as the tray can tell. How
// usePref stores them is its own business (usePref.dom.test).
@@ -137,6 +141,31 @@ describe("DownloadTray", () => {
expect(within(rowOf("Clip 2")).getByText("downloads.status.queued")).toBeTruthy();
});
+ it("a running job names its step: the second stream's bar does not read as a restart", () => {
+ poll([job(1, "running", { phase: "audio" }), job(2, "running", { phase: "merging" })]);
+ mount();
+ const [audio, merging] = screen.getAllByRole("progressbar");
+ expect(within(rowOf("Clip 1")).getByText("downloads.phase.audio")).toBeTruthy();
+ expect(audio!.getAttribute("aria-valuenow")).toBe("40");
+ // A step that reports no bytes has no % to claim.
+ expect(within(rowOf("Clip 2")).getByText("downloads.phase.merging")).toBeTruthy();
+ expect(merging!.getAttribute("aria-valuenow")).toBeNull();
+ expect(within(rowOf("Clip 2")).queryByText("40%")).toBeNull();
+ });
+
+ it("a paused download is waited on slowly, and the tray neither spins nor says done", () => {
+ poll([job(1, "paused")]);
+ mount();
+ // Nothing moves until its user resumes it: no fast polling for that.
+ expect(h.intervalMs([job(1, "paused")])).toBe(60000);
+ expect(h.intervalMs([job(1, "paused"), job(2, "queued")])).toBe(2000);
+ const marks = [...tray()!.querySelectorAll("svg")].map((i) => i.getAttribute("class") ?? "");
+ expect(marks.some((c) => /lucide-(circle-check|check-circle)/.test(c))).toBe(false);
+ expect(
+ marks.some((c) => /lucide-(circle-pause|pause-circle)/.test(c) && /text-warn/.test(c)),
+ ).toBe(true);
+ });
+
it("a failure says why, and its Retry resumes it", () => {
const resume = vi.spyOn(api, "resumeDownload").mockResolvedValue({} as never);
poll([job(3, "error", { error_code: "login_required" })]);
diff --git a/frontend/src/components/DownloadTray.tsx b/frontend/src/components/DownloadTray.tsx
index dcd75730..83e46e6b 100644
--- a/frontend/src/components/DownloadTray.tsx
+++ b/frontend/src/components/DownloadTray.tsx
@@ -9,6 +9,7 @@ import {
FolderOpen,
Link,
Loader2,
+ PauseCircle,
RotateCw,
X,
} from "lucide-react";
@@ -17,6 +18,8 @@ import { quickShareLink } from "../lib/downloads";
import { failCopyKeys, failTitleKey } from "../lib/downloadFailure";
import {
IN_FLIGHT,
+ DOWNLOAD_PHASES,
+ MOVING,
clearFinished,
doneRevealing,
fetchQueue,
@@ -66,22 +69,39 @@ const BYTE_PHASES = ["video", "audio", "editing", "converting"];
const keyOf = (job: QueueJob) => job.id;
+// The step a running job is on is named beside its bar: a download fetches its video and its audio
+// as two streams, so the bar fills twice — without the name, the second start reads as a restart.
function Progress({ job }: { job: QueueJob }) {
- if (job.phase && !BYTE_PHASES.includes(job.phase))
- return (
-
-
-
- );
+ const { t } = useTranslation();
+ const bytes = !job.phase || BYTE_PHASES.includes(job.phase);
+ const step =
+ job.phase && DOWNLOAD_PHASES.includes(job.phase) ? t(`downloads.phase.${job.phase}`) : null;
return (
-
-
-
+
+ {/* One column for the step, so every row's bar starts on the same line. */}
+ {step && (
+
+ {step}
+
+ )}
+
+ {bytes ? (
+
+ ) : (
+
+ )}
- {job.progress}%
+ {bytes && {job.progress}%}
);
}
@@ -95,7 +115,7 @@ export default function DownloadTray() {
// Every page polls it now, so the idle cadence is slow: a job started here refetches at once
// (invalidateDownloads), and one started on another device shows within a minute.
const queueQ = useLiveQuery(qk.downloads(), fetchQueue, {
- intervalMs: (data) => (data?.some((j) => IN_FLIGHT.has(j.status)) ? 2000 : 60000),
+ intervalMs: (data) => (data?.some((j) => MOVING.has(j.status)) ? 2000 : 60000),
});
const finished = useFinished();
const active = (queueQ.data ?? []).filter((j) => IN_FLIGHT.has(j.status) || j.status === "error");
@@ -225,13 +245,26 @@ export default function DownloadTray() {
const meanProgress = running.length
? Math.round(running.reduce((sum, j) => sum + j.progress, 0) / running.length)
: null;
- const StateIcon = counts.running ? Loader2 : counts.error ? AlertTriangle : CheckCircle2;
+ // The tray's state, worst news first; a paused job is neither working nor done.
+ const StateIcon = counts.running
+ ? Loader2
+ : counts.error
+ ? AlertTriangle
+ : counts.paused
+ ? PauseCircle
+ : CheckCircle2;
const stateIcon = (
);
diff --git a/frontend/src/components/FilePreview.dom.test.tsx b/frontend/src/components/FilePreview.dom.test.tsx
index 7d41591f..63462a64 100644
--- a/frontend/src/components/FilePreview.dom.test.tsx
+++ b/frontend/src/components/FilePreview.dom.test.tsx
@@ -21,6 +21,7 @@ vi.mock("react-i18next", async (importOriginal) => {
});
import FilePreview from "./FilePreview";
+import { pushLayer, removeLayer } from "../lib/layerStack";
const file = (id: number, preview: string | null, extra: Record = {}) =>
({
@@ -97,6 +98,21 @@ describe("FilePreview · what it shows", () => {
]);
});
+ it("lays out a long text's first lines only, and says where the rest is", async () => {
+ // Every line is a row of the page: a log of tens of thousands would stall the viewer.
+ const text = Array.from({ length: 6000 }, (_, i) => `line ${i + 1}`).join(
+ String.fromCharCode(10),
+ );
+ vi.spyOn(api, "previewText").mockResolvedValue({ text, truncated: false });
+ mount([file(9, "text")], 9);
+ await screen.findByText("line 1");
+ expect(document.querySelectorAll("li")).toHaveLength(5000);
+ expect(screen.getByText(/files\.preview\.firstLines/)).toBeTruthy();
+ expect(screen.getByText("files.preview.downloadWhole").closest("a")!.getAttribute("href")).toBe(
+ api.fileUrl(9),
+ );
+ });
+
it("says when it shows only the start of a long text", async () => {
vi.spyOn(api, "previewText").mockResolvedValue({ text: "a", truncated: true });
mount([file(4, "text")], 4);
@@ -141,6 +157,30 @@ describe("FilePreview · stepping", () => {
expect(onFileChange).toHaveBeenLastCalledWith(1);
});
+ it("a layer opened from it (a menu, a confirm) keeps its arrows — the viewer stays on its file", () => {
+ const { onFileChange } = mount(list, 2);
+ const confirm = pushLayer();
+ try {
+ fireEvent.keyDown(window, { key: "ArrowRight" });
+ expect(onFileChange).not.toHaveBeenCalled();
+ } finally {
+ removeLayer(confirm);
+ }
+ fireEvent.keyDown(window, { key: "ArrowRight" });
+ expect(onFileChange).toHaveBeenLastCalledWith(3);
+ });
+
+ it("a text line wider than the stage scrolls sideways under ← / →, it does not page", async () => {
+ vi.spyOn(api, "previewText").mockResolvedValue({ text: "a very long line", truncated: false });
+ const { onFileChange } = mount([file(1, "image"), file(8, "text"), file(9, "text")], 8);
+ await screen.findByText("a very long line");
+ const region = screen.getByRole("region");
+ Object.defineProperty(region, "scrollWidth", { value: 900 });
+ Object.defineProperty(region, "clientWidth", { value: 300 });
+ fireEvent.keyDown(region, { key: "ArrowRight" });
+ expect(onFileChange).not.toHaveBeenCalled();
+ });
+
it("stops at either end", () => {
const { onFileChange } = mount(list, 1);
fireEvent.keyDown(window, { key: "ArrowLeft" });
diff --git a/frontend/src/components/FilePreview.tsx b/frontend/src/components/FilePreview.tsx
index 6c37e261..2a5d6725 100644
--- a/frontend/src/components/FilePreview.tsx
+++ b/frontend/src/components/FilePreview.tsx
@@ -12,6 +12,7 @@ import {
import { api, HttpError, type FilePreviewKind, type LibraryFile } from "../lib/api";
import { formatBytes } from "../lib/format";
import { dispatch, useKeyScope } from "../lib/keymap";
+import { useLayer } from "../lib/layerStack";
import { qk } from "../lib/queryKeys";
import { jobTitle } from "./downloadTitle";
import { type ActionMenuItem } from "./ui/ActionMenu";
@@ -32,6 +33,8 @@ import { useContextMenu } from "./ui/useContextMenu";
//
// Every kind fills the same stage, so a step never resizes the dialog: its ‹ › stay under the pointer.
const STAGE = "h-[min(75dvh,56rem)] w-full";
+// How many lines of a text file the viewer lays out (see TextStage).
+const TEXT_LINES = 5000;
/** A text file's head (one read, shared by the viewer's header and its stage). */
function useTextPreview(id: number, enabled: boolean) {
@@ -74,7 +77,17 @@ export default function FilePreview({
if (next) onFileChange(next.id);
};
useKeyScope("fileViewer");
+ // The viewer's own place on the layer stack (above its dialog): ← / → are its only while nothing is
+ // on top of it — its "…" menu, a confirm, the Share or Details dialog opened from that menu. There an
+ // arrow moves through the layer's own buttons; paging the viewer under it would show one file while
+ // the dialog acts on another.
+ const layer = useLayer(true, undefined, { passive: true });
const onKey = useEffectEvent((e: KeyboardEvent) => {
+ if (layer.blockingAbove()) return;
+ // The text, scrolled sideways by its arrows: a line wider than the stage is read that way.
+ const scroller =
+ e.target instanceof Element ? e.target.closest("[data-scrolls-sideways]") : null;
+ if (scroller && scroller.scrollWidth > scroller.clientWidth) return;
dispatch(e, "fileViewer", {
prevFile: (ev) => {
ev.preventDefault();
@@ -217,8 +230,11 @@ function TextStage({ file }: { file: LibraryFile }) {
);
if (!q.data) return ;
- // A file's last line ends in a newline; that is no line of its own.
- const lines = q.data.text.replace(/\r?\n$/, "").split(/\r?\n/);
+ // A file's last line ends in a newline; that is no line of its own. Only the first TEXT_LINES are
+ // laid out: every line is a row of the page, and a head of short lines (a log) runs to tens of
+ // thousands of them — a stall on open and on every wrap toggle. The rest is behind Download.
+ const all = q.data.text.replace(/\r?\n$/, "").split(/\r?\n/);
+ const lines = all.length > TEXT_LINES ? all.slice(0, TEXT_LINES) : all;
return (
);
@@ -202,7 +202,8 @@ export default function FilesRail({
{failed}
{sources.map(entry)}
- {foldersQ.data && sources.length === 0 && (
+ {/* Only when there is nothing at all: files moved into own folders were still downloaded. */}
+ {foldersQ.data && sources.length === 0 && total === 0 && (
{t("files.rail.noSources")}
)}
diff --git a/frontend/src/components/LinkBox.tsx b/frontend/src/components/LinkBox.tsx
index 0911cb01..5a49cb17 100644
--- a/frontend/src/components/LinkBox.tsx
+++ b/frontend/src/components/LinkBox.tsx
@@ -41,7 +41,9 @@ export function LinkBox({ className }: { className?: string }) {
const paste = async (force: boolean) => {
if (!force && url.trim()) return;
const link = asDownloadLink(await clipboardText());
- if (link) setUrl(link);
+ // Asked again once the read is back: it can wait on a permission prompt, and what was typed
+ // meanwhile is the reader's.
+ if (link) setUrl((typed) => (force || !typed.trim() ? link : typed));
};
// The palette's "Download from link…" (lib/downloadIntake): the request wakes this effect, the
diff --git a/frontend/src/components/ProfileEditor.dom.test.tsx b/frontend/src/components/ProfileEditor.dom.test.tsx
index 10a3017f..710a6b50 100644
--- a/frontend/src/components/ProfileEditor.dom.test.tsx
+++ b/frontend/src/components/ProfileEditor.dom.test.tsx
@@ -111,7 +111,9 @@ describe("DownloadFormats", () => {
await mount();
await openMenu(rowOf("Phone 480"));
pick("downloads.profiles.duplicate");
- const name = await screen.findByPlaceholderText("downloads.profiles.namePlaceholder");
+ // The form opens with the keyboard in its name, the field named by its label.
+ const name = await screen.findByRole("textbox", { name: "downloads.profiles.name" });
+ await waitFor(() => expect(document.activeElement).toBe(name));
expect((name as HTMLInputElement).value).toBe("");
fireEvent.change(name, { target: { value: "Phone copy" } });
fireEvent.click(screen.getByText("downloads.profiles.create"));
diff --git a/frontend/src/components/ProfileEditor.tsx b/frontend/src/components/ProfileEditor.tsx
index 15c7bd7f..e84ec870 100644
--- a/frontend/src/components/ProfileEditor.tsx
+++ b/frontend/src/components/ProfileEditor.tsx
@@ -1,4 +1,4 @@
-import { useState } from "react";
+import { useEffect, useId, useRef, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
@@ -93,6 +93,14 @@ export function DownloadFormats() {
const [name, setName] = useState("");
const [spec, setSpec] = useState(BLANK);
const [formOpen, setFormOpen] = useState(false);
+ // Opening the form (New, Edit, Duplicate) puts the keyboard in its name: the form opens away from
+ // the control that asked for it, and "New format" is replaced by it.
+ const [opened, setOpened] = useState(0);
+ const nameField = useRef(null);
+ const nameId = useId();
+ useEffect(() => {
+ if (opened) nameField.current?.focus();
+ }, [opened]);
const invalidate = () => void qc.invalidateQueries({ queryKey: qk.downloadProfiles() });
const startNew = () => {
@@ -100,12 +108,14 @@ export function DownloadFormats() {
setName("");
setSpec(BLANK);
setFormOpen(true);
+ setOpened((n) => n + 1);
};
const startEdit = (p: DownloadProfile) => {
setEditing(p);
setName(p.name);
setSpec({ ...BLANK, ...p.spec });
setFormOpen(true);
+ setOpened((n) => n + 1);
};
// A new format from an existing one: its settings, and a name of its own to give.
const startCopy = (p: DownloadProfile) => {
@@ -113,6 +123,7 @@ export function DownloadFormats() {
setName("");
setSpec({ ...BLANK, ...p.spec });
setFormOpen(true);
+ setOpened((n) => n + 1);
};
const save = useMutation({
@@ -261,8 +272,12 @@ export function DownloadFormats() {
) : (
-
+
setName(e.target.value)}
diff --git a/frontend/src/components/VideoEditor.tsx b/frontend/src/components/VideoEditor.tsx
index f8ce3687..a52bac94 100644
--- a/frontend/src/components/VideoEditor.tsx
+++ b/frontend/src/components/VideoEditor.tsx
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
+import { invalidateDownloads } from "../lib/downloads";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -280,8 +281,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
// so trying again queued the first two a second time. Say how many made it, and deselect them
// so another try adds only the rest.
if (done.length) {
- qc.invalidateQueries({ queryKey: qk.downloads() });
- qc.invalidateQueries({ queryKey: qk.storageUsage() });
+ invalidateDownloads(qc);
setSegments((segs) => segs.map((s) => (done.includes(s.id) ? { ...s, keep: false } : s)));
// Its own toast, not notifyActionError: a quota or edit refusal has already opened req's
// error dialog and marked the error, so that helper would say nothing — and the count is
@@ -298,8 +298,8 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
return { files: N };
},
onSuccess: ({ files }) => {
- qc.invalidateQueries({ queryKey: qk.downloads() });
- qc.invalidateQueries({ queryKey: qk.storageUsage() });
+ // The whole library moves with it: an edit the user already made is done at once, and lists.
+ invalidateDownloads(qc);
notify({ level: "success", message: t("editor.created", { count: files }) });
onClose();
},
diff --git a/frontend/src/components/downloadAdmin.tsx b/frontend/src/components/downloadAdmin.tsx
index fe9d4fc4..a6059384 100644
--- a/frontend/src/components/downloadAdmin.tsx
+++ b/frontend/src/components/downloadAdmin.tsx
@@ -1,8 +1,8 @@
import { useState } from "react";
+import { Meter } from "./ui/Meter";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import clsx from "clsx";
import { ExternalLink, RotateCcw, Settings2 } from "lucide-react";
import Modal, { ModalFooter } from "./ui/Modal";
import { useLiveQuery } from "../lib/useLiveQuery";
@@ -441,22 +441,14 @@ function QuotaFill({ user, lang }: { user: AdminStorageUser; lang: string }) {
const { t } = useTranslation();
if (user.unlimited || !user.max_bytes)
return {t("downloads.admin.unlimitedShort")};
- const pct = Math.min(100, Math.round((user.footprint_bytes / user.max_bytes) * 100));
return (
-
- = 90 ? "bg-danger" : "bg-accent")}
- style={{ width: `${pct}%` }}
- />
-
+
{t("downloads.admin.ofQuota", { size: formatBytes(user.max_bytes, lang) })}
diff --git a/frontend/src/components/downloadRow.tsx b/frontend/src/components/downloadRow.tsx
index 18d36340..b192396e 100644
--- a/frontend/src/components/downloadRow.tsx
+++ b/frontend/src/components/downloadRow.tsx
@@ -2,6 +2,7 @@ import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { File, Film, Music } from "lucide-react";
import clsx from "clsx";
+import { DOWNLOAD_PHASES } from "../lib/downloadQueue";
import { api, type DownloadJob } from "../lib/api";
import { IconButton } from "./ui/IconButton";
import { MediaProgress } from "./ui/MediaCard";
@@ -19,24 +20,11 @@ const STATUS_CLS: Record = {
canceled: "text-muted",
};
-const PHASE_KEYS = [
- "video",
- "audio",
- "editing",
- "converting",
- "merging",
- "audio_extract",
- "thumbnail",
- "sponsorblock",
- "metadata",
- "processing",
-];
-
export function StatusPill({ job }: { job: DownloadJob }) {
const { t } = useTranslation();
// While downloading, show the concrete phase (video / audio / merging) so the user isn't
// confused by the bar resetting between the separate video and audio streams.
- if (job.status === "running" && job.phase && PHASE_KEYS.includes(job.phase)) {
+ if (job.status === "running" && job.phase && DOWNLOAD_PHASES.includes(job.phase)) {
return (
{t(`downloads.phase.${job.phase}`)}
);
@@ -48,13 +36,9 @@ export function StatusPill({ job }: { job: DownloadJob }) {
);
}
-// The row answers to its OWN width (a container query), not the window's: the same list sits in a
-// phone, a split-screen tablet and a desktop column. Below 40rem the actions no longer leave the title
-// column room for the meta line, so the row stacks (the owner's pick A): the picture beside a
-// two-line title and the channel; the meta line across the whole row; then the source link and the
-// actions on one wrapping line.
+// A picture's box when the caller gives none: a thumbnail-sized 16:9 frame.
const THUMB_BOX =
- "w-28 @max-[40rem]/dlrow:w-24 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border";
+ "w-28 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border";
/** A job's picture: its poster, else the source's thumbnail, else a glyph of what it is. `box` sizes
* it (the Files list draws a smaller one). */
diff --git a/frontend/src/components/fileFolders.tsx b/frontend/src/components/fileFolders.tsx
index 0ba8afe2..0649e7d8 100644
--- a/frontend/src/components/fileFolders.tsx
+++ b/frontend/src/components/fileFolders.tsx
@@ -1,9 +1,9 @@
-import { Film, Folder, Globe, Server, Upload, type LucideIcon } from "lucide-react";
+import { Film, Folder, Globe, Upload, type LucideIcon } from "lucide-react";
import { YouTubeIcon } from "../lib/brandIcons";
import type { FileFolder } from "../lib/api";
// A library folder's and a file's origin, named and drawn. The backend sends a source folder's KEY
-// (`youtube`, `plex`, `upload`, `hub`, `site:`, see backend downloads/folders.py) and leaves
+// (`youtube`, `plex`, `upload`, `site:`, see backend downloads/folders.py) and leaves
// its name to the client, in the reader's language; an own folder carries the name its user gave it.
const SITE_PREFIX = "site:";
@@ -13,8 +13,7 @@ type T = (key: string) => string;
/** The name of a source: a site is its host, the rest are words of the UI. */
export function sourceLabel(key: string, t: T): string {
if (key.startsWith(SITE_PREFIX)) return key.slice(SITE_PREFIX.length);
- if (key === "youtube" || key === "plex" || key === "upload" || key === "hub")
- return t(`files.source.${key}`);
+ if (key === "youtube" || key === "plex" || key === "upload") return t(`files.source.${key}`);
return key;
}
@@ -22,7 +21,6 @@ export function sourceIcon(key: string): LucideIcon | typeof YouTubeIcon {
if (key === "youtube") return YouTubeIcon;
if (key === "plex") return Film;
if (key === "upload") return Upload;
- if (key === "hub") return Server;
return Globe;
}
diff --git a/frontend/src/components/ui/DataTable.pager.dom.test.tsx b/frontend/src/components/ui/DataTable.pager.dom.test.tsx
index 49d7621a..b06e94dd 100644
--- a/frontend/src/components/ui/DataTable.pager.dom.test.tsx
+++ b/frontend/src/components/ui/DataTable.pager.dom.test.tsx
@@ -36,4 +36,27 @@ describe("DataTable pager", () => {
expect(screen.getByText("row 12")).toBeTruthy();
expect(screen.queryByText("datatable.rowsPerPage")).toBeNull();
});
+
+ it("another list (pageScope) starts on its first page; a remembered page comes back only for its own", () => {
+ const table = (scope: string) => (
+ r.id}
+ pageSize={10}
+ persistKey="t"
+ pageScope={scope}
+ />
+ );
+ localStorage.setItem("t", JSON.stringify({ size: 5, page: 2, pageScope: "folder 1" }));
+ const { rerender } = render(table("folder 1"));
+ expect(screen.getByText("row 11")).toBeTruthy(); // the page it was on, in the list it was on
+ rerender(table("folder 2"));
+ expect(screen.getByText("row 1")).toBeTruthy();
+ expect(screen.queryByText("row 11")).toBeNull();
+ cleanup();
+ localStorage.setItem("t", JSON.stringify({ size: 5, page: 2, pageScope: "folder 1" }));
+ render(table("a search"));
+ expect(screen.getByText("row 1")).toBeTruthy();
+ });
});
diff --git a/frontend/src/components/ui/DataTable.tsx b/frontend/src/components/ui/DataTable.tsx
index af8f6045..bf179689 100644
--- a/frontend/src/components/ui/DataTable.tsx
+++ b/frontend/src/components/ui/DataTable.tsx
@@ -31,6 +31,8 @@ interface Persisted {
filters: FilterMap;
page: number;
size?: number;
+ // The `pageScope` the page was on: it is taken back only for the same list.
+ pageScope?: string;
}
// Stable empty-map identity for the controlled-but-value-omitted fallback, so `filters` keeps a
@@ -38,12 +40,13 @@ interface Persisted {
// render). Mirrors controlled sort's stable `null` fallback.
const EMPTY_FILTERS: FilterMap = {};
-function loadPersist(key?: string): Persisted {
+function loadPersist(key?: string, pageScope?: string): Persisted {
const empty: Persisted = { sort: null, filters: {}, page: 0 };
if (!key) return empty;
try {
const v = JSON.parse(localStorage.getItem(key) || "{}");
- return { sort: v.sort ?? null, filters: v.filters ?? {}, page: v.page ?? 0, size: v.size };
+ const page = pageScope === undefined || v.pageScope === pageScope ? (v.page ?? 0) : 0;
+ return { sort: v.sort ?? null, filters: v.filters ?? {}, page, size: v.size };
} catch {
return empty;
}
@@ -114,6 +117,7 @@ export default function DataTable({
reveal,
surfacedFilters,
narrowedBy = "",
+ pageScope,
}: {
rows: T[];
columns: Column[];
@@ -181,10 +185,13 @@ export default function DataTable({
// in the shared top bar): a change starts a long page over from its first rows, as the table's own
// sort and filter changes do (progressiveRows). Without it every key re-rendered the whole page.
narrowedBy?: string;
+ // Which list `rows` are, when one table shows several by turns (a folder, then another; a search):
+ // another list starts on its first page, and a remembered page is only taken back for its own list.
+ pageScope?: string;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
- const initial = loadPersist(persistKey);
+ const initial = loadPersist(persistKey, pageScope);
// Sort is controlled when the caller passes onSortChange (it then owns/persists it); otherwise
// the table keeps its own, seeded from and saved to persistKey.
const controlledSort = onSortChange !== undefined;
@@ -196,6 +203,12 @@ export default function DataTable({
const [internalFilters, setInternalFilters] = useState(initial.filters);
const filters = controlledFilters ? (filtersProp ?? EMPTY_FILTERS) : internalFilters;
const [page, setPage] = useState(initial.page);
+ // Adjusted during render against the last list seen, so the first frame of a new list is its first page.
+ const [scopeSeen, setScopeSeen] = useState(pageScope);
+ if (pageScope !== scopeSeen) {
+ setScopeSeen(pageScope);
+ setPage(0);
+ }
// 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)");
@@ -236,9 +249,10 @@ export default function DataTable({
filters: controlledFilters ? {} : filters,
page,
size,
+ pageScope,
}),
);
- }, [persistKey, controlledSort, controlledFilters, sort, filters, page, size]);
+ }, [persistKey, controlledSort, controlledFilters, sort, filters, page, size, pageScope]);
// Destructured so the deps are the values the body actually reads. `externalFilter` itself is an
// object literal at every call site, so depending on it would re-apply the filter — and reset the
diff --git a/frontend/src/components/ui/Meter.dom.test.tsx b/frontend/src/components/ui/Meter.dom.test.tsx
new file mode 100644
index 00000000..30332a4d
--- /dev/null
+++ b/frontend/src/components/ui/Meter.dom.test.tsx
@@ -0,0 +1,31 @@
+// @vitest-environment jsdom
+//
+// One meter for every place that shows how full a quota is: it turns to a warning at the share it
+// SHOWS (the rounded percent), so the user's own rail and the admin's Storage tab never disagree
+// about the same fill, and it reads as a meter with that percent.
+import { afterEach, describe, expect, it } from "vitest";
+import { cleanup, render, screen } from "@testing-library/react";
+import { Meter } from "./Meter";
+
+afterEach(cleanup);
+
+const fill = () => screen.getByRole("meter").firstElementChild!;
+
+describe("Meter", () => {
+ it.each([
+ [896, "90", true],
+ [894, "89", false],
+ [2000, "100", true],
+ ])("%i of 1000 shows its percent and warns from 90", (value, shown, danger) => {
+ render();
+ expect(screen.getByRole("meter", { name: "Storage" }).getAttribute("aria-valuenow")).toBe(
+ shown,
+ );
+ expect(fill().classList.contains("bg-danger")).toBe(danger);
+ });
+
+ it("an unset limit is an empty meter, never a division by zero", () => {
+ render();
+ expect(screen.getByRole("meter").getAttribute("aria-valuenow")).toBe("0");
+ });
+});
diff --git a/frontend/src/components/ui/Meter.tsx b/frontend/src/components/ui/Meter.tsx
new file mode 100644
index 00000000..12d5f3ea
--- /dev/null
+++ b/frontend/src/components/ui/Meter.tsx
@@ -0,0 +1,46 @@
+import clsx from "clsx";
+
+/** How full something is against its limit, from this share up drawn as a warning. */
+const METER_DANGER_PCT = 90;
+
+/** The share of `max` that `value` fills, as the whole percent a reader is shown (0–100). */
+function meterPct(value: number, max: number): number {
+ return max > 0 ? Math.min(100, Math.round((value / max) * 100)) : 0;
+}
+
+// A thin bar of how full something is (a storage quota): one drawing and one danger line for every
+// place that shows the same measure, and a `meter` to assistive tech with the percent it shows — two
+// hand-painted bars once turned red at different fills for the same quota.
+export function Meter({
+ value,
+ max,
+ label,
+ className,
+}: {
+ value: number;
+ max: number;
+ /** The meter's name for assistive tech (what it measures). */
+ label: string;
+ /** Its height and place, e.g. `h-1.5`; the track and the fill are the meter's. */
+ className?: string;
+}) {
+ const pct = meterPct(value, max);
+ return (
+
+ = METER_DANGER_PCT ? "bg-danger" : "bg-accent",
+ )}
+ style={{ width: `${pct}%` }}
+ />
+
+ );
+}
diff --git a/frontend/src/components/ui/README.md b/frontend/src/components/ui/README.md
index 4a59f260..86f00cc6 100644
--- a/frontend/src/components/ui/README.md
+++ b/frontend/src/components/ui/README.md
@@ -35,13 +35,13 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage
## What lives here
-| area | components |
-| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
-| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `