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 (
@@ -239,8 +255,10 @@ function TextStage({ file }: { file: LibraryFile }) {
))} + {lines.length < all.length && ( +

+ {t("files.preview.firstLines", { lines: TEXT_LINES })}{" "} + {t("files.preview.downloadWhole")} +

+ )}
); diff --git a/frontend/src/components/Files.menu.dom.test.tsx b/frontend/src/components/Files.menu.dom.test.tsx index 325f8771..227f7f4b 100644 --- a/frontend/src/components/Files.menu.dom.test.tsx +++ b/frontend/src/components/Files.menu.dom.test.tsx @@ -26,6 +26,14 @@ const mocks = vi.hoisted(() => ({ confirm: vi.fn(async (_o?: unknown) => true), keys: [] as unknown[], fetching: false, + titleArgs: [] as unknown[], + searched: [] as unknown[], + foldersFailed: false, + notify: vi.fn(), +})); +vi.mock("../lib/notifications", async (importOriginal) => ({ + ...(await importOriginal()), + notify: mocks.notify, })); vi.mock("react-i18next", async (importOriginal) => { @@ -34,19 +42,39 @@ vi.mock("react-i18next", async (importOriginal) => { ...actual, useTranslation: () => ({ i18n: { language: "en" }, - t: (key: string, o?: Record) => - o && "folder" in o ? `${key}:${String(o.folder)}` : key, + t: (key: string, o?: Record) => { + if (o) mocks.titleArgs.push([key, o]); + return o && "folder" in o ? `${key}:${String(o.folder)}` : key; + }, }), }; }); +vi.mock("./LibraryPlayer", () => ({ + default: ({ job, fromStart }: { job: { id: number }; fromStart?: boolean }) => ( +
{`playing ${job.id}${fromStart ? " from the start" : ""}`}
+ ), +})); vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm })); vi.mock("../lib/useLiveQuery", () => ({ - useLiveQuery: (key: readonly unknown[]) => { + useLiveQuery: (key: readonly unknown[], _fn: unknown, opts?: { enabled?: boolean }) => { + if (opts?.enabled === false) return { data: undefined, isError: false, isLoading: false }; mocks.keys.push(key); + const asked = key[1] as { q?: string; sort?: string } | undefined; return { data: - key[0] === "files" ? mocks.files : key[0] === "file-folders" ? mocks.folders : undefined, - isError: false, + key[0] === "files" + ? asked?.q || asked?.sort + ? mocks.searched + : mocks.files + : key[0] === "file-folders" + ? mocks.foldersFailed + ? undefined + : mocks.folders + : undefined, + isError: key[0] === "file-folders" && mocks.foldersFailed, isLoading: false, isFetching: mocks.fetching, refetch: vi.fn(), @@ -59,6 +87,7 @@ vi.mock("./ui/PageShell", async (importOriginal) => ({ })); import Files from "./Files"; +import { FILES_LIST_CAP } from "../lib/api"; const file = (id: number, extra: Partial = {}): LibraryFile => ({ @@ -172,10 +201,10 @@ describe("Files · my file's row", () => { "files.actions.move", "files.actions.keep", "files.actions.details", - "downloads.actions.redownload", + "files.actions.redownload", "common.copy.title", "files.actions.copySourceLink", - "downloads.actions.delete", + "files.actions.delete", ]); }); @@ -265,6 +294,23 @@ describe("Files · my file's row", () => { await waitFor(() => expect(update).toHaveBeenLastCalledWith(2, { name: null })); }); + it("a rename left as it was sends nothing — a name never set stays unset", async () => { + // The field starts on the title the file shows; saving that would pin it as a name of its own. + mocks.files = [file(2, { display_name: null, asset: { title: "Its own title" } } as never)]; + mocks.folders = [YT]; + const update = vi.spyOn(api, "updateFile").mockResolvedValue({} as never); + mount(); + await openMenu(() => fireEvent.contextMenu(rowOf("Its own title"))); + fireEvent.click(menuItem("files.actions.rename")); + const field = await screen.findByRole("textbox", { name: "files.actions.rename" }); + expect((field as HTMLInputElement).value).toBe("Its own title"); + fireEvent.keyDown(field, { key: "Enter" }); + await waitFor(() => + expect(screen.queryByRole("textbox", { name: "files.actions.rename" })).toBeNull(), + ); + expect(update).not.toHaveBeenCalled(); + }); + it("Escape leaves the name as it was", async () => { mocks.files = [file(2)]; mocks.folders = [YT]; @@ -329,15 +375,39 @@ describe("Files · my file's row", () => { mocks.confirm.mockResolvedValueOnce(false); mount(); await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2"))); - fireEvent.click(menuItem("downloads.actions.delete")); + fireEvent.click(menuItem("files.actions.delete")); await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1)); expect(del).not.toHaveBeenCalled(); await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2"))); - fireEvent.click(menuItem("downloads.actions.delete")); + fireEvent.click(menuItem("files.actions.delete")); await waitFor(() => expect(del).toHaveBeenCalledWith(2)); }); }); +describe("Files · a library past the list's cap", () => { + it("a search under the cap runs on the rows; one past it asks the server, so an old file is found", () => { + mocks.folders = [YT]; + mocks.files = [file(1, { display_name: "Recept" }), file(2)]; + mocks.keys = []; + mount(ALL_FILES, vi.fn(), "recept"); + expect(rowOf("Recept")).toBeTruthy(); + expect( + mocks.keys.some( + (k) => (k as unknown[])[0] === "files" && "q" in ((k as unknown[])[1] as object), + ), + ).toBe(false); + cleanup(); + + // The newest FILES_LIST_CAP files came back: the one searched for may be older than all of them. + mocks.files = Array.from({ length: FILES_LIST_CAP }, (_, i) => file(i + 1)); + mocks.searched = [file(9999, { display_name: "Régi recept" })]; + mocks.keys = []; + mount(ALL_FILES, vi.fn(), "recept"); + expect(rowOf("Régi recept")).toBeTruthy(); + expect(mocks.keys).toContainEqual(["files", { q: "recept" }]); + }); +}); + describe("Files · the confirms", () => { it("label the confirm button with the action, not the dialog's question", async () => { mocks.files = [file(2)]; @@ -345,10 +415,10 @@ describe("Files · the confirms", () => { mocks.confirm.mockResolvedValue(false); mount(); await openMenu(() => fireEvent.click(moreOf("Clip 2"))); - fireEvent.click(menuItem("downloads.actions.redownload")); + fireEvent.click(menuItem("files.actions.redownload")); await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1)); await openMenu(() => fireEvent.click(moreOf("Clip 2"))); - fireEvent.click(menuItem("downloads.actions.delete")); + fireEvent.click(menuItem("files.actions.delete")); await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(2)); expect( mocks.confirm.mock.calls.map(([o]) => { @@ -363,20 +433,21 @@ describe("Files · the confirms", () => { }); it.each([ - ["upload", "downloads.confirm.deleteUploadTitle", "downloads.confirm.deleteUploadBody"], - ["edit", "downloads.confirm.deleteEditTitle", "downloads.confirm.deleteEditBody"], - ["download", "downloads.confirm.deleteTitle", "downloads.confirm.deleteBody"], - ] as const)("deleting a %s says what happens to its file", async (kind, title, body) => { - // Only a download's file may stay cached for other users; an upload's or a clip's is theirs alone. + ["upload", "downloads.confirm.deleteUploadBody"], + ["edit", "downloads.confirm.deleteEditBody"], + ["download", "downloads.confirm.deleteBody"], + ] as const)("deleting a %s names it and says what happens to its file", async (kind, body) => { + // Only a download's file may stay for other users; an upload's or a clip's is theirs alone. mocks.files = [file(5, { job_kind: kind })]; mocks.folders = [YT]; mocks.confirm.mockResolvedValueOnce(false); mount(); await openMenu(() => fireEvent.click(moreOf("Clip 5"))); - fireEvent.click(menuItem("downloads.actions.delete")); + fireEvent.click(menuItem("files.actions.delete")); await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1)); const opts = mocks.confirm.mock.calls[0]![0] as { title: string; message?: string }; - expect([opts.title, opts.message]).toEqual([title, body]); + expect([opts.title, opts.message]).toEqual(["downloads.confirm.deleteTitle", body]); + expect(mocks.titleArgs).toContainEqual(["downloads.confirm.deleteTitle", { name: "Clip 5" }]); }); }); @@ -393,9 +464,9 @@ describe("Files · a shared file's row", () => { "downloads.actions.download", "downloads.actions.edit", "common.copy.title", - "downloads.actions.removeShared", + "files.actions.removeShared", ]); - fireEvent.click(menuItem("downloads.actions.removeShared")); + fireEvent.click(menuItem("files.actions.removeShared")); await waitFor(() => expect(remove).toHaveBeenCalledWith(4)); // The shared list is its own read, and nothing can be uploaded into it. expect(mocks.keys).toContainEqual(["files", { shared: true }]); @@ -547,7 +618,7 @@ describe("Files · the grid", () => { expect(rowOf("Clip 2").textContent).toContain("files.retention.kept"); await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2"))); expect(menuRows()).toContain("files.actions.move"); - expect(menuRows()).toContain("downloads.actions.delete"); + expect(menuRows()).toContain("files.actions.delete"); }); it("is remembered per folder", async () => { @@ -765,17 +836,17 @@ describe("Files · arriving at one file", () => { }); }); -describe("Files · dropping a file on the page", () => { - const drag = (type: string, files: File[] = []) => { - const e = new Event(type, { bubbles: true, cancelable: true }); - const list = Object.assign([...files], { item: (i: number) => files[i] ?? null }); - Object.defineProperty(e, "dataTransfer", { value: { types: ["Files"], files: list } }); - act(() => { - window.dispatchEvent(e); - }); - return e; - }; +const drag = (type: string, files: File[] = []) => { + const e = new Event(type, { bubbles: true, cancelable: true }); + const list = Object.assign([...files], { item: (i: number) => files[i] ?? null }); + Object.defineProperty(e, "dataTransfer", { value: { types: ["Files"], files: list } }); + act(() => { + window.dispatchEvent(e); + }); + return e; +}; +describe("Files · dropping a file on the page", () => { it("names the folder it lands in, and opens Upload with the file", async () => { mocks.files = []; mocks.folders = [YT, OWN]; @@ -789,11 +860,100 @@ describe("Files · dropping a file on the page", () => { expect(await screen.findByText("recept.pdf")).toBeTruthy(); }); - it("is no target where nothing can be uploaded", () => { + it("is no target where nothing can be uploaded — but the browser never gets the file", () => { mocks.files = []; mocks.folders = [YT]; mount({ kind: "shared" }); - expect(drag("dragenter").defaultPrevented).toBe(false); + drag("dragenter"); expect(screen.queryByText(/files\.drop\.here/)).toBeNull(); + // Left to the browser, a dropped file opens in the tab and the app is gone. + const drop = drag("drop", [new File(["x"], "recept.pdf")]); + expect(drop.defaultPrevented).toBe(true); + expect(screen.queryByText("recept.pdf")).toBeNull(); + }); + + it("a second file dropped beside the open Upload dialog does not unload the app", async () => { + mocks.files = []; + mocks.folders = [YT, OWN]; + vi.spyOn(api, "filesUsage").mockResolvedValue({ upload_max_bytes: 1_000_000 } as never); + mount({ kind: "folder", id: 2 }); + drag("drop", [new File(["x"], "recept.pdf")]); + expect(await screen.findByText("recept.pdf")).toBeTruthy(); + expect(drag("dragover").defaultPrevented).toBe(true); + expect(drag("drop", [new File(["y"], "masik.pdf")]).defaultPrevented).toBe(true); + }); +}); + +describe("Files · when what it needs is missing", () => { + afterEach(() => { + mocks.foldersFailed = false; + mocks.fetching = false; + mocks.notify.mockClear(); + }); + + it("folders that failed to load are said to be missing, never shown as none — and take no drop", async () => { + mocks.files = [file(2)]; + mocks.foldersFailed = true; + mount({ kind: "folder", id: 2 }); + expect(screen.getByText("files.folderUnknown")).toBeTruthy(); + // Dropped now, the file would land in Uploads instead of the folder on screen. + drag("dragenter"); + expect(screen.queryByText(/files\.drop\.here/)).toBeNull(); + await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2"))); + fireEvent.click(menuItem("files.actions.move")); + expect(await screen.findByText("files.actions.foldersUnknown")).toBeTruthy(); + expect(screen.queryByText("files.actions.noOwnFolders")).toBeNull(); + }); + + it("a folder the cached list does not have yet is waited for, not taken for gone", () => { + mocks.files = [file(2)]; + mocks.folders = [YT]; + mocks.fetching = true; + const setView = mount({ kind: "folder", id: 7 }); + expect(setView).not.toHaveBeenCalled(); + }); + + it("opening a file that is no longer there says so", async () => { + mocks.files = [file(2)]; + mocks.folders = [YT]; + mount(ALL_FILES, vi.fn(), "", { focus: { id: 9, token: 1 } }); + await waitFor(() => + expect(mocks.notify).toHaveBeenCalledWith({ level: "info", message: "files.arrival.gone" }), + ); + }); +}); + +describe("Files · the open folder deleted", () => { + it("hands the keyboard to the page's heading, not to ", async () => { + // The deleted folder's "…" held the focus and is gone with it. + mocks.files = []; + mocks.folders = [YT]; + (document.activeElement as HTMLElement | null)?.blur(); + mount({ kind: "folder", id: 2 }); + await waitFor(() => + expect(document.activeElement).toBe(screen.getByRole("heading", { name: "files.rail.all" })), + ); + }); +}); + +describe("Files · a file watched part way", () => { + it("leads with Continue and offers Start over, which plays it from the start", async () => { + mocks.files = [file(2, { position_seconds: 120 })]; + mocks.folders = [YT]; + mount(); + await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2"))); + expect(menuRows().slice(0, 2)).toEqual(["card.continue", "card.restartTitle"]); + fireEvent.click(menuItem("card.restartTitle")); + expect(await screen.findByText("playing 2 from the start")).toBeTruthy(); + }); + + it("one never played has no Start over, and plays where it is", async () => { + mocks.files = [file(3)]; + mocks.folders = [YT]; + mount(); + await openMenu(() => fireEvent.contextMenu(rowOf("Clip 3"))); + expect(menuRows()).not.toContain("card.restartTitle"); + fireEvent.click(menuItem("downloads.actions.play")); + expect(await screen.findByText("playing 3")).toBeTruthy(); }); }); diff --git a/frontend/src/components/Files.tsx b/frontend/src/components/Files.tsx index 38d7a39d..260a71a6 100644 --- a/frontend/src/components/Files.tsx +++ b/frontend/src/components/Files.tsx @@ -1,7 +1,15 @@ import { lazy, Suspense, useEffect, useEffectEvent, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Download, LayoutGrid, Link, List, Scissors, Share2, UploadCloud } from "lucide-react"; -import { api, type FileFolder, type FileListParams, type LibraryFile } from "../lib/api"; +import { + api, + FILE_SORTS, + FILES_LIST_CAP, + type FileFolder, + type FileListParams, + type FileSort, + type LibraryFile, +} from "../lib/api"; import { type SortState, sortRows } from "../lib/columnSort"; import { libraryRowActions, sharedRowActions } from "../lib/downloadActions"; import { formatBytes, formatDateTime, relativeTime } from "../lib/format"; @@ -10,6 +18,7 @@ import { notify } from "../lib/notifications"; import { qk } from "../lib/queryKeys"; import { LS, accountKey } from "../lib/storage"; import { useCardPager } from "../lib/useCardPager"; +import { useDebounced } from "../lib/useDebounced"; import { useLiveQuery } from "../lib/useLiveQuery"; import { usePref } from "../lib/usePref"; import { JobThumb } from "./downloadRow"; @@ -21,7 +30,7 @@ import { folderLabel } from "./fileFolders"; import { ALL_FILES, useFiles, useFilesActions, viewParam, type FilesView } from "./filesContext"; import { useFileActions } from "./useFileActions"; import { useFolderActions } from "./useFolderActions"; -import { RenameField } from "./RenameField"; +import { RenameField } from "./ui/RenameField"; import ViewSwitcher, { type ViewOption } from "./ViewSwitcher"; import { Badge } from "./ui/Badge"; import { Button } from "./ui/Button"; @@ -86,8 +95,8 @@ function shownFileElement(within: HTMLElement, id: number): HTMLElement | null { return all.find((el) => el.getClientRects().length > 0) ?? all[0] ?? null; } -/** The list a view reads. The search is applied here, on the rows (the list is the whole library, - * capped server-side): a request per keystroke would buy nothing the rows do not already hold. */ +/** The list a view reads. Below the server's cap it holds every file of the view, so the search and + * the sort run here, on the rows; a view that fills the cap asks the server for both (see below). */ function listParams(view: FilesView): FileListParams { if (view.kind === "shared") return { shared: true }; if (view.kind === "folder") return { folderId: view.id }; @@ -165,18 +174,49 @@ export default function Files() { const folders = foldersQ.data ?? []; // A remembered or bookmarked folder that is gone (deleted, or another account's id) opens the whole // library instead of an empty page that names nothing; the provider is told, so the rail agrees. - const gone = view.kind === "folder" && !!foldersQ.data && !folders.some((f) => f.id === view.id); + // Decided on a fresh list only: a folder made since the cached one (a first download from a new + // site, opened from its notice) is not gone, just not in the cache yet. + const gone = + view.kind === "folder" && + !!foldersQ.data && + !foldersQ.isFetching && + !folders.some((f) => f.id === view.id); const shown = gone ? ALL_FILES : view; + // A folder deleted from its own "…" (or its rail entry) takes the focused button with it: the + // keyboard goes to the page's heading, never to , where Tab would start the page over. + const heading = useRef(null); useEffect(() => { - if (gone) setView(ALL_FILES); + if (!gone) return; + setView(ALL_FILES); + const lost = document.activeElement; + if (!lost || lost === document.body || !lost.isConnected) heading.current?.focus(); }, [gone, setView]); + // One sort for both paints: the table's headers and the grid's dropdown move the same state. + const [sort, setSort] = usePref("filesSort", null, { localKey: LS.filesSort }); const params = listParams(shown); // Live at rest: a download finishing elsewhere (the queue, a clip, another tab) lands here on its own. const filesQ = useLiveQuery(qk.files(params), () => api.files(params), { intervalMs: 15000 }); - const all = filesQ.data ?? []; const q = search.trim(); - const rows = all.filter((f) => matches(f, q, lang)); + // A view that fills the server's cap holds only its newest files: searching or sorting those rows + // would miss an older file, so the server does both, over the whole view. Asked once the typing + // pauses; a sort the server does not know (the source, the retention) stays on the rows. + const capped = (filesQ.data?.length ?? 0) >= FILES_LIST_CAP; + const typed = useDebounced(q, 300); + const serverSort = sort && (FILE_SORTS as readonly string[]).includes(sort.key) ? sort : null; + const wide = capped && (!!typed || !!serverSort); + const wideParams: FileListParams = { + ...params, + q: typed, + ...(serverSort && { sort: serverSort.key as FileSort, order: serverSort.dir }), + }; + const wideQ = useLiveQuery(qk.files(wideParams), () => api.files(wideParams), { + intervalMs: 15000, + enabled: wide, + }); + const listQ = wide ? wideQ : filesQ; + const all = listQ.data ?? []; + const rows = wide ? all : all.filter((f) => matches(f, q, lang)); const listRef = useRef(null); const [renamingId, setRenamingId] = useState(null); @@ -199,7 +239,7 @@ export default function Files() { const [viewing, setViewing] = useState(null); const actions = useFileActions({ - folders, + folders: foldersQ.data ?? null, onPlay: play, onView: (f) => setViewing(f.id), onEdit: setEditFile, @@ -233,9 +273,13 @@ export default function Files() { : shown.kind === "folder" ? open ? folderLabel(open, t) - : "" + : foldersQ.isError && !foldersQ.data + ? t("files.folderUnknown") + : "" : t("files.rail.all"); - const canUpload = shown.kind !== "shared"; + // Not before the folders are known: what a folder may take (`uploadFolder`) is read from them, and + // a guess would send the file to Uploads instead of the folder on screen. + const canUpload = shown.kind !== "shared" && (shown.kind !== "folder" || !!foldersQ.data); const uploadTarget = uploadFolder(shown, folders); const uploadTargetName = uploadTarget !== null && open ? folderLabel(open, t) : t("files.source.upload"); @@ -252,8 +296,6 @@ export default function Files() { const viewKey = viewParam(shown); const layout: FilesLayout = layouts[viewKey] ?? "list"; const isGrid = layout === "grid"; - // One sort for both paints: the table's headers and the grid's dropdown move the same state. - const [sort, setSort] = usePref("filesSort", null, { localKey: LS.filesSort }); // ── Arrival at one file ───────────────────────────────────────────────────────────────────────── // A pending arrival (`focus`) is looked for once the list is in: found here, it becomes `arrived` @@ -267,8 +309,12 @@ export default function Files() { useEffect(() => { if (lookEverywhere) setView(ALL_FILES); else if (here) consumeFocus(viewKey); - else if (missing) consumeFocus(null); - }, [here, missing, lookEverywhere, viewKey, setView, consumeFocus]); + else if (missing) { + consumeFocus(null); + // Said, not swallowed: an old notice's file may have expired or been deleted since. + notify({ level: "info", message: t("files.arrival.gone") }); + } + }, [here, missing, lookEverywhere, viewKey, setView, consumeFocus, t]); const arrival = arrived && arrived.view === viewKey ? arrived : null; const ringed = arrival?.id ?? null; const reveal = arrival && { key: String(arrival.id), seq: arrival.token }; @@ -285,13 +331,19 @@ export default function Files() { // ── A file dropped anywhere on the page ───────────────────────────────────────────────────────── // The page is the target, not a box on it: a short folder leaves most of the screen empty. Window // listeners, since the drag starts outside the app. While the dialog is open its own zone takes the - // drop; a drag that carries no files (a link, selected text) is not ours. + // drop; a drag that carries no files (a link, selected text) is not ours. A file dropped where the + // page takes none (beside the open dialog, on the shared list) is still kept from the browser, + // which would open it in the tab — unloading the app and any upload under way. const [dragging, setDragging] = useState(false); const dragDepth = useRef(0); const dropReady = canUpload && upload === null; const onDrag = useEffectEvent((e: DragEvent) => { - if (!dropReady || !e.dataTransfer?.types.includes("Files")) return; + if (!e.dataTransfer?.types.includes("Files")) return; e.preventDefault(); + if (!dropReady) { + e.dataTransfer.dropEffect = "none"; + return; + } if (e.type === "dragenter") dragDepth.current += 1; if (e.type === "dragleave") dragDepth.current = Math.max(0, dragDepth.current - 1); if (e.type === "drop") { @@ -480,6 +532,8 @@ export default function Files() { }, ]; + // Each folder and each search is a list of its own: it opens on its first page. + const pageScope = `${viewKey}|${q}`; // The grid sorts and pages here; the table does both itself (from the same sort). const cards = isGrid ? sortRows(rows, columns, sort, lang) : rows; const { paged, pagerProps } = useCardPager( @@ -488,6 +542,7 @@ export default function Files() { isGrid, reveal && { seq: reveal.seq, match: (f) => String(f.id) === reveal.key }, t("files.perPage"), + `${pageScope}|${JSON.stringify(sort)}`, ); const emptyText = q @@ -526,11 +581,11 @@ export default function Files() { ); const body = - filesQ.isError && !filesQ.data ? ( - filesQ.refetch()}> + listQ.isError && !listQ.data ? ( + listQ.refetch()}> {t("common.loadError")} - ) : filesQ.isLoading ? ( + ) : listQ.isLoading ? ( ) : isGrid ? ( grid @@ -547,6 +602,7 @@ export default function Files() { onSortChange={setSort} reveal={reveal} narrowedBy={q} + pageScope={pageScope} rowProps={(f) => ({ ...rowMenu.triggerProps(f), "data-file-id": f.id })} // `group`: the row's quick icons show while it is hovered or holds the focus. rowClassName={(f) => @@ -578,7 +634,13 @@ export default function Files() { }} /> ) : ( -

{title}

+

+ {title} +

)} {open && !renamingFolder && ( )} - {filesQ.data && ( + {listQ.data && ( - {t("files.count", { count: rows.length })} + {/* A capped list is the newest files of the view, and says so. */} + {t(capped && !wide ? "files.countNewest" : "files.count", { count: rows.length })} )} {isGrid && ( diff --git a/frontend/src/components/FilesRail.dom.test.tsx b/frontend/src/components/FilesRail.dom.test.tsx index bd6bf770..dfbfd493 100644 --- a/frontend/src/components/FilesRail.dom.test.tsx +++ b/frontend/src/components/FilesRail.dom.test.tsx @@ -155,3 +155,31 @@ describe("FilesRail · folders", () => { await waitFor(() => expect(document.activeElement).toBe(entry("Receptek"))); }); }); + +describe("FilesRail · what it says when a part is empty or missing", () => { + it("says nothing was downloaded only when nothing is there — not when it all moved into own folders", async () => { + await mount([folder(1, { source_key: "youtube", count: 0 }), { ...OWN, count: 3 }]); + expect(screen.queryByText("files.rail.noSources")).toBeNull(); + cleanup(); + await mount([folder(1, { source_key: "youtube", count: 0 }), { ...OWN, count: 0 }]); + expect(screen.getByText("files.rail.noSources")).toBeTruthy(); + }); + + it("a storage figure that failed to load says so, rather than the meter quietly not being there", async () => { + vi.spyOn(api, "fileFolders").mockResolvedValue([YT, OWN]); + vi.spyOn(api, "filesUsage").mockRejectedValue(new Error("down")); + render( + + + , + ); + expect(await screen.findByText("files.usage.failed")).toBeTruthy(); + }); +}); diff --git a/frontend/src/components/FilesRail.tsx b/frontend/src/components/FilesRail.tsx index 1242fc4d..faa16032 100644 --- a/frontend/src/components/FilesRail.tsx +++ b/frontend/src/components/FilesRail.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from "react"; +import { Meter } from "./ui/Meter"; import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import clsx from "clsx"; @@ -12,7 +13,7 @@ import { useLiveQuery } from "../lib/useLiveQuery"; import FilterRail from "./FilterRail"; import { folderIcon, folderLabel } from "./fileFolders"; import { sameView, useFiles, useFilesActions, type FilesView } from "./filesContext"; -import { RenameField } from "./RenameField"; +import { RenameField } from "./ui/RenameField"; import { useFolderActions } from "./useFolderActions"; import { ContextMenu } from "./ui/ContextMenu"; import { Pressable } from "./ui/Pressable"; @@ -77,9 +78,13 @@ function StorageMeter() { intervalMs: (u) => (u?.running_jobs ? 2000 : 15000), }); const u = usage.data; - if (!u) return null; - const pct = - u.unlimited || !u.max_bytes ? 0 : Math.min(100, (u.footprint_bytes / u.max_bytes) * 100); + // A failed read says so where the figures would be, rather than the meter quietly not being there. + if (!u) + return usage.isError ? ( +

+ {t("files.usage.failed")} +

+ ) : null; return (
@@ -90,12 +95,7 @@ function StorageMeter() { {!u.unlimited && ( - - 90 ? "bg-danger" : "bg-accent")} - style={{ width: `${pct}%` }} - /> - + )}
); @@ -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 `