diff --git a/CLAUDE.md b/CLAUDE.md index ae8fa045..347e1f3a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ be developed that way. This file is the rules of the road: read it before you to loaded into every agent session, so keep it **short and true**. A rule here that has gone stale is worse than no rule — if you change how the code works, change this file in the same commit. -Siftlode is a self-hosted YouTube reader (+ a local Plex browser/player and a download center). It is a +Siftlode is a self-hosted YouTube reader (+ a local Plex browser/player and a files library with downloads). It is a **modular monolith**: one Postgres, one FastAPI process (uvicorn + APScheduler), one worker process (yt-dlp/ffmpeg), one PO-token sidecar. React 19 (with the React Compiler) + react-query frontend, with a router-free navigation provider. diff --git a/README.md b/README.md index 80ab33b7..b5424876 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,11 @@ blocker and SponsorBlock keep working. tags to slice the feed by. - **Playlists with two-way YouTube sync** — build them locally, keep them in sync in both directions. - **In-app player** with resume, plus keyboard/scroll controls. -- **Download Center** — save videos to the server with yt-dlp in a Plex-friendly layout (format - presets, per-user storage quota), trim / crop / split & join them in a built-in editor, play them +- **Files** — one library for everything you keep: paste a link anywhere in the app to download it + with yt-dlp (format presets, per-user storage quota, the running downloads in a tray), upload your + own files, sort them into folders, trim / crop / split & join videos in a built-in editor, play them where you left off, then save to your device, share with another user, or hand out a public watch - link. + link that plays on any phone. - **Plex library** *(optional)* — point Siftlode at your own Plex server to browse and play its movies and shows alongside your feed, with watch state kept in sync. - **Multi-user** with per-user private state, a shared catalog, and a fair daily API-quota guard. diff --git a/backend/app/routes/downloads.py b/backend/app/routes/downloads.py index 053e83c1..45957b36 100644 --- a/backend/app/routes/downloads.py +++ b/backend/app/routes/downloads.py @@ -842,6 +842,7 @@ def admin_list_downloads( out = [] for j in jobs: data = serialize(j, assets.get(j.asset_id) if j.asset_id is not None else None) + data["user_id"] = j.user_id data["user_email"] = emails.get(j.user_id) out.append(data) return out @@ -860,19 +861,35 @@ def admin_storage(admin: User = Depends(admin_user), db: Session = Depends(get_d .where(DownloadJob.asset_id.is_not(None)) .group_by(DownloadJob.user_id) ).all() - emails = { - u.id: u.email - for u in db.execute(select(User).where(User.id.in_([uid for uid, _ in per_user]))).scalars() + uids = [uid for uid, _ in per_user] + emails = {u.id: u.email for u in db.execute(select(User).where(User.id.in_(uids))).scalars()} + # What each one's Files lists: their finished files. + files: dict[int, int] = { + uid: n + for uid, n in db.execute( + select(DownloadJob.user_id, func.count()) + .where(DownloadJob.user_id.in_(uids), DownloadJob.status == JobStatus.DONE) + .group_by(DownloadJob.user_id) + ).tuples() } + + def row(uid: int) -> dict: + lim = quota.resolve(db, uid) + return { + "user_id": uid, + "email": emails.get(uid), + "footprint_bytes": quota.footprint(db, uid), + "files": files.get(uid, 0), + "max_bytes": lim.max_bytes, + "unlimited": lim.unlimited, + } + return { "files_on_disk": on_disk[0], "total_bytes": int(on_disk[1]), "total_assets": total_assets, "total_cap_bytes": sysconfig.get_int(db, "download_total_max_bytes"), - "per_user": [ - {"user_id": uid, "email": emails.get(uid), "footprint_bytes": quota.footprint(db, uid)} - for uid, _ in per_user - ], + "per_user": [row(uid) for uid in uids], } diff --git a/backend/tests/test_upload_conversion.py b/backend/tests/test_upload_conversion.py index 969c33c0..b3d7bc48 100644 --- a/backend/tests/test_upload_conversion.py +++ b/backend/tests/test_upload_conversion.py @@ -598,6 +598,29 @@ def test_the_admin_storage_view_counts_a_waiting_upload(db, as_user, make_user, assert {p["user_id"]: p["footprint_bytes"] for p in r["per_user"]} == {u.id: 10} +def test_the_admin_storage_view_names_each_users_quota_and_files( + db, as_user, make_user, _root, monkeypatch +): + # A row says how full the user is against THEIR limit (a custom one, here), and how many files + # their Files lists — the finished ones, not one still waiting for its conversion. + _probe(monkeypatch) + u = make_user() + done, _ = _ingest(db, _root, u.id, size=10) + waiting, _ = _ingest(db, _root, u.id, name="b.mov", size=5) + done.status, waiting.status = "done", "queued" + db.commit() + admin = as_user(make_user(role="admin")) + put = admin.put(f"/api/admin/downloads/quota/{u.id}", json={"max_bytes": 1000}) + assert put.status_code == 200 + row = admin.get("/api/admin/downloads/storage").json()["per_user"] + assert [(p["user_id"], p["files"], p["max_bytes"], p["unlimited"]) for p in row] == [ + (u.id, 1, 1000, False) + ] + # Every download names its owner, so its row can open THEIR quota. + jobs = admin.get("/api/admin/downloads").json() + assert {(j["id"], j["user_id"]) for j in jobs} == {(done.id, u.id), (waiting.id, u.id)} + + @pytest.mark.parametrize( ("job_status", "asset_status", "ref_count"), [ diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 1cb68b4d..c253daa3 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -8,7 +8,7 @@ configured in a web wizard on first start. There's no editing of config files by - A machine with **Docker** and the **Docker Compose plugin** (Docker Desktop on Windows/macOS, or Docker Engine on Linux). -- A few hundred MB of disk and ~1 GB RAM free for the app itself. The optional Download Center +- A few hundred MB of disk and ~1 GB RAM free for the app itself. The Files library stores media too — budget disk for whatever you download (it's bounded by per-user quotas you set as admin). - Optional: a domain name + reverse proxy if you want HTTPS / public access (see below). @@ -97,13 +97,13 @@ Only that peer is trusted, so a request sent straight to port `8080` (bypassing app is exposed directly with no proxy. Make sure your proxy actually sets `X-Forwarded-For` (Caddy's `reverse_proxy` and Nginx's `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for` both do). -## Download Center (media storage) +## Files (media storage) -The stack includes a **Download Center**: an admin-enabled feature that saves videos to the server +The stack includes **Files**: one library that saves videos to the server with yt-dlp (Plex-friendly folders + `.nfo`/poster), lets users trim/crop/join clips, and shares them. It runs two extra containers that come up automatically — a `worker` (the download/edit job loop) and a small `bgutil-pot` sidecar (mints YouTube tokens so downloads aren't bot-blocked). No -configuration is required; per-user storage quotas are set on the admin **Downloads → System** page. +configuration is required; per-user storage quotas are set on the admin **Users → Storage** tab. By default the media lives in a Docker-managed volume (`siftlode_files`). To keep it somewhere you can reach from other apps — e.g. a folder your **Plex** server indexes — point it at a host @@ -164,7 +164,7 @@ then cannot read its own secrets. token and every stored refresh token are encrypted with it *inside* the database. Restore a dump against a different key and those values are lost — you would have to clear the affected rows from `system_config` and re-enter every secret by hand. Keep `.env` beside the dump, and just as private. -3. **Downloaded media** *(only if you use the Download Center)* — the `siftlode_files` volume, or +3. **Downloaded media** *(only if you use Files)* — the `siftlode_files` volume, or whatever `FILES_HOST_PATH` points at. Not needed to bring the instance back; the jobs and their metadata are in the database, the files themselves are not. diff --git a/frontend/public/welcome/downloads.webp b/frontend/public/welcome/downloads.webp deleted file mode 100644 index 120ffdc7..00000000 Binary files a/frontend/public/welcome/downloads.webp and /dev/null differ diff --git a/frontend/public/welcome/files.webp b/frontend/public/welcome/files.webp new file mode 100644 index 00000000..9f6a22db Binary files /dev/null and b/frontend/public/welcome/files.webp differ diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index 5f8d3689..6e38265d 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -52,8 +52,10 @@ import { MoreButton } from "./ui/MoreButton"; import type { ActionMenuItem } from "./ui/ActionMenu"; import { Input } from "./ui/Input"; import { SURFACE } from "./ui/surfaces"; +import { StorageTab } from "./downloadAdmin"; -// Admin-only user & access management: roles and access requests (the Invite whitelist). Each +// Admin-only user & access management: roles, access requests (the Invite whitelist), and the +// storage every user's downloads take (with their quotas). Each // section is its own tab (persisted across reloads); the Access tab carries a badge with the count // of pending requests so it's visible without switching to it. // (The tab key + a "focus the Access tab" helper live in lib/adminUsersTab so callers can @@ -81,6 +83,8 @@ export default function AdminUsers() { label: t("users.tabs.access"), badge: me.pending_invites + me.pending_activations, }, + // What every user's downloads take on the disk, against their own limit (the quota editor). + { id: "storage", label: t("users.tabs.storage") }, ]; const active = tabs.some((x) => x.id === tab) ? tab : "roles"; return ( @@ -99,6 +103,7 @@ export default function AdminUsers() { )} + {active === "storage" && } ); diff --git a/frontend/src/components/BootGate.dom.test.tsx b/frontend/src/components/BootGate.dom.test.tsx index e436e12c..e281c054 100644 --- a/frontend/src/components/BootGate.dom.test.tsx +++ b/frontend/src/components/BootGate.dom.test.tsx @@ -70,13 +70,13 @@ afterEach(() => { describe("BootGate", () => { it("a new tab with Startup off opens on the page the account left — never on Home first", async () => { localStorage.setItem("siftlode.startOnHome.5", "0"); - localStorage.setItem("siftlode.page.5", "downloads"); + localStorage.setItem("siftlode.page.5", "plex"); vi.spyOn(api, "me").mockResolvedValue(account()); mount(); - expect((await screen.findByTestId("app")).textContent).toBe("downloads"); - expect(renders.map((r) => r.page)).toEqual(renders.map(() => "downloads")); - expect(new URLSearchParams(window.location.search).get("page")).toBe("downloads"); + expect((await screen.findByTestId("app")).textContent).toBe("plex"); + expect(renders.map((r) => r.page)).toEqual(renders.map(() => "plex")); + expect(new URLSearchParams(window.location.search).get("page")).toBe("plex"); expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5"); // the tab is pinned }); @@ -176,12 +176,12 @@ describe("BootGate", () => { // Pinned to 9, but 9 was signed out in another tab: the server answers as the browser's default, 5. sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "9"); localStorage.setItem("siftlode.startOnHome.5", "0"); - localStorage.setItem("siftlode.page.5", "downloads"); + localStorage.setItem("siftlode.page.5", "plex"); vi.spyOn(api, "me").mockResolvedValue(account()); mount(); - expect((await screen.findByTestId("app")).textContent).toBe("downloads"); - expect(renders.map((r) => r.page)).toEqual(renders.map(() => "downloads")); + expect((await screen.findByTestId("app")).textContent).toBe("plex"); + expect(renders.map((r) => r.page)).toEqual(renders.map(() => "plex")); expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5"); }); diff --git a/frontend/src/components/ClipPanel.dom.test.tsx b/frontend/src/components/ClipPanel.dom.test.tsx index 4f2a6ba0..0665b4cb 100644 --- a/frontend/src/components/ClipPanel.dom.test.tsx +++ b/frontend/src/components/ClipPanel.dom.test.tsx @@ -25,6 +25,8 @@ vi.mock("../lib/notifications", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, notify: (n: unknown) => notify(n) }; }); +const showInTray = vi.fn(); +vi.mock("../lib/downloadQueue", () => ({ showInTray: (id: number) => showInTray(id) })); const notifyActionError = vi.fn(); vi.mock("../lib/actionErrors", async (importOriginal) => { const actual = await importOriginal(); @@ -188,6 +190,11 @@ describe("ClipPanel", () => { expect(notify).toHaveBeenCalledWith( expect.objectContaining({ level: "success", message: "clip.queued" }), ); + // Its View opens the tray on the new clip. + ( + notify.mock.calls[notify.mock.calls.length - 1]![0] as { action: { onClick: () => void } } + ).action.onClick(); + expect(showInTray).toHaveBeenCalledWith(1); }); it("sends no audio track for a YouTube clip", async () => { diff --git a/frontend/src/components/ClipPanel.tsx b/frontend/src/components/ClipPanel.tsx index 6dad8438..f92194be 100644 --- a/frontend/src/components/ClipPanel.tsx +++ b/frontend/src/components/ClipPanel.tsx @@ -7,7 +7,7 @@ import { DISPLAY_NAME_MAX } from "../lib/api/downloads"; import { invalidateDownloads } from "../lib/downloads"; import { notify } from "../lib/notifications"; import { notifyActionError } from "../lib/actionErrors"; -import { navigateTo } from "../lib/nav"; +import { showInTray } from "../lib/downloadQueue"; import { log } from "../lib/log"; import { formatTimecode } from "../lib/timecode"; import { handleBounds, markIn, markOut, zoomWindow, type ClipRange } from "../lib/clipRange"; @@ -192,13 +192,13 @@ export default function ClipPanel({ if (busy) return; setBusy(true); try { - await api.enqueueClip(body); + const job = await api.enqueueClip(body); invalidateDownloads(qc); log.info("clip.queued", "clip queued", { source: src.source, seconds: Math.round(length) }); notify({ level: "success", message: t("clip.queued"), - action: { label: t("clip.view"), onClick: () => navigateTo("downloads") }, + action: { label: t("clip.view"), onClick: () => showInTray(job.id) }, }); onClose(); } catch (err) { diff --git a/frontend/src/components/DownloadCenter.confirm.dom.test.tsx b/frontend/src/components/DownloadCenter.confirm.dom.test.tsx deleted file mode 100644 index d216f11f..00000000 --- a/frontend/src/components/DownloadCenter.confirm.dom.test.tsx +++ /dev/null @@ -1,157 +0,0 @@ -// @vitest-environment jsdom -// -// The Download Center's confirm dialogs name their ACTION on the confirm button: the shared -// `confirmThen` once labelled it with the dialog's question — the cancel dialog's own "Cancel" beside -// "Cancel". A finished file's confirms are the Files page's (Files.menu.dom.test.tsx). i18n is -// key-passthrough, so labels read as keys. -import { afterEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import type { DownloadJob } from "../lib/api"; - -const mocks = vi.hoisted(() => ({ - jobs: [] as unknown[], - confirm: vi.fn( - async (_opts: { title: string; message?: string; confirmLabel?: string }) => false, - ), -})); - -vi.mock("react-i18next", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), - }; -}); -vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm })); -vi.mock("../lib/useLiveQuery", () => ({ - useLiveQuery: (key: readonly unknown[]) => ({ - data: key[0] === "downloads" ? mocks.jobs : undefined, - isError: false, - isLoading: false, - refetch: vi.fn(), - }), -})); -vi.mock("../lib/useMe", () => ({ useMe: () => ({ role: "user" }) })); -// The page toolbar (title + tabs) renders into the app shell's slot, which a bare render has none of. -vi.mock("./ui/PageShell", async (importOriginal) => ({ - ...(await importOriginal()), - PageToolbar: ({ children }: { children: React.ReactNode }) => <>{children}, -})); - -import DownloadCenter from "./DownloadCenter"; - -const job = (id: number, status: DownloadJob["status"]): DownloadJob => ({ - id, - status, - progress: 0, - speed_bps: null, - eta_s: null, - phase: null, - error: null, - error_code: null, - error_detail: null, - queue_pos: 0, - created_at: null, - source_kind: "youtube", - source_ref: `v${id}`, - source_url: null, - poster_url: null, - profile_id: null, - spec: { - mode: "av", - max_height: null, - container: null, - audio_format: null, - vcodec: null, - embed_subs: false, - embed_chapters: false, - embed_thumbnail: false, - sponsorblock: false, - }, - display_name: `Clip ${id}`, - display_uploader: null, - display_uploader_url: null, - extra_links: [], - can_download: status === "done", - folder_id: null, - kept: false, - expires_at: null, - asset: null, -}); - -afterEach(() => { - cleanup(); - mocks.confirm.mockClear(); -}); - -/** Pick `label` from the row's menu, opened by its "…". The surface is visibility:hidden in jsdom - * (floating-ui never positions it), so the row is matched on its text. */ -async function pick(row: string, label: string) { - const more = screen - .getByText(row) - .closest("[data-nav-index]")! - .querySelector('button[aria-label="downloads.rowMenu"]')!; - fireEvent.click(more); - const item = await waitFor(() => { - const found = screen - .getAllByRole("menuitem", { hidden: true }) - .find((b) => b.textContent === label); - expect(found).toBeTruthy(); - return found!; - }); - fireEvent.click(item); -} - -describe("DownloadCenter confirm dialogs", () => { - it("label the confirm button with the action, not the dialog's question", async () => { - mocks.jobs = [job(1, "running")]; - render( - - - , - ); - - // The destructive and rare row buttons live in the row's "…". - await pick("Clip 1", "downloads.actions.cancel"); - await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1)); - - expect(mocks.confirm.mock.calls.map(([o]) => [o.title, o.confirmLabel])).toEqual([ - ["downloads.confirm.cancelTitle", "downloads.confirm.cancelAction"], - ]); - }); - - it("cancelling an upload says the uploaded file goes with it", async () => { - // Nothing to fetch again: the cancel deletes the original from the server. - mocks.jobs = [{ ...job(3, "queued"), job_kind: "upload" }]; - render( - - - , - ); - await pick("Clip 3", "downloads.actions.cancel"); - await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1)); - const [opts] = mocks.confirm.mock.calls[0]!; - expect([opts.title, opts.confirmLabel]).toEqual([ - "downloads.confirm.cancelUploadTitle", - "downloads.confirm.cancelUploadAction", - ]); - }); - - it("cancelling a clip speaks of the clip", async () => { - mocks.jobs = [{ ...job(4, "running"), job_kind: "edit" }]; - render( - - - , - ); - await pick("Clip 4", "downloads.actions.cancel"); - await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1)); - const [opts] = mocks.confirm.mock.calls[0]!; - expect([opts.title, opts.message, opts.confirmLabel]).toEqual([ - "downloads.confirm.cancelEditTitle", - "downloads.confirm.cancelEditBody", - "downloads.confirm.cancelEditAction", - ]); - }); -}); diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx deleted file mode 100644 index de372660..00000000 --- a/frontend/src/components/DownloadCenter.tsx +++ /dev/null @@ -1,199 +0,0 @@ -import { lazy, Suspense, useState } from "react"; -import { qk } from "../lib/queryKeys"; -import { useTranslation } from "react-i18next"; -import { Pause, Play, RefreshCw, Settings2, UploadCloud } from "lucide-react"; -import Tabs from "./ui/Tabs"; -import { useTabPanel } from "./ui/tabPanel"; -import { PageToolbar } from "./ui/PageShell"; -import { LoadingState, StateMessage } from "./ui/QueryState"; -// Lazy: these dialogs open on demand, so they stay out of the Downloads page chunk until used. -const UploadDialog = lazy(() => import("./UploadDialog")); -const ProfileEditor = lazy(() => import("./ProfileEditor")); -import { useLiveQuery } from "../lib/useLiveQuery"; -import { useMe } from "../lib/useMe"; -import { type DownloadJob } from "../lib/api"; -import { fetchQueue } from "../lib/downloadQueue"; -import { Button } from "./ui/Button"; -import { DownloadRow, IconBtn } from "./downloadRow"; -import { ErrorModal } from "./downloadModals"; -import { AdminSystem } from "./downloadAdmin"; -import NavGrid from "./ui/NavGrid"; -import { useContextMenu } from "./ui/useContextMenu"; -import { ContextMenu } from "./ui/ContextMenu"; -import { MoreButton } from "./ui/MoreButton"; -import { useDownloadActions } from "./useDownloadActions"; -import { LinkBox } from "./LinkBox"; - -const jobKey = (job: DownloadJob) => job.id; - -// The download queue: what is downloading, waiting or failed, the link box and the upload, the -// download profiles, and (admin) the instance's downloads. A finished download is a library file and -// lives in Files. -export default function DownloadCenter() { - const me = useMe(); - const { t } = useTranslation(); - // Always land on the queue tab (not a persisted last-tab) — the queue is where new work and - // any failures are, so it's the useful default every visit. - const [tab, setTab] = useState("queue"); - const panel = useTabPanel(); - const [upload, setUpload] = useState(false); - const [manage, setManage] = useState(false); - const [errorJob, setErrorJob] = useState(null); - - // Poll fast (2s) while work is actually in flight; back off to 15s once the queue is settled - // (only done/error/paused rows left, which don't advance on their own) so an idle Download Center - // isn't hitting the API — and adding to the shared box's load — every 2s for nothing. - const jobsQ = useLiveQuery(qk.downloads(), fetchQueue, { - intervalMs: (data) => - data?.some((j) => j.status === "queued" || j.status === "running") ? 2000 : 15000, - }); - const jobs = jobsQ.data ?? []; - const queue = jobs.filter((j) => ["queued", "running", "paused", "error"].includes(j.status)); - - // --- The rows' ONE menu ------------------------------------------------------------- - // Keyed by the job's id: the queue polls every 2 s while a job runs, replacing the object under an - // open menu, and its "…" must still close it. - const rowMenu = useContextMenu({ keyOf: jobKey }); - const actions = useDownloadActions(); - const menuItems = rowMenu.target && tab === "queue" ? actions.items(rowMenu.target) : []; - // The row's menu triggers and its "…". - const rowMenuProps = (job: DownloadJob) => { - const mine = rowMenu.open && rowMenu.target?.id === job.id; - return { - rowProps: rowMenu.triggerProps(job), - more: ( - - ), - }; - }; - - const tabs = [ - { id: "queue", label: t("downloads.tabs.queue"), badge: queue.length }, - ...(me.role === "admin" ? [{ id: "system", label: t("downloads.tabs.system") }] : []), - ]; - // Guard against the active tab going away under us (e.g. admin on "system" gets demoted mid-session) - // — that would render a blank page with no active tab. Fall back to the default. - // Adjusted during render rather than from an effect: as an effect the blank page it exists to - // prevent was committed and painted first. `setTab` is this component's own state (persisted per - // account), so a render-phase write is allowed, and it converges — "queue" is always in the list. - if (!tabs.some((tb) => tb.id === tab)) setTab("queue"); - - return ( - <> - {/* The tabs are the page's band (B4); the tab content scrolls under them. No visible title: the - nav rail and the tab title name the page, the shell's hidden h1 names it to a screen reader. */} - -
- - -
-
- -
- {tab === "queue" && ( - <> - {/* The field takes the whole line when the buttons would squeeze it (at 375 it had 57px). */} -
- - {/* Local upload is a peer to "add a URL": a general file → the same library. */} - -
- - {queue.map((job, i) => ( - - {/* The queue's primary action stays in sight; cancel moved into the "…". */} - {job.status === "queued" || job.status === "running" ? ( - actions.run(job, "pause")} - title={t("downloads.actions.pause")} - > - - - ) : ( - actions.run(job, "resume")} - title={t( - job.status === "error" - ? "downloads.actions.retry" - : "downloads.actions.resume", - )} - > - {/* Retry is "do it again", not playback: the Play glyph promised a video. */} - {job.status === "error" ? ( - - ) : ( - - )} - - )} - - ))} - {queue.length === 0 && - (jobsQ.isError && !jobsQ.data ? ( - jobsQ.refetch()}> - {t("common.loadError")} - - ) : jobsQ.isLoading ? ( - - ) : ( -
- {t("downloads.empty.queue")} -
- ))} -
- - )} - - {tab === "system" && me.role === "admin" && } - - - - - {upload && setUpload(false)} />} - {manage && setManage(false)} />} - {errorJob && ( - actions.run(errorJob, "resume")} - onClose={() => setErrorJob(null)} - /> - )} - -
- - ); -} diff --git a/frontend/src/components/DownloadDialog.dom.test.tsx b/frontend/src/components/DownloadDialog.dom.test.tsx new file mode 100644 index 00000000..119e2100 --- /dev/null +++ b/frontend/src/components/DownloadDialog.dom.test.tsx @@ -0,0 +1,101 @@ +// @vitest-environment jsdom +// +// The download dialog opens on the format used last (per account), so a pasted link is one Enter +// away; a format deleted since falls back to the first. Its "added" notice's View opens the tray on +// the new job. The prefs are a map that outlives a mount — a reload, as far as the dialog can tell. +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useState } from "react"; +import { api, type DownloadProfile } from "../lib/api"; + +const h = vi.hoisted(() => ({ + prefs: new Map(), + notify: vi.fn(), + showInTray: vi.fn(), +})); + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), +})); +vi.mock("../lib/usePref", () => ({ + usePref: (key: string, initial: T) => { + const [value, setValue] = useState(() => + h.prefs.has(key) ? (h.prefs.get(key) as T) : initial, + ); + return [ + value, + (next: T) => { + h.prefs.set(key, next); + setValue(next); + }, + ]; + }, +})); +vi.mock("../lib/notifications", async (importOriginal) => ({ + ...(await importOriginal()), + notify: h.notify, +})); +vi.mock("../lib/downloadQueue", () => ({ showInTray: h.showInTray })); + +import DownloadDialog from "./DownloadDialog"; + +const profile = (id: number, name: string) => + ({ id, name, is_builtin: false, spec: { mode: "av" } }) as unknown as DownloadProfile; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + h.prefs.clear(); + h.notify.mockClear(); + h.showInTray.mockClear(); +}); + +function open() { + vi.spyOn(api, "downloadProfiles").mockResolvedValue([profile(1, "Best"), profile(2, "Phone")]); + const enqueue = vi.spyOn(api, "enqueueDownload").mockResolvedValue({ id: 77 } as never); + render( + + {}} /> + , + ); + return enqueue; +} + +const picked = () => + screen.getByRole("button", { name: /^downloads\.dialog\.profile: / }).getAttribute("aria-label"); + +describe("DownloadDialog", () => { + it("opens on the format used last; a deleted one falls back to the first", async () => { + const enqueue = open(); + await waitFor(() => expect(picked()).toBe("downloads.dialog.profile: Best")); + fireEvent.click(screen.getByRole("button", { name: /^downloads\.dialog\.profile: / })); + const phone = (await screen.findAllByRole("menuitemradio", { hidden: true })).find( + (o) => o.textContent === "Phone", + )!; + fireEvent.click(phone); + fireEvent.click(screen.getByText("downloads.dialog.submit")); + await waitFor(() => + expect(enqueue).toHaveBeenCalledWith(expect.objectContaining({ profile_id: 2 })), + ); + cleanup(); + + open(); + await waitFor(() => expect(picked()).toBe("downloads.dialog.profile: Phone")); + cleanup(); + + h.prefs.set("downloadProfile", 99); + open(); + await waitFor(() => expect(picked()).toBe("downloads.dialog.profile: Best")); + }); + + it("its notice's View opens the tray on the new job", async () => { + open(); + await waitFor(() => expect(picked()).toBe("downloads.dialog.profile: Best")); + fireEvent.click(screen.getByText("downloads.dialog.submit")); + await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1)); + (h.notify.mock.calls[0]![0] as { action: { onClick: () => void } }).action.onClick(); + expect(h.showInTray).toHaveBeenCalledWith(77); + }); +}); diff --git a/frontend/src/components/DownloadDialog.tsx b/frontend/src/components/DownloadDialog.tsx index bf09fd3e..4b7e7d2b 100644 --- a/frontend/src/components/DownloadDialog.tsx +++ b/frontend/src/components/DownloadDialog.tsx @@ -11,13 +11,18 @@ import { invalidateDownloads } from "../lib/downloads"; const ProfileEditor = lazy(() => import("./ProfileEditor")); import { notify } from "../lib/notifications"; import { notifyActionError } from "../lib/actionErrors"; -import { navigateTo } from "../lib/nav"; +import { showInTray } from "../lib/downloadQueue"; import { profileName } from "../lib/vocab"; +import { LS } from "../lib/storage"; +import { usePref } from "../lib/usePref"; import FilterSelect from "./ui/FilterSelect"; import { Input } from "./ui/Input"; -// Preset picker shown when the user hits Download on a card / in the player, or adds a URL from -// the Download Center. Enqueues via the API and points the user at the Download Center. +// Preset picker shown when the user hits Download on a card / in the player, or brings a link (the +// Files link box, a paste anywhere). Enqueues via the API; its notice's "View" opens the tray on it. +// It opens on the format used last (per account): a pasted link is then one Enter away. +const parseProfile = (v: unknown): number | null => (typeof v === "number" ? v : null); + export default function DownloadDialog({ source, title, @@ -36,23 +41,30 @@ export default function DownloadDialog({ const [manage, setManage] = useState(false); const profiles = profilesQ.data ?? []; - const chosen = profileId ?? profiles[0]?.id ?? null; + const [lastProfile, setLastProfile] = usePref("downloadProfile", null, { + localKey: LS.downloadProfile, + parse: parseProfile, + }); + // The one used last, while it still exists (a deleted format falls back to the first). + const remembered = profiles.some((p) => p.id === lastProfile) ? lastProfile : null; + const chosen = profileId ?? remembered ?? profiles[0]?.id ?? null; const submit = async () => { if (!chosen || busy) return; setBusy(true); const displayName = name.trim() || undefined; try { - await api.enqueueDownload({ + const job = await api.enqueueDownload({ source, profile_id: chosen, display_name: displayName, }); invalidateDownloads(qc); + setLastProfile(chosen); notify({ level: "success", message: t("downloads.dialog.added"), - action: { label: t("downloads.dialog.view"), onClick: () => navigateTo("downloads") }, + action: { label: t("downloads.dialog.view"), onClick: () => showInTray(job.id) }, }); onClose(); } catch (e) { diff --git a/frontend/src/components/DownloadTray.dom.test.tsx b/frontend/src/components/DownloadTray.dom.test.tsx index fe62ddb2..334fb7b5 100644 --- a/frontend/src/components/DownloadTray.dom.test.tsx +++ b/frontend/src/components/DownloadTray.dom.test.tsx @@ -12,6 +12,7 @@ import { api, type QueueJob } from "../lib/api"; const h = vi.hoisted(() => ({ jobs: [] as unknown[], + updatedAt: 0, openFile: vi.fn(), share: vi.fn(async () => "https://x/watch/t"), notify: vi.fn(), @@ -27,7 +28,13 @@ vi.mock("react-i18next", async (importOriginal) => ({ }), })); vi.mock("../lib/useLiveQuery", () => ({ - useLiveQuery: () => ({ data: h.jobs, isError: false, isLoading: false, refetch: vi.fn() }), + useLiveQuery: () => ({ + 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). @@ -57,11 +64,13 @@ vi.mock("../lib/notifications", async (importOriginal) => ({ })); import DownloadTray from "./DownloadTray"; -import { noteQueue, resetQueueWatch } from "../lib/downloadQueue"; +import { noteQueue, resetQueueWatch, showInTray } from "../lib/downloadQueue"; +import { pushLayer, removeLayer } from "../lib/layerStack"; beforeEach(() => { resetQueueWatch(); h.prefs.clear(); + h.updatedAt = 0; globalThis.ResizeObserver ??= class { observe() {} unobserve() {} @@ -265,4 +274,62 @@ describe("DownloadTray", () => { fireEvent.click(screen.getByRole("button", { name: "tray.close" })); expect(document.activeElement?.id).toBe("content"); }); + + describe("View on a job just started", () => { + it("brings a closed tray back, on the job's row", async () => { + poll([job(3, "error")]); + const view = mount(); + fireEvent.click(screen.getByRole("button", { name: "tray.close" })); + expect(tray()).toBeNull(); + act(() => showInTray(3)); + view.rerender(); + expect(tray()).not.toBeNull(); + await waitFor(() => expect(document.activeElement).toBe(rowOf("Clip 3"))); + }); + + it("unfolds a folded tray onto the row", async () => { + poll([job(1, "running"), job(2, "queued")]); + mount(); + fireEvent.click(screen.getByRole("button", { name: "tray.fold" })); + act(() => showInTray(2)); + await waitFor(() => expect(document.activeElement).toBe(rowOf("Clip 2"))); + }); + + it("asked from inside a player, it opens under it but leaves the keyboard there", async () => { + poll([job(1, "running"), job(2, "queued")]); + mount(); + fireEvent.click(screen.getByRole("button", { name: "tray.fold" })); + const player = document.createElement("button"); + document.body.append(player); + player.focus(); + const layer = pushLayer(); + act(() => showInTray(2)); + await waitFor(() => expect(screen.getByText("Clip 2")).toBeTruthy()); + expect(document.activeElement).toBe(player); + removeLayer(layer); + player.remove(); + }); + + it("a job done before the tray saw it run opens as a file", () => { + // The queue's first look only learns what is there: this one never ran in this tab. + poll([job(5, "done", { folder_id: 7 })]); + mount(); + act(() => showInTray(5)); + expect(h.openFile).toHaveBeenCalledWith(5, 7); + expect(tray()).toBeNull(); + }); + + it("a job a later poll does not have is let go — nothing opens, nothing waits for it", () => { + poll([job(1, "running")]); + const view = mount(); + fireEvent.click(screen.getByRole("button", { name: "tray.fold" })); + act(() => showInTray(9)); + h.updatedAt = Date.now() + 1000; + poll([job(1, "running")], view); + // Had the ask stayed, the job turning up later would pull the tray open on it. + poll([job(1, "running"), job(9, "running")], view); + expect(screen.queryByText("Clip 9")).toBeNull(); + expect(h.openFile).not.toHaveBeenCalled(); + }); + }); }); diff --git a/frontend/src/components/DownloadCenter.menu.dom.test.tsx b/frontend/src/components/DownloadTray.menu.dom.test.tsx similarity index 68% rename from frontend/src/components/DownloadCenter.menu.dom.test.tsx rename to frontend/src/components/DownloadTray.menu.dom.test.tsx index c85cb948..a28411e3 100644 --- a/frontend/src/components/DownloadCenter.menu.dom.test.tsx +++ b/frontend/src/components/DownloadTray.menu.dom.test.tsx @@ -1,18 +1,24 @@ // @vitest-environment jsdom // -// The Download Center's queue: a row keeps its primary action (pause / resume / retry) in sight, and -// the copies and cancel are its ONE menu, opened by the row's "…" (it shows on hover or focus) or a -// right-click on the row. A finished download's row is the Files page's (Files.menu.dom.test.tsx). +// The tray's row menu: the copies and cancel of a waiting, running or failed job are its ONE menu, +// opened by the row's "…" (it shows on hover or focus) or a right-click on the row; a failed job's +// first row is Retry. The cancel confirm names its ACTION on its button, and speaks of what is +// cancelled (a download, an upload, a clip). The tray's own showing and done rows are +// DownloadTray.dom.test.tsx's; a finished file's menu is the Files page's (Files.menu.dom.test.tsx). // i18n is key-passthrough, so labels read as keys. import { afterEach, describe, expect, it, vi } from "vitest"; import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useState } from "react"; import { api, type DownloadJob } from "../lib/api"; import { menuGroups } from "./ui/menuGroups"; const mocks = vi.hoisted(() => ({ jobs: [] as unknown[], - confirm: vi.fn(async (_o?: unknown) => true), + confirm: vi.fn( + async (_o?: { title: string; message?: string; confirmLabel?: string; danger?: boolean }) => + true, + ), })); vi.mock("react-i18next", async (importOriginal) => { @@ -31,13 +37,18 @@ vi.mock("../lib/useLiveQuery", () => ({ refetch: vi.fn(), }), })); -vi.mock("../lib/useMe", () => ({ useMe: () => ({ role: "user" }) })); -vi.mock("./ui/PageShell", async (importOriginal) => ({ - ...(await importOriginal()), - PageToolbar: ({ children }: { children: React.ReactNode }) => <>{children}, +vi.mock("../lib/usePref", () => ({ + usePref: (_key: string, initial: T) => useState(initial), })); +vi.mock("./filesContext", () => ({ useFilesActions: () => ({ openFile: vi.fn() }) })); -import DownloadCenter from "./DownloadCenter"; +globalThis.ResizeObserver ??= class { + observe() {} + unobserve() {} + disconnect() {} +} as unknown as typeof ResizeObserver; + +import DownloadTray from "./DownloadTray"; const job = ( id: number, @@ -68,6 +79,7 @@ const job = ( extra_links: [], can_download: status === "done", job_kind: "download", + source_key: "youtube", asset: { media_kind: "video", duration_s: 60 }, ...extra, }) as unknown as DownloadJob; @@ -78,25 +90,23 @@ afterEach(() => { mocks.confirm.mockClear(); }); -function mount(tab?: string) { +function mount() { const qc = new QueryClient(); const tree = () => ( - + ); const { rerender } = render(tree()); mounted.rerender = () => rerender(tree()); - if (tab) - fireEvent.click(screen.getByRole("tab", { name: new RegExp(`downloads\\.tabs\\.${tab}`) })); } -// The last mount's re-render: what a poll's new data does to the page. +// The last mount's re-render: what a poll's new data does to the tray. const mounted = { rerender: () => {} }; const rowOf = (text: string) => screen.getByText(text).closest("[data-nav-index]")!; const moreOf = (text: string) => - rowOf(text).querySelector('button[aria-label="downloads.rowMenu"]')!; + rowOf(text).querySelector('button[aria-label="tray.rowMenu"]')!; const menuRows = () => screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent ?? ""); async function openMenu(open: () => void) { @@ -105,16 +115,10 @@ async function openMenu(open: () => void) { expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0), ); } -const rowButtons = (text: string) => - [...rowOf(text).querySelectorAll("button, a[aria-label]")].map((b) => - b.getAttribute("aria-label"), - ); - -describe("DownloadCenter · the queue row", () => { - it("shows pause and its '…' only; the '…' hides until hover or focus", async () => { +describe("DownloadTray · the row menu", () => { + it("the '…' hides until hover or focus", () => { mocks.jobs = [job(1, "running")]; mount(); - expect(rowButtons("Clip 1")).toEqual(["downloads.actions.pause", "downloads.rowMenu"]); expect(moreOf("Clip 1").classList.contains("reveal")).toBe(true); expect(rowOf("Clip 1").classList.contains("group")).toBe(true); }); @@ -139,15 +143,10 @@ describe("DownloadCenter · the queue row", () => { ]); }); - it("draws Retry as 'do it again', never the Play glyph (row and menu)", async () => { - mocks.jobs = [job(1, "error"), job(2, "paused")]; + it("draws Retry as 'do it again', never the Play glyph", async () => { + mocks.jobs = [job(1, "error")]; mount(); const glyph = (el: Element) => el.querySelector("svg")?.getAttribute("class") ?? ""; - const retry = rowOf("Clip 1").querySelector('[aria-label="downloads.actions.retry"]')!; - expect(glyph(retry)).toContain("lucide-refresh-cw"); - // A paused job resumes: that one keeps the transport glyph. - const resume = rowOf("Clip 2").querySelector('[aria-label="downloads.actions.resume"]')!; - expect(glyph(resume)).toContain("lucide-play"); await openMenu(() => fireEvent.contextMenu(rowOf("Clip 1"))); const row = screen .getAllByRole("menuitem", { hidden: true }) @@ -211,6 +210,52 @@ describe("DownloadCenter · the queue row", () => { }); }); +describe("DownloadTray · the cancel confirm", () => { + const cancelOf = async (title: string) => { + mount(); + await openMenu(() => fireEvent.click(moreOf(title))); + fireEvent.click( + screen + .getAllByRole("menuitem", { hidden: true }) + .find((b) => b.textContent === "downloads.actions.cancel")!, + ); + await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1)); + return mocks.confirm.mock.calls[0]![0]!; + }; + + it("labels its button with the action, not the dialog's question", async () => { + mocks.confirm.mockResolvedValueOnce(false); + mocks.jobs = [job(1, "running")]; + const o = await cancelOf("Clip 1"); + expect([o.title, o.confirmLabel]).toEqual([ + "downloads.confirm.cancelTitle", + "downloads.confirm.cancelAction", + ]); + }); + + it("cancelling an upload says the uploaded file goes with it", async () => { + // Nothing to fetch again: the cancel deletes the original from the server. + mocks.confirm.mockResolvedValueOnce(false); + mocks.jobs = [job(3, "queued", { job_kind: "upload" })]; + const o = await cancelOf("Clip 3"); + expect([o.title, o.confirmLabel]).toEqual([ + "downloads.confirm.cancelUploadTitle", + "downloads.confirm.cancelUploadAction", + ]); + }); + + it("cancelling a clip speaks of the clip", async () => { + mocks.confirm.mockResolvedValueOnce(false); + mocks.jobs = [job(4, "running", { job_kind: "edit" })]; + const o = await cancelOf("Clip 4"); + expect([o.title, o.message, o.confirmLabel]).toEqual([ + "downloads.confirm.cancelEditTitle", + "downloads.confirm.cancelEditBody", + "downloads.confirm.cancelEditAction", + ]); + }); +}); + describe("menuGroups", () => { const row = (id: string) => ({ id, label: id, onSelect: () => {} }); it("rules between non-empty groups only", () => { diff --git a/frontend/src/components/DownloadTray.tsx b/frontend/src/components/DownloadTray.tsx index 2aa2bc94..dcd75730 100644 --- a/frontend/src/components/DownloadTray.tsx +++ b/frontend/src/components/DownloadTray.tsx @@ -18,12 +18,15 @@ import { failCopyKeys, failTitleKey } from "../lib/downloadFailure"; import { IN_FLIGHT, clearFinished, + doneRevealing, fetchQueue, takeUnannounced, useFinished, + useRevealing, useUnannounced, } from "../lib/downloadQueue"; -import { navItemAttrs } from "../lib/listNav"; +import { anyBlockingLayer } from "../lib/layerStack"; +import { focusNavItem, navItemAttrs } from "../lib/listNav"; import { notify } from "../lib/notifications"; import { qk } from "../lib/queryKeys"; import { LS } from "../lib/storage"; @@ -57,7 +60,8 @@ type Fold = "open" | "folded"; const parseFold = (v: unknown): Fold => (v === "folded" ? "folded" : "open"); const parseClosed = (v: unknown): string | null => (typeof v === "string" ? v : null); -// Byte progress (a bar with a %) vs ffmpeg's post-steps (a pulse): the queue row's split. +// Byte progress (a bar with a %) vs a post-step that reports none (a pulse): "editing" and +// "converting" report a real % from ffmpeg's out_time. const BYTE_PHASES = ["video", "audio", "editing", "converting"]; const keyOf = (job: QueueJob) => job.id; @@ -132,6 +136,35 @@ export default function DownloadTray() { else if (target === "open") foldButton.current?.focus(); }, [fold]); + // --- "View" on a job just started: the tray opens on its row ---------------------------------- + // Answered over as many renders as it takes: a closed or folded tray is brought back first, and the + // row is focused once it is on the page. A job the tray does not list is done already (it opens as + // a file), or — once a poll made after the ask has landed without it — not in the queue at all. + // Asked from inside a player (a clip's notice, the player's own download), the tray opens under it + // but leaves the keyboard there: the row is behind the player, out of the reader's sight. + const box = useRef(null); + const revealing = useRevealing(); + const answerReveal = useEffectEvent(() => { + if (!revealing) return; + const index = rows.findIndex((j) => j.id === revealing.id); + if (index >= 0) { + if (!shown) setClosedAt(null); + else if (fold === "folded") setFold("open"); + else { + if (box.current && !anyBlockingLayer()) focusNavItem(box.current, index); + doneRevealing(); + } + return; + } + const job = queueQ.data?.find((j) => j.id === revealing.id); + if (job?.status === "done") { + doneRevealing(); + openFile(job.id, job.folder_id); + } else if (queueQ.dataUpdatedAt > revealing.at) doneRevealing(); + else void qc.invalidateQueries({ queryKey: qk.downloads() }); + }); + useEffect(() => answerReveal(), [revealing, queueQ.data, shown, fold]); + // --- the done notice ------------------------------------------------------------------------ const announce = useEffectEvent((job: QueueJob) => { const title = jobTitle(job); @@ -153,7 +186,6 @@ export default function DownloadTray() { }, [fresh]); // --- Back to top steps up over the tray while it shows ----------------------------------------- - const box = useRef(null); useLayoutEffect(() => { const el = box.current; if (!el) { diff --git a/frontend/src/components/Files.tsx b/frontend/src/components/Files.tsx index 199adf0d..95711670 100644 --- a/frontend/src/components/Files.tsx +++ b/frontend/src/components/Files.tsx @@ -411,6 +411,7 @@ export default function Files() { width: "10rem", align: "right", cardLabel: false, + cardRow: true, render: (f) => { const a = f.shared ? sharedRowActions(f) : libraryRowActions(f); const mine = rowMenu.open && rowMenu.target?.id === f.id; diff --git a/frontend/src/components/LibraryPlayer.tsx b/frontend/src/components/LibraryPlayer.tsx index 37d26328..031df4af 100644 --- a/frontend/src/components/LibraryPlayer.tsx +++ b/frontend/src/components/LibraryPlayer.tsx @@ -36,7 +36,7 @@ export default function LibraryPlayer({ const qc = useQueryClient(); const fileUrl = useMemo(() => api.fileUrl(job.id), [job.id]); // Video vs audio-only comes from the stored media_kind (the single source every sibling reads — - // WatchPage/downloadRow/DownloadCenter) — NOT a re-derived container list, which would misclassify + // WatchPage/downloadRow/the tray) — NOT a re-derived container list, which would misclassify // an uploaded audio file whose container is outside a hardcoded set (uploads accept any container). // Only fall back to the old dimension+container heuristic for a legacy asset with a null media_kind. const asset = job.asset; diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index a539ec44..3a7e856e 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -120,12 +120,6 @@ export default function NavSidebar({ // events (the former separate bell is now folded into the inbox page). const clientUnread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount); const unread = (unreadQuery.data?.count ?? 0) + clientUnread; - // Download center badge = items still working (queued/running/paused). Reuses the same - // lightweight per-user index the feed cards poll, so no extra request shape. - const dlIndexQuery = useLiveQuery(qk.downloadIndex(), api.downloadIndex, { - intervalMs: 30000, - }); - const dlActive = Object.values(dlIndexQuery.data ?? {}).filter((s) => s !== "done").length; // Scroll-affordance for the module list: hidden scrollbar + top/bottom edge fade (shared hook, // also used by the side panels) so a high-zoom overflow scrolls without a scrollbar. @@ -138,7 +132,6 @@ export default function NavSidebar({ // badge NUMBERS are the only nav-only concern (they need the live polling queries above). const BADGE: Partial> = { notifications: unread, - downloads: dlActive, }; const META = (p: Page): Omit => ({ icon: moduleDef(p).icon, diff --git a/frontend/src/components/NavigationProvider.dom.test.tsx b/frontend/src/components/NavigationProvider.dom.test.tsx index df1dcaaa..5d020e2f 100644 --- a/frontend/src/components/NavigationProvider.dom.test.tsx +++ b/frontend/src/components/NavigationProvider.dom.test.tsx @@ -206,6 +206,18 @@ describe("NavigationProvider · Back", () => { expect(window.location.search).toBe("?keep=1"); }); + it("lands where a retired page's content went, and the entry names that page", () => { + const { result } = render(); + act(() => result.current.setPage("plex")); + act(() => { + window.history.replaceState({ sfPage: "downloads" }, "", "/?page=downloads&keep=1"); + window.dispatchEvent(new PopStateEvent("popstate", { state: { sfPage: "downloads" } })); + }); + expect(result.current.page).toBe("files"); + expect(window.history.state).toMatchObject({ sfPage: "files" }); + expect(window.location.search).toBe("?page=files&keep=1"); + }); + // lib/history's own listener takes a spent focus stamp off the entry it lands on; the rewrite must // not put it back, or the next landing there (an overlay closing) would refocus a stale item. it("does not put back the focus stamp the landing spent", () => { diff --git a/frontend/src/components/NavigationProvider.tsx b/frontend/src/components/NavigationProvider.tsx index e9463e92..a8d18c0f 100644 --- a/frontend/src/components/NavigationProvider.tsx +++ b/frontend/src/components/NavigationProvider.tsx @@ -2,12 +2,11 @@ import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react import { useLatest } from "../lib/useLatest"; import { useTranslation } from "react-i18next"; import { useMe } from "../lib/useMe"; -import { isPage, urlWithParams, type Page } from "../lib/urlState"; +import { resolvePage, urlWithParams, type Page } from "../lib/urlState"; import { LOGS_KEYS } from "../lib/logsUrlState"; import { pageTitleKey } from "../lib/pageMeta"; import { useDocumentObjectTitle } from "../lib/documentTitle"; import { setAccountRaw } from "../lib/storage"; -import { setNavigator } from "../lib/nav"; import { backStaysInDocument, closeOverlaysThenNavigate, @@ -156,9 +155,6 @@ export function NavigationProvider({ children }: { children: ReactNode }) { } }, []); - // Expose setPage to decoupled callers (download toast "View", etc.) via the lib/nav singleton. - useEffect(() => setNavigator(setPage), [setPage]); - // Leaving the feed ends any live YouTube search, so coming back shows the normal feed. Adjusted // DURING the render that changes the page, against the page we were last on: as an effect it ran // after the new page had already committed, so for one frame a module rendered with a live search @@ -206,17 +202,18 @@ export function NavigationProvider({ children }: { children: ReactNode }) { } function onPop(e: PopStateEvent) { // An entry can name a page this build no longer has (one pushed before a module was removed, - // reached by Back after a reload onto the new build); that lands on the feed, not on a crash. - // The entry is rewritten to say so: left alone, the address bar would keep naming the dead page - // and a click on Feed would be a no-op (setPage's same-page guard). It spreads the LIVE entry, - // not `e.state`: lib/history's listener ran first and already took the spent `sfNav` off it. + // reached by Back after a reload onto the new build); that lands where the page's content went + // (`resolvePage`), else on the feed, not on a crash. The entry is rewritten to say so: left + // alone, the address bar would keep naming the dead page and a click on the page it landed on + // would be a no-op (setPage's same-page guard). It spreads the LIVE entry, not `e.state`: + // lib/history's listener ran first and already took the spent `sfNav` off it. const sf: unknown = e.state?.sfPage; - const p: Page = typeof sf === "string" && isPage(sf) ? sf : "feed"; + const p: Page = (typeof sf === "string" && resolvePage(sf)) || "feed"; if (typeof sf === "string" && sf !== p) window.history.replaceState( { ...window.history.state, sfPage: p }, "", - urlWithParams({ page: null }), + urlWithParams({ page: p === "feed" ? null : p }), ); setPageState(p); setAccountRaw(PAGE_KEY, p); diff --git a/frontend/src/components/ProfileEditor.dom.test.tsx b/frontend/src/components/ProfileEditor.dom.test.tsx new file mode 100644 index 00000000..10a3017f --- /dev/null +++ b/frontend/src/components/ProfileEditor.dom.test.tsx @@ -0,0 +1,139 @@ +// @vitest-environment jsdom +// +// The download formats (Settings → Downloads, and the download dialog's "Manage formats"): one aligned +// list, built-ins first, each row saying what it saves. A row's one menu: a built-in's only +// Duplicate; the user's own Edit, Duplicate and Delete (behind a confirm). A duplicate is a NEW format +// with the original's settings and a name of its own. i18n is key-passthrough. +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { api, type DownloadProfile } from "../lib/api"; + +const mocks = vi.hoisted(() => ({ confirm: vi.fn(async (_o?: unknown) => true) })); + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), +})); +vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm })); + +import { DownloadFormats } from "./ProfileEditor"; + +const SPEC = { + mode: "av", + max_height: 480, + container: "mp4", + audio_format: "m4a", + vcodec: null, + embed_subs: true, + embed_chapters: false, + embed_thumbnail: false, + sponsorblock: true, +}; +const PROFILES = [ + { id: 9, name: "Phone 480", is_builtin: false, spec: SPEC }, + { id: 1, name: "best", is_builtin: true, spec: { mode: "a", audio_format: "mp3" } }, +] as unknown as DownloadProfile[]; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + mocks.confirm.mockClear(); +}); + +async function mount() { + vi.spyOn(api, "downloadProfiles").mockResolvedValue(PROFILES); + render( + + + , + ); + await screen.findByText("Phone 480"); +} + +const rows = () => [...document.querySelectorAll("[data-nav-index]")]; +const rowOf = (text: string) => screen.getByText(text).closest("[data-nav-index]")!; +const menuRows = () => + screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent ?? ""); +const pick = (label: string) => + fireEvent.click( + screen.getAllByRole("menuitem", { hidden: true }).find((b) => b.textContent === label)!, + ); +async function openMenu(row: HTMLElement) { + act(() => { + fireEvent.contextMenu(row); + }); + await waitFor(() => + expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0), + ); +} + +describe("DownloadFormats", () => { + it("lists the built-ins first, each saying what it saves", async () => { + await mount(); + expect(rows().map((r) => r.textContent)).toEqual([ + expect.stringContaining("downloads.profiles.modeA · MP3"), + expect.stringContaining( + "downloads.profiles.modeAv · ≤480p · downloads.profiles.short.subs · downloads.profiles.short.sponsorblock", + ), + ]); + }); + + it("a built-in's menu only duplicates; the user's own edits, duplicates and deletes", async () => { + await mount(); + await openMenu(rows()[0]!); + expect(menuRows()).toEqual(["downloads.profiles.duplicate"]); + fireEvent.keyDown(document.activeElement!, { key: "Escape" }); + await openMenu(rowOf("Phone 480")); + expect(menuRows()).toEqual([ + "downloads.profiles.edit", + "downloads.profiles.duplicate", + "downloads.profiles.delete", + ]); + }); + + it("deletes only through the confirm", async () => { + const del = vi.spyOn(api, "deleteDownloadProfile").mockResolvedValue({} as never); + await mount(); + mocks.confirm.mockResolvedValueOnce(false); + await openMenu(rowOf("Phone 480")); + pick("downloads.profiles.delete"); + await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1)); + expect(del).not.toHaveBeenCalled(); + await openMenu(rowOf("Phone 480")); + pick("downloads.profiles.delete"); + await waitFor(() => expect(del).toHaveBeenCalledWith(9)); + }); + + it("a duplicate is a new format with the original's settings and a name of its own", async () => { + const create = vi.spyOn(api, "createDownloadProfile").mockResolvedValue({} as never); + const update = vi.spyOn(api, "updateDownloadProfile").mockResolvedValue({} as never); + await mount(); + await openMenu(rowOf("Phone 480")); + pick("downloads.profiles.duplicate"); + const name = await screen.findByPlaceholderText("downloads.profiles.namePlaceholder"); + expect((name as HTMLInputElement).value).toBe(""); + fireEvent.change(name, { target: { value: "Phone copy" } }); + fireEvent.click(screen.getByText("downloads.profiles.create")); + await waitFor(() => + expect(create).toHaveBeenCalledWith({ + name: "Phone copy", + spec: expect.objectContaining({ max_height: 480, embed_subs: true, sponsorblock: true }), + }), + ); + expect(update).not.toHaveBeenCalled(); + }); + + it("Edit saves over the format itself", async () => { + const update = vi.spyOn(api, "updateDownloadProfile").mockResolvedValue({} as never); + await mount(); + await openMenu(rowOf("Phone 480")); + pick("downloads.profiles.edit"); + const name = await screen.findByDisplayValue("Phone 480"); + fireEvent.change(name, { target: { value: "Phone" } }); + fireEvent.click(screen.getByText("downloads.profiles.save")); + await waitFor(() => + expect(update).toHaveBeenCalledWith(9, expect.objectContaining({ name: "Phone" })), + ); + }); +}); diff --git a/frontend/src/components/ProfileEditor.tsx b/frontend/src/components/ProfileEditor.tsx index d84454a5..15c7bd7f 100644 --- a/frontend/src/components/ProfileEditor.tsx +++ b/frontend/src/components/ProfileEditor.tsx @@ -1,8 +1,9 @@ import { useState } from "react"; import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Lock, Pencil, Plus, Trash2 } from "lucide-react"; +import { CopyPlus, Lock, Pencil, Plus, Trash2 } from "lucide-react"; import Modal from "./ui/Modal"; import { useConfirm } from "./confirmContext"; import { api, type DownloadProfile, type DownloadSpec } from "../lib/api"; @@ -10,11 +11,15 @@ import { profileName } from "../lib/vocab"; import { Button } from "./ui/Button"; import SegmentedToggle from "./ui/SegmentedToggle"; import FilterSelect from "./ui/FilterSelect"; -import { IconButton } from "./ui/IconButton"; import { CheckRow } from "./ui/NativeControls"; import { Input } from "./ui/Input"; -import { SURFACE } from "./ui/surfaces"; -import { SectionLabel } from "./ui/Headings"; +import { type ActionMenuItem } from "./ui/ActionMenu"; +import { ContextMenu } from "./ui/ContextMenu"; +import DataTable, { type Column } from "./ui/DataTable"; +import { menuGroups } from "./ui/menuGroups"; +import { MoreButton } from "./ui/MoreButton"; +import { LoadingState, StateMessage } from "./ui/QueryState"; +import { useContextMenu } from "./ui/useContextMenu"; const HEIGHTS = [null, 2160, 1440, 1080, 720, 480, 360]; @@ -46,14 +51,43 @@ function Check({ ); } -export default function ProfileEditor({ onClose }: { onClose: () => void }) { +// The download formats: the built-in ones (read-only, a base to start from) and the user's own, in one +// aligned list whose rows each have ONE menu (its "…", a right-click, a long-press, Shift+F10) — +// Edit · Duplicate · Delete, a built-in's only Duplicate. The new/edit form opens under the list. +// Settings → Downloads shows it in place; the download dialog's "Manage formats" in a modal. + +const MODE_KEY = { + av: "downloads.profiles.modeAv", + v: "downloads.profiles.modeV", + a: "downloads.profiles.modeA", +} as const; + +/** What a format saves, in a line: the content, the quality (or the audio format), the extras. */ +function specSummary(t: TFunction, spec: Partial): string { + const mode = spec.mode ?? "av"; + const parts = [ + t(MODE_KEY[mode]), + mode === "a" + ? (spec.audio_format ?? "m4a").toUpperCase() + : spec.max_height + ? `≤${spec.max_height}p` + : t("downloads.profiles.best"), + ]; + if (mode !== "a" && spec.embed_subs) parts.push(t("downloads.profiles.short.subs")); + if (spec.embed_chapters) parts.push(t("downloads.profiles.short.chapters")); + if (spec.embed_thumbnail) parts.push(t("downloads.profiles.short.thumbnail")); + if (spec.sponsorblock) parts.push(t("downloads.profiles.short.sponsorblock")); + return parts.join(" · "); +} + +export function DownloadFormats() { const { t } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); const profilesQ = useQuery({ queryKey: qk.downloadProfiles(), queryFn: api.downloadProfiles }); - const profiles = profilesQ.data ?? []; - const builtins = profiles.filter((p) => p.is_builtin); - const mine = profiles.filter((p) => !p.is_builtin); + // Built-ins first, then the user's own — the order the dialog's menu lists them in. + const all = profilesQ.data ?? []; + const profiles = [...all.filter((p) => p.is_builtin), ...all.filter((p) => !p.is_builtin)]; const [editing, setEditing] = useState(null); const [name, setName] = useState(""); @@ -73,6 +107,13 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) { setSpec({ ...BLANK, ...p.spec }); setFormOpen(true); }; + // A new format from an existing one: its settings, and a name of its own to give. + const startCopy = (p: DownloadProfile) => { + setEditing(null); + setName(""); + setSpec({ ...BLANK, ...p.spec }); + setFormOpen(true); + }; const save = useMutation({ mutationFn: () => @@ -106,43 +147,112 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) { const patch = (p: Partial) => setSpec((s) => ({ ...s, ...p })); + const menu = useContextMenu({ keyOf: (p) => p.id }); + const items = (p: DownloadProfile): ActionMenuItem[] => + menuGroups( + p.is_builtin + ? [] + : [ + { + id: "edit", + label: t("downloads.profiles.edit"), + icon: , + onSelect: () => startEdit(p), + }, + ], + [ + { + id: "copy", + label: t("downloads.profiles.duplicate"), + icon: , + onSelect: () => startCopy(p), + }, + ], + p.is_builtin + ? [] + : [ + { + id: "delete", + label: t("downloads.profiles.delete"), + icon: , + danger: true, + onSelect: () => void onDelete(p), + }, + ], + ); + const columns: Column[] = [ + { + key: "name", + header: t("downloads.profiles.name"), + cardPrimary: true, + render: (p) => ( + + {profileName(t, p)} + {p.is_builtin && ( + + )} + + ), + }, + { + key: "spec", + header: t("downloads.profiles.saves"), + render: (p) => { + const summary = specSummary(t, p.spec); + return ( + + {summary} + + ); + }, + }, + { + key: "menu", + header: "", + width: "3rem", + align: "right", + cardLabel: false, + cardRow: true, + render: (p) => ( + + ), + }, + ]; + return ( - - {/* Existing formats */} -
- {t("downloads.profiles.builtin")} - {builtins.map((p) => ( -
- - {profileName(t, p)} -
- ))} - {mine.length > 0 && ( - - {t("downloads.profiles.yours")} - - )} - {mine.map((p) => ( -
- {p.name} - startEdit(p)} label={t("downloads.actions.rename")}> - - - onDelete(p)} - label={t("downloads.profiles.delete")} - > - - -
- ))} -
+
+ {profilesQ.isError && !profilesQ.data ? ( + void profilesQ.refetch()}> + {t("common.loadError")} + + ) : !profilesQ.data ? ( + + ) : ( +
+ String(p.id)} + pager={false} + keyboardNav + fixedLayout + rowProps={(p) => menu.triggerProps(p)} + /> +
+ )} + {!formOpen ? (
-
+ {/* One option a line: a CheckRow is inline, and in the wide Settings column four of them + ran together on one line. */} +
{spec.mode !== "a" && ( void }) {
)} + + ); +} + +export default function ProfileEditor({ onClose }: { onClose: () => void }) { + const { t } = useTranslation(); + return ( + + ); } diff --git a/frontend/src/components/SettingsPanel.downloads.dom.test.tsx b/frontend/src/components/SettingsPanel.downloads.dom.test.tsx new file mode 100644 index 00000000..6b63579f --- /dev/null +++ b/frontend/src/components/SettingsPanel.downloads.dom.test.tsx @@ -0,0 +1,52 @@ +// @vitest-environment jsdom +// +// Settings has a Downloads tab: the download formats and the sign-ins to sites that need one, both +// preferences of the downloading. The sign-ins left the Account tab for it. i18n is key-passthrough. +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useState } from "react"; +import { api } from "../lib/api"; + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), +})); +vi.mock("../lib/storage", async (importOriginal) => ({ + ...(await importOriginal()), + // Opened on the Downloads tab: Appearance, the default, needs the whole prefs provider. + useTabPersistedState: () => useState("downloads"), +})); +vi.mock("../lib/useMe", () => ({ + useMe: () => ({ id: 1, role: "user", email: "a@b.c", google_enabled: false }), +})); +vi.mock("./prefsContext", () => ({ usePrefs: () => ({ saveState: "idle" }) })); +vi.mock("./wizardContext", () => ({ useWizard: () => ({ openWizard: vi.fn() }) })); +vi.mock("./confirmContext", () => ({ useConfirm: () => vi.fn(async () => true) })); + +import SettingsPanel from "./SettingsPanel"; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +function mount() { + vi.spyOn(api, "downloadProfiles").mockResolvedValue([]); + vi.spyOn(api, "downloadCredentials").mockResolvedValue({ hosts: [] } as never); + render( + + + , + ); +} + +describe("Settings · Downloads", () => { + it("holds the formats and the site sign-ins; Account no longer does", async () => { + mount(); + expect(await screen.findByText("settings.downloads.formats")).toBeTruthy(); + expect(screen.getByText("settings.downloadAuth.title")).toBeTruthy(); + fireEvent.click(screen.getByTestId("settings-tab-account")); + expect(screen.queryByText("settings.downloadAuth.title")).toBeNull(); + }); +}); diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 8c397753..657a4cee 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -4,7 +4,18 @@ import { qk } from "../lib/queryKeys"; import { formatDate, formatDateTime, formatUnit } from "../lib/format"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Activity, Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-react"; +import { + Activity, + Bell, + Check, + Download, + Loader2, + Monitor, + RefreshCw, + Trash2, + User, +} from "lucide-react"; +import { DownloadFormats } from "./ProfileEditor"; import DiagnosticsSettings from "./DiagnosticsSettings"; import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme"; import { api, HttpError, type Me } from "../lib/api"; @@ -50,10 +61,11 @@ export interface PrefsController { saveState: PrefsSaveState; } -type TabId = "appearance" | "notifications" | "account" | "diagnostics"; +type TabId = "appearance" | "notifications" | "downloads" | "account" | "diagnostics"; const TABS: { id: TabId; icon: typeof Monitor }[] = [ { id: "appearance", icon: Monitor }, { id: "notifications", icon: Bell }, + { id: "downloads", icon: Download }, { id: "account", icon: User }, { id: "diagnostics", icon: Activity }, ]; @@ -101,6 +113,7 @@ export default function SettingsPanel() {
{tab === "appearance" && } {tab === "notifications" && } + {tab === "downloads" && } {tab === "account" && } {tab === "diagnostics" && }
@@ -615,7 +628,7 @@ function PlexWatchSync() { // (e.g. a private Facebook group you belong to) so downloads from it use YOUR login. Write-only — // the stored blob is never shown back; each row shows only the host + whether it's set + when. // A cookie-authed download is kept private to your account (never cache-shared with another user). -// Exported for a focused DOM test (write-only behaviour); rendered inside Account below. +// Exported for a focused DOM test (write-only behaviour); rendered on the Downloads tab below. export function DownloadAuth() { const { t, i18n } = useTranslation(); const qc = useQueryClient(); @@ -796,6 +809,23 @@ export function AccountHeader({ me }: { me: Me }) { ); } +// How this account downloads: its formats (the download dialog's choices) and its sign-ins to sites +// that need one. Preferences of the downloading, not of the account's identity. +function Downloads() { + const { t } = useTranslation(); + return ( + <> +
+

+ {t("settings.downloads.formatsIntro")} +

+ +
+ + + ); +} + function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) { const { t } = useTranslation(); const confirm = useConfirm(); @@ -825,8 +855,6 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) { {me.role === "admin" && me.plex_enabled && } - - {me.google_enabled ? ( // YouTube access requires Google OAuth; hidden when this instance has no Google configured.
diff --git a/frontend/src/components/UploadDialog.dom.test.tsx b/frontend/src/components/UploadDialog.dom.test.tsx index f0bd42e0..b0836ea8 100644 --- a/frontend/src/components/UploadDialog.dom.test.tsx +++ b/frontend/src/components/UploadDialog.dom.test.tsx @@ -15,7 +15,7 @@ const h = vi.hoisted(() => ({ // user's bandwidth on it. Generous by default; the cap test below lowers it. filesUsage: vi.fn(async () => ({ upload_max_bytes: 1_000_000 })), notify: vi.fn(), - navigateTo: vi.fn(), + showInTray: vi.fn(), openFile: vi.fn(), })); @@ -35,7 +35,7 @@ vi.mock("../lib/notifications", async (importOriginal) => ({ ...(await importOriginal()), notify: h.notify, })); -vi.mock("../lib/nav", () => ({ navigateTo: h.navigateTo })); +vi.mock("../lib/downloadQueue", () => ({ showInTray: h.showInTray })); import UploadDialog from "./UploadDialog"; import { FilesActionsContext } from "./filesContext"; @@ -67,7 +67,7 @@ const fileInput = () => document.querySelector('input[type="file"]') as HTMLInpu beforeEach(() => { h.uploadFile.mockClear(); h.openFile.mockClear(); - h.navigateTo.mockClear(); + h.showInTray.mockClear(); h.notify.mockClear(); h.filesUsage.mockClear(); h.filesUsage.mockResolvedValue({ upload_max_bytes: 1_000_000 }); @@ -115,8 +115,7 @@ describe("UploadDialog", () => { }); it("puts the file in the folder it was opened from, and its notice opens where it now is", async () => { - // Done: Files opens on the file itself, in its folder. Still converting: it is in the download - // queue until it is done. + // Done: Files opens on the file itself, in its folder. Still converting: the tray opens on it. h.uploadFile.mockResolvedValueOnce({ id: 42, status: "done", folder_id: 5 } as never); wrap(vi.fn(), 5); fireEvent.change(fileInput(), { target: { files: [new File(["x"], "a.mp4")] } }); @@ -125,7 +124,7 @@ describe("UploadDialog", () => { expect((h.uploadFile.mock.calls[0]![1] as { folderId?: number }).folderId).toBe(5); (h.notify.mock.calls[0]![0] as { action: { onClick: () => void } }).action.onClick(); expect(h.openFile).toHaveBeenLastCalledWith(42, 5); - expect(h.navigateTo).not.toHaveBeenCalled(); + expect(h.showInTray).not.toHaveBeenCalled(); cleanup(); h.notify.mockClear(); @@ -135,7 +134,7 @@ describe("UploadDialog", () => { fireEvent.click(screen.getByText("downloads.upload.submit")); await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1)); (h.notify.mock.calls[0]![0] as { action: { onClick: () => void } }).action.onClick(); - expect(h.navigateTo).toHaveBeenLastCalledWith("downloads"); + expect(h.showInTray).toHaveBeenLastCalledWith(43); }); it("opens ready to send a file dropped on the page", async () => { diff --git a/frontend/src/components/UploadDialog.tsx b/frontend/src/components/UploadDialog.tsx index 8c1f5ae5..348b98cb 100644 --- a/frontend/src/components/UploadDialog.tsx +++ b/frontend/src/components/UploadDialog.tsx @@ -10,7 +10,7 @@ import { isAborted } from "../lib/api/core"; import { notifyActionError } from "../lib/actionErrors"; import { invalidateDownloads } from "../lib/downloads"; import { notify } from "../lib/notifications"; -import { navigateTo } from "../lib/nav"; +import { showInTray } from "../lib/downloadQueue"; import type { DownloadJob } from "../lib/api"; import { useFilesActions } from "./filesContext"; import { formatBytes } from "../lib/format"; @@ -68,11 +68,10 @@ export default function UploadDialog({ // same reason, and `formAlerts.dom.test.tsx` is the test that exists because of it. const [pickSeq, setPickSeq] = useState(0); const { openFile } = useFilesActions(); - // A done file is in Files, opened on it; one the worker still converts is in the download queue - // until it is. + // A done file is in Files, opened on it; one the worker still converts is in the tray until it is. const openUploaded = (job: DownloadJob) => { if (job.status === "done") openFile(job.id, job.folder_id ?? null); - else navigateTo("downloads"); + else showInTray(job.id); }; const pick_ = (f: File) => { setFile(f); diff --git a/frontend/src/components/Welcome.tsx b/frontend/src/components/Welcome.tsx index ba604bac..f161967c 100644 --- a/frontend/src/components/Welcome.tsx +++ b/frontend/src/components/Welcome.tsx @@ -7,7 +7,7 @@ import { Clapperboard, Clock, Code2, - Download, + FolderOpen, Expand, Filter, Inbox, @@ -182,7 +182,7 @@ const GALLERY: { key: string; src: string }[] = [ { key: "playlists", src: "/welcome/playlists.webp" }, { key: "search", src: "/welcome/search.webp" }, { key: "player", src: "/welcome/player.webp" }, - { key: "downloads", src: "/welcome/downloads.webp" }, + { key: "files", src: "/welcome/files.webp" }, { key: "plex", src: "/welcome/plex.webp" }, ]; @@ -192,7 +192,7 @@ const FEATURES: { key: string; icon: LucideIcon }[] = [ { key: "search", icon: Search }, { key: "playlists", icon: ListVideo }, { key: "player", icon: PlayCircle }, - { key: "downloads", icon: Download }, + { key: "files", icon: FolderOpen }, { key: "plex", icon: Clapperboard }, { key: "private", icon: Lock }, ]; diff --git a/frontend/src/components/channelColumns.tsx b/frontend/src/components/channelColumns.tsx index d973a472..0aa9b8f5 100644 --- a/frontend/src/components/channelColumns.tsx +++ b/frontend/src/components/channelColumns.tsx @@ -252,6 +252,7 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column ({ + storage: undefined as unknown, + jobs: undefined as unknown, + confirm: vi.fn(async (_o?: unknown) => true), +})); + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ + i18n: { language: "en" }, + t: (key: string, o?: { size?: string; email?: string }) => + `${key}${o?.size ? `:${o.size}` : ""}${o?.email ? `:${o.email}` : ""}`, + }), +})); +vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm })); +vi.mock("../lib/useLiveQuery", () => ({ + useLiveQuery: (key: readonly unknown[]) => ({ + data: key[0] === qk.adminStorage()[0] ? mocks.storage : mocks.jobs, + isError: false, + isLoading: false, + refetch: vi.fn(), + }), +})); + +import { StorageTab } from "./downloadAdmin"; + +const GiB = 1024 ** 3; + +const storage = (): AdminStorage => ({ + files_on_disk: 3, + total_bytes: 3 * GiB, + total_assets: 3, + total_cap_bytes: 0, + per_user: [ + { + user_id: 4, + email: "anna@x", + footprint_bytes: GiB, + files: 27, + max_bytes: 5 * GiB, + unlimited: false, + }, + { + user_id: 1, + email: "peter@x", + footprint_bytes: 2 * GiB, + files: 180, + // Unlimited wins over the limit the row still carries. + max_bytes: 10 * GiB, + unlimited: true, + }, + ], +}); + +const job = (id: number, extra: Partial = {}) => + ({ + id, + status: "done", + display_name: `Clip ${id}`, + source_url: `https://example.com/${id}`, + user_id: 4, + user_email: "anna@x", + asset: { size_bytes: 1024 }, + ...extra, + }) as unknown as DownloadJob; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + mocks.confirm.mockClear(); +}); + +function mount() { + mocks.storage = storage(); + mocks.jobs = [job(7), job(8, { source_url: null, user_id: 1, user_email: "peter@x" })]; + render( + + + , + ); +} + +const rowOf = (text: string) => + screen.getAllByText(text)[0]!.closest("[data-nav-index]")!; +const menuRows = () => + screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent ?? ""); +const pick = (label: string) => + fireEvent.click( + screen.getAllByRole("menuitem", { hidden: true }).find((b) => b.textContent === label)!, + ); +async function openMenu(row: HTMLElement) { + act(() => { + fireEvent.contextMenu(row); + }); + await waitFor(() => + expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0), + ); +} + +describe("StorageTab", () => { + it("names each user's use against their own limit, and their files", () => { + mount(); + const anna = rowOf("anna@x"); + expect(anna.textContent).toMatch(/downloads\.admin\.ofQuota:5(\.0)? GB/); + expect(anna.textContent).toContain("27"); + expect(anna.querySelector('[role="meter"]')?.getAttribute("aria-valuenow")).toBe("20"); + expect(rowOf("peter@x").textContent).toContain("downloads.admin.unlimitedShort"); + }); + + it("a user's menu edits their quota, and resets it only through the confirm", async () => { + vi.spyOn(api, "adminGetDownloadQuota").mockReturnValue(new Promise(() => {}) as never); + const reset = vi.spyOn(api, "adminResetDownloadQuota").mockResolvedValue({} as never); + mount(); + await openMenu(rowOf("anna@x")); + expect(menuRows()).toEqual(["downloads.admin.editQuota", "downloads.admin.reset"]); + mocks.confirm.mockResolvedValueOnce(false); + pick("downloads.admin.reset"); + await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1)); + expect(reset).not.toHaveBeenCalled(); + await openMenu(rowOf("anna@x")); + pick("downloads.admin.reset"); + await waitFor(() => expect(reset).toHaveBeenCalledWith(4)); + await openMenu(rowOf("anna@x")); + pick("downloads.admin.editQuota"); + expect(await screen.findByText("downloads.admin.quotaTitle:anna@x")).toBeTruthy(); + expect(api.adminGetDownloadQuota).toHaveBeenCalledWith(4); + }); + + it("a download's menu opens its source, copies, and opens its OWNER's quota", async () => { + vi.spyOn(api, "adminGetDownloadQuota").mockReturnValue(new Promise(() => {}) as never); + const open = vi.spyOn(window, "open").mockReturnValue(null); + mount(); + await openMenu(rowOf("Clip 7")); + expect(menuRows()).toEqual([ + "downloads.admin.openSource", + "common.copy.title", + "common.copy.link", + "downloads.admin.editUserQuota", + ]); + pick("downloads.admin.openSource"); + expect(open).toHaveBeenCalledWith("https://example.com/7", "_blank", "noopener,noreferrer"); + // One with no source link offers neither the link nor its copy. + await openMenu(rowOf("Clip 8")); + expect(menuRows()).toEqual(["common.copy.title", "downloads.admin.editUserQuota"]); + pick("downloads.admin.editUserQuota"); + await waitFor(() => expect(api.adminGetDownloadQuota).toHaveBeenCalledWith(1)); + }); +}); diff --git a/frontend/src/components/downloadAdmin.tsx b/frontend/src/components/downloadAdmin.tsx index dcc59e4e..55e13286 100644 --- a/frontend/src/components/downloadAdmin.tsx +++ b/frontend/src/components/downloadAdmin.tsx @@ -2,23 +2,33 @@ import { useState } from "react"; import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Settings2 } from "lucide-react"; +import clsx from "clsx"; +import { ExternalLink, RotateCcw, Settings2 } from "lucide-react"; import Modal, { ModalFooter } from "./ui/Modal"; import { useLiveQuery } from "../lib/useLiveQuery"; -import { api } from "../lib/api"; +import { api, type AdminStorageUser, type DownloadJob } from "../lib/api"; import { formatBytes } from "../lib/format"; import { Button } from "./ui/Button"; import { StatusPill } from "./downloadRow"; import { jobTitle } from "./downloadTitle"; -import { IconButton } from "./ui/IconButton"; +import { useConfirm } from "./confirmContext"; +import { useCopyItems } from "./copyItems"; +import { type ActionMenuItem } from "./ui/ActionMenu"; +import { ContextMenu } from "./ui/ContextMenu"; +import DataTable, { type Column } from "./ui/DataTable"; +import { Section } from "./ui/form"; +import { menuGroups } from "./ui/menuGroups"; +import { MoreButton } from "./ui/MoreButton"; +import { useContextMenu } from "./ui/useContextMenu"; import { Input } from "./ui/Input"; import { CheckRow } from "./ui/NativeControls"; import { LoadingState, StateMessage } from "./ui/QueryState"; import { SURFACE } from "./ui/surfaces"; import UserPicker from "./UserPicker"; -// The admin "System" tab — storage overview, per-user footprint, quota editor, and the global job -// list. Split out of DownloadCenter (self-contained, admin-only surface). +// The admin Users page's "Storage" tab: the disk in three numbers, every user's footprint against +// their own limit (the quota editor), and every user's downloads. Both lists are tables with aligned +// columns and one menu per row (its "…", a right-click, a long-press, Shift+F10). const GiB = 1024 ** 3; @@ -173,18 +183,201 @@ function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string } ); } - -export function AdminSystem() { +export function StorageTab() { const { t, i18n } = useTranslation(); const lang = i18n.language; + const qc = useQueryClient(); + const confirm = useConfirm(); + const copy = useCopyItems(); const storage = useLiveQuery(qk.adminStorage(), api.adminDownloadStorage, { intervalMs: 5000 }); const jobs = useLiveQuery(qk.adminDownloads(), api.adminDownloads, { intervalMs: 5000 }); const [quotaFor, setQuotaFor] = useState<{ id: number; email: string } | null>(null); const s = storage.data; + + const reset = useMutation({ + mutationFn: (userId: number) => api.adminResetDownloadQuota(userId), + onSuccess: (_d, userId) => { + void qc.invalidateQueries({ queryKey: qk.dlQuota(userId) }); + void qc.invalidateQueries({ queryKey: qk.adminStorage() }); + }, + meta: { failTitle: "errors.writeFailed.downloadQuotaReset" }, + }); + const onReset = async (u: AdminStorageUser) => { + const ok = await confirm({ + title: t("downloads.admin.resetTitle"), + message: t("downloads.admin.resetBody", { email: u.email ?? "" }), + confirmLabel: t("downloads.admin.reset"), + }); + if (ok) reset.mutate(u.user_id); + }; + + // --- every user ------------------------------------------------------------------------------ + const userMenu = useContextMenu({ keyOf: (u) => u.user_id }); + const userItems = (u: AdminStorageUser): ActionMenuItem[] => [ + { + id: "quota", + label: t("downloads.admin.editQuota"), + icon: , + onSelect: () => setQuotaFor({ id: u.user_id, email: u.email ?? "" }), + }, + { + id: "reset", + label: t("downloads.admin.reset"), + icon: , + onSelect: () => void onReset(u), + }, + ]; + // The bar sits beside the name and the number after it: a right-aligned "Used" before a + // left-aligned bar ran the two headers together into one phrase. + const userColumns: Column[] = [ + { + key: "user", + header: t("downloads.admin.columns.user"), + cardPrimary: true, + sortable: true, + sortValue: (u) => u.email ?? "", + render: (u) => {u.email}, + }, + { + key: "quota", + header: t("downloads.admin.columns.quota"), + width: "9rem", + sortable: true, + sortValue: (u) => (u.unlimited || !u.max_bytes ? -1 : u.footprint_bytes / u.max_bytes), + render: (u) => , + }, + { + key: "used", + header: t("downloads.admin.columns.used"), + width: "6.5rem", + align: "right", + sortable: true, + sortValue: (u) => u.footprint_bytes, + render: (u) => {formatBytes(u.footprint_bytes, lang)}, + }, + { + key: "files", + header: t("downloads.admin.columns.files"), + width: "4.5rem", + align: "right", + sortable: true, + sortValue: (u) => u.files, + render: (u) => {u.files}, + }, + { + key: "menu", + header: "", + width: "3rem", + align: "right", + cardLabel: false, + cardRow: true, + render: (u) => ( + + ), + }, + ]; + + // --- every download ---------------------------------------------------------------------------- + const jobMenu = useContextMenu({ keyOf: (j) => j.id }); + const jobItems = (j: DownloadJob): ActionMenuItem[] => { + const source = j.source_url; + const owner = j.user_id; + return menuGroups( + source + ? [ + { + id: "open", + label: t("downloads.admin.openSource"), + icon: , + // A new tab only opens inside the click (a popup blocker refuses one a task later). + inGesture: true, + onSelect: () => void window.open(source, "_blank", "noopener,noreferrer"), + }, + ] + : [], + copy(jobTitle(j), source), + owner != null + ? [ + { + id: "quota", + label: t("downloads.admin.editUserQuota"), + icon: , + onSelect: () => setQuotaFor({ id: owner, email: j.user_email ?? "" }), + }, + ] + : [], + ); + }; + const jobColumns: Column[] = [ + { + key: "title", + header: t("downloads.admin.columns.title"), + cardPrimary: true, + sortable: true, + sortValue: (j) => jobTitle(j), + render: (j) => ( + + {jobTitle(j)} + + ), + }, + { + key: "user", + header: t("downloads.admin.columns.user"), + width: "11rem", + sortable: true, + sortValue: (j) => j.user_email ?? "", + narrowHidden: true, + render: (j) => {j.user_email}, + }, + { + key: "status", + header: t("downloads.admin.columns.status"), + width: "7rem", + sortable: true, + sortValue: (j) => j.status, + render: (j) => , + }, + { + key: "size", + header: t("downloads.admin.columns.size"), + width: "6rem", + align: "right", + sortable: true, + sortValue: (j) => j.asset?.size_bytes ?? 0, + render: (j) => ( + + {j.asset?.size_bytes ? formatBytes(j.asset.size_bytes, lang) : "—"} + + ), + }, + { + key: "menu", + header: "", + width: "3rem", + align: "right", + cardLabel: false, + cardRow: true, + render: (j) => ( + + ), + }, + ]; + return (
{s && ( -
+
{[ [t("downloads.admin.filesOnDisk"), String(s.files_on_disk)], [t("downloads.admin.totalSize"), formatBytes(s.total_bytes, lang)], @@ -195,65 +388,103 @@ export function AdminSystem() { ].map(([label, val]) => (
{label}
-
{val}
+
{val}
))}
)} - {s && ( -
-
-
{t("downloads.admin.perUser")}
- {/* Set a quota for any user, regardless of whether they've downloaded anything. */} - -
-
- {s.per_user.map((u) => ( -
- {u.email} - {formatBytes(u.footprint_bytes, lang)} - setQuotaFor({ id: u.user_id, email: u.email ?? "" })} - label={t("downloads.admin.editQuota")} - > - - -
- ))} - {s.per_user.length === 0 && ( -
{t("downloads.admin.noFootprint")}
- )} -
+
+ {/* Set a quota for any user, regardless of whether they've downloaded anything. */} +
+
- )} - -
{t("downloads.tabs.system")}
-
- {(jobs.data ?? []).map((j) => ( -
- {jobTitle(j)} - {j.user_email} - - - {j.asset?.size_bytes ? formatBytes(j.asset.size_bytes, lang) : ""} - + {storage.isError && !s ? ( + void storage.refetch()}> + {t("common.loadError")} + + ) : !s ? ( + + ) : ( +
+ String(u.user_id)} + pageSize={25} + keyboardNav + fixedLayout + rowProps={(u) => userMenu.triggerProps(u)} + emptyText={t("downloads.admin.noFootprint")} + />
- ))} - {jobs.data && jobs.data.length === 0 && ( -
{t("downloads.empty.admin")}
)} -
+
+
+ {jobs.isError && !jobs.data ? ( + void jobs.refetch()}> + {t("common.loadError")} + + ) : !jobs.data ? ( + + ) : ( +
+ String(j.id)} + pageSize={25} + keyboardNav + fixedLayout + rowProps={(j) => jobMenu.triggerProps(j)} + emptyText={t("downloads.empty.admin")} + /> +
+ )} +
+ + + {quotaFor && ( setQuotaFor(null)} /> )}
); } + +/** How full a user is against their own limit: a bar, and the limit under it. */ +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/downloadModals.tsx b/frontend/src/components/downloadModals.tsx index f4078b49..ae264ce9 100644 --- a/frontend/src/components/downloadModals.tsx +++ b/frontend/src/components/downloadModals.tsx @@ -10,8 +10,8 @@ import { IconBtn } from "./downloadRow"; import { failCopyKeys, failTitleKey } from "../lib/downloadFailure"; import { Input } from "./ui/Input"; -// Per-item user modals split out of DownloadCenter (self-contained, prop-driven): rename/metadata -// editing and the failure-detail window. +// Per-item user modals (self-contained, prop-driven): a library file's details (Files) and a failed +// download's reasons (the tray). // Edit a library file's details: its channel (name + link) and extra reference links. Display-only // overrides — the on-disk file is never touched. Its name is renamed in place in the Files list. diff --git a/frontend/src/components/downloadRow.dom.test.tsx b/frontend/src/components/downloadRow.dom.test.tsx index deba7974..f92cc770 100644 --- a/frontend/src/components/downloadRow.dom.test.tsx +++ b/frontend/src/components/downloadRow.dom.test.tsx @@ -1,11 +1,9 @@ // @vitest-environment jsdom // -// Library-row metadata: the completed row surfaces the concrete container/format alongside the -// already-shown size. The format chip reads the raw `asset.container` (uppercased in the UI), so it must -// render when present and be absent when the asset has no container yet (queued/running before the worker -// fills it). i18n is key-passthrough here. +// A job's picture (the Files list and grid): with none at all, a quiet glyph of what it is; a remote +// thumbnail that fails falls back to the same glyph. i18n is key-passthrough here. import { describe, it, expect, afterEach, vi } from "vitest"; -import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import { render, cleanup, fireEvent } from "@testing-library/react"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); @@ -15,7 +13,7 @@ vi.mock("react-i18next", async (importOriginal) => { }; }); -import { DownloadRow, IconBtn } from "./downloadRow"; +import { JobThumb } from "./downloadRow"; import type { DownloadJob } from "../lib/api"; const baseJob: DownloadJob = { @@ -82,201 +80,15 @@ const withAsset = ( afterEach(cleanup); -describe("DownloadRow format chip", () => { - it("shows the uppercased container next to the size when ready", () => { - render(); - // Raw container is stored lowercase; the row uppercases it via CSS, so the text node stays "mp4". - expect(screen.getByText(/·\s*mp4/i)).toBeTruthy(); - expect(screen.getByText(/142 MB/)).toBeTruthy(); - }); - - it("omits the format chip when the asset has no container yet", () => { - const { container } = render(); - expect(container.textContent).not.toMatch(/·\s*(mp4|m4a|webm)/i); - }); -}); - -// A generic uploaded file (media_kind='file') has no video frame and no timeline: the row -// shows a neutral file glyph instead of a thumbnail , and no duration chip (it stays media info -// like the container/size). Media rows keep their thumbnail path (an , or empty until a poster). -describe("DownloadRow file kind", () => { - it("renders a file glyph, not a thumbnail , for a generic file", () => { - const { container } = render( - , - ); - // The file glyph is an inline svg; no element is emitted for a file. - expect(container.querySelector("img")).toBeNull(); - expect(container.querySelector("svg")).not.toBeNull(); - // File info still reads (container + size), but there is no duration chip. - expect(screen.getByText(/·\s*pdf/i)).toBeTruthy(); - expect(screen.getByText(/2\.3 MB/)).toBeTruthy(); - }); - - it("keeps the thumbnail path (an when a poster exists) for a media asset", () => { - const { container } = render( - , - ); - expect(container.querySelector("img")).not.toBeNull(); - }); -}); - -describe("DownloadRow · keyboard", () => { - it("is a list item only when it is given a position", () => { - const { container, rerender } = render(); - expect(container.firstElementChild?.getAttribute("data-nav-index")).toBe("3"); - rerender(); - expect(container.firstElementChild?.hasAttribute("data-nav-index")).toBe(false); - }); - - it("marks the action that opens the row, and only that one", () => { - render( - - {}} title="play"> - p - - {}} title="delete"> - d - - , - ); - expect(screen.getByTitle("play").hasAttribute("data-nav-open")).toBe(true); - expect(screen.getByTitle("delete").hasAttribute("data-nav-open")).toBe(false); - }); - - // The row once resized its actions with a `[&_button]:size-8` override, which a save LINK in the - // same row escaped (28px beside 32px buttons). The size is the primitive's own rung, set by IconBtn. - it("sizes its actions through the primitive's rung, not a descendant override", () => { - const { container } = render( - - {}} title="play"> - p - - , - ); - expect(screen.getByTitle("play").className).toContain("size-8"); - expect(container.innerHTML).not.toContain("[&_button]"); - }); -}); - -// The row answers to its own width (a container query) and its meta line never wraps — on -// the iPad, zoomed in, its "18.9 / MB" pieces broke into a jumble (the owner, 2026-09-27). jsdom lays -// nothing out, so this holds the structure the layout rests on; the widths were measured in a browser. -describe("DownloadRow · narrow", () => { - it("keeps the meta line one cut line of inline text, not a flex row that wraps its pieces", () => { - const { container } = render( - , - ); - const meta = container.querySelector("[data-dl-meta]")!; - expect(meta.className.split(/\s+/)).toContain("truncate"); - expect(meta.className).not.toMatch(/(^|\s)(inline-)?flex(\s|$)/); - for (const el of meta.querySelectorAll("*")) - expect(el.className.toString()).not.toMatch(/(^|\s)(inline-)?flex(\s|$)/); - }); - - it("is its own size container and stacks below 40rem", () => { - const { container } = render(); - const row = container.firstElementChild as HTMLElement; - expect(row.className).toContain("@container/dlrow"); - const grid = row.firstElementChild as HTMLElement; - expect(grid.className).toContain( - "@max-[40rem]/dlrow:[grid-template-areas:'thumb_head''meta_meta''foot_foot''prog_prog']", - ); - }); - - it("stacked, puts the source link and the actions on ONE wrapping line, not a line each", () => { - const { container } = render( - - {}} title="play"> - x - - , - ); - const link = container.querySelector("a[href='https://example.com/v']")!; - const foot = screen.getByTitle("play").closest(".contents")!; - expect(foot.contains(link)).toBe(true); - for (const c of ["@max-[40rem]/dlrow:[grid-area:foot]", "@max-[40rem]/dlrow:flex-wrap"]) - expect(foot.className).toContain(c); - }); - - it("gives the failure link a real 24px line on a mouse, not the text's 20 (DESIGN B1)", () => { - render( - {}} />, - ); - const link = screen.getByTitle("downloads.errors.fail.title"); - const cls = link.className.split(/\s+/); - expect(cls).toEqual(expect.arrayContaining(["inline-block", "min-h-6", "leading-6"])); - }); - - it("draws nothing for an empty link list (a bare 0 once leaked into the row)", () => { - const { container } = render(); - const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT); - const texts: string[] = []; - while (walker.nextNode()) texts.push(walker.currentNode.textContent ?? ""); - expect(texts).not.toContain("0"); - }); -}); - -// A link queued (or failed) before its details: the title, the channel line and the link line all -// fell back to the same URL (the owner, 2026-09-27). Now the title IS the short link, once. -describe("DownloadRow · a link with no details yet", () => { - const bare: DownloadJob = { - ...baseJob, - status: "error", - source_kind: "url", - source_ref: "https://www.facebook.com/watch?v=12345", - source_url: "https://www.facebook.com/watch?v=12345", - display_name: null, - asset: null, - }; - - it("shows the short link once, as the title's link, with no channel line", () => { - const { container } = render(); - const links = [...container.querySelectorAll("a")].filter((a) => a.href === bare.source_url); - expect(links.map((a) => a.textContent)).toEqual(["www.facebook.com/watch?v=12345"]); - expect(container.textContent).not.toContain("https://"); - }); - - // Measured at 21px on the Downloads page: every link in the row stands on a 24px line on - // a mouse (DESIGN B1), as the link line and the failure link already did. - it("gives the title link and the channel link a 24px line", () => { - const { container } = render( - , - ); - const title = [...container.querySelectorAll("a")].find((a) => a.href === bare.source_url)!; - const channel = container.querySelector("a[href='https://example.com/c']")!; - // inline-block: an inline link's box is the font's height (21px) whatever its line height. - for (const a of [title, channel]) - expect(a.className.split(/\s+/)).toEqual( - expect.arrayContaining(["inline-block", "leading-6"]), - ); - }); - - it("keeps the named row as it was: title text, and the link on its own line", () => { - const named = { ...bare, display_name: "A clip" }; - const { container } = render(); - expect(screen.getByText("A clip").closest("a")).toBeNull(); - const links = [...container.querySelectorAll("a")].filter((a) => a.href === bare.source_url); - expect(links.map((a) => a.textContent)).toEqual(["www.facebook.com/watch?v=12345"]); - }); -}); - // No picture at all: a quiet glyph of what it is instead of an empty box (the owner, 2026-09-27). -describe("DownloadRow · no picture", () => { +describe("JobThumb · no picture", () => { it("draws a film glyph for a video, a note for audio", () => { - const { container, unmount } = render(); + const { container, unmount } = render(); expect(container.querySelector("img")).toBeNull(); expect(container.querySelector("svg.lucide-film")).not.toBeNull(); unmount(); const { container: audio } = render( - , + , ); expect(audio.querySelector("svg.lucide-music")).not.toBeNull(); }); @@ -284,7 +96,7 @@ describe("DownloadRow · no picture", () => { // failed too (a dead CDN link), the row drew the browser's broken-image mark. it("falls back to the glyph when the remote picture fails too", () => { const { container } = render( - , + , ); const img = container.querySelector("img")!; expect(img.getAttribute("src")).toBe("https://cdn.example/dead.jpg"); @@ -293,27 +105,3 @@ describe("DownloadRow · no picture", () => { expect(container.querySelector("svg.lucide-film")).not.toBeNull(); }); }); - -// The phone-safe conversion reports ffmpeg's real progress, so it gets the % bar a download has; -// a yt-dlp post-step reports none and keeps the pulse. -describe("DownloadRow · a running conversion", () => { - const running = (phase: string): DownloadJob => ({ - ...baseJob, - status: "running", - progress: 40, - phase, - }); - - it("shows its phase and its %", () => { - const { container } = render(); - expect(screen.getByText("downloads.phase.converting")).toBeTruthy(); - expect(screen.getByText("40%")).toBeTruthy(); - expect(container.querySelector(".animate-pulse")).toBeNull(); - }); - - it("a post-step with no % keeps the pulse", () => { - const { container } = render(); - expect(screen.queryByText("40%")).toBeNull(); - expect(container.querySelector(".animate-pulse")).not.toBeNull(); - }); -}); diff --git a/frontend/src/components/downloadRow.tsx b/frontend/src/components/downloadRow.tsx index 7dfb086a..f734b8e6 100644 --- a/frontend/src/components/downloadRow.tsx +++ b/frontend/src/components/downloadRow.tsx @@ -1,27 +1,14 @@ import { useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import { CalendarClock, File, Film, Link2, Music, Scissors } from "lucide-react"; +import { File, Film, Music } from "lucide-react"; import clsx from "clsx"; import { api, type DownloadJob } from "../lib/api"; -import { - formatBytes, - formatDateTime, - formatDuration, - formatEta, - formatSpeed, - relativeTime, -} from "../lib/format"; -import { navItemAttrs } from "../lib/listNav"; -import { failCopyKeys, failTitleKey } from "../lib/downloadFailure"; -import { displayUrl, hasOwnTitle, jobTitle } from "./downloadTitle"; import { IconButton } from "./ui/IconButton"; import { MediaProgress } from "./ui/MediaCard"; -import { Pressable } from "./ui/Pressable"; -import { SURFACE } from "./ui/surfaces"; -// Download-row presentation + the small shared primitives (status pill, icon button, the job's -// picture), split out of DownloadCenter (all purely prop-driven, no shared state). Rendered by the -// queue and the admin tab; the Files list borrows the picture. +// The small shared primitives of a download (status pill, icon button, the job's picture), all purely +// prop-driven: the admin Storage tab shows the status, the Files list and grid the picture, the +// details dialog the icon button. const STATUS_CLS: Record = { queued: "text-muted", @@ -32,10 +19,6 @@ const STATUS_CLS: Record = { canceled: "text-muted", }; -// Byte-progress phases (show a % bar) vs ffmpeg post-steps (no %, indeterminate pulse). -// "editing" (the phase-2 editor) and "converting" (the phone-safe conversion of a download or an -// upload) report a real % from ffmpeg's out_time, so they get a bar. -const DOWNLOAD_PHASES = ["video", "audio", "editing", "converting"]; const PHASE_KEYS = [ "video", "audio", @@ -143,16 +126,6 @@ export function JobThumb({ job, box = THUMB_BOX }: { job: DownloadJob; box?: str ); } -// The channel to show under the title: a user override wins over the auto-extracted asset value. -// `url` is null for sources without a channel link (e.g. reddit) — then it renders as plain text. No -// channel known yet → no line: the ref it once fell back to repeated the title (the owner, 2026-09-27). -function jobChannel(job: DownloadJob): { name: string | null; url: string | null } { - return { - name: job.display_uploader || job.asset?.uploader || null, - url: job.display_uploader_url || job.asset?.uploader_url || null, - }; -} - export function IconBtn({ onClick, title, @@ -179,202 +152,3 @@ export function IconBtn({ ); } - -// A link inside the row keeps the BROWSER's own right-click menu (open in a new tab, copy the -// address): the row's menu answers everywhere else on it. -const nativeMenu = { onContextMenu: (e: React.MouseEvent) => e.stopPropagation() }; - -// "Downloaded from" reference: opens the original page in a new tab. Its copy moved into the row's -// menu ("Copy link"); an extra link copies through the browser's own link menu. -function SourceRef({ url }: { url: string }) { - const { t } = useTranslation(); - return ( - - ); -} - -// Channel under the title — a clickable link when a channel URL is known, plain text otherwise. -function ChannelLine({ job }: { job: DownloadJob }) { - const { name, url } = jobChannel(job); - if (!name) return null; - if (url) { - return ( - - ); - } - return
{name}
; -} - -export function DownloadRow({ - job, - onShowError, - navIndex, - rowProps, - more, - children, -}: { - job: DownloadJob; - onShowError?: (job: DownloadJob) => void; - /** Its position in a keyboard-walkable list (lib/listNav). */ - navIndex?: number; - /** The list's menu triggers (right-click, long-press, Shift+F10): `useContextMenu().triggerProps`. */ - rowProps?: Record; - /** The row's "…", last in its action strip. */ - more?: ReactNode; - children?: ReactNode; -}) { - const { t, i18n } = useTranslation(); - const lang = i18n.language; - const running = job.status === "running"; - const linkTitle = !hasOwnTitle(job) && !!job.source_url; - const sourceLink = linkTitle ? null : job.source_url; - return ( -
-
-
- -
-
-
- {(job.job_kind === "edit" || job.job_kind === "clip") && ( - - {t("downloads.clipBadge")} - - )} - {linkTitle ? ( - // No name yet: the title IS the link, so the link line below leaves it out (it said the - // same URL a third time). - - {jobTitle(job)} - - ) : ( - jobTitle(job) - )} -
- -
- {/* The meta line never wraps (the owner, 2026-09-27: on the iPad its "18.9 / MB" pieces broke - into a jumble). One line, cut at its end, so the least telling part (the age) goes first. */} -
- - {job.asset?.container ? ( - // Concrete container (mp4 / m4a / webm …) — reads as the file format, and its audio-vs-video - // extension is the video/audio distinction the running-only phase pill can't show at rest. - · {job.asset.container} - ) : null} - {job.asset?.size_bytes ? ( - · {formatBytes(job.asset.size_bytes, lang)} - ) : null} - {job.asset?.duration_s ? ( - · {formatDuration(job.asset.duration_s)} - ) : null} - {job.status === "done" && job.created_at ? ( - - {" · "} - {" "} - {relativeTime(job.created_at, lang, t)} - - ) : null} - {job.status === "error" ? ( - <> - {" "} - onShowError?.(job)} - className="text-danger inline-block leading-6 min-h-6 touch-target pointer-coarse:leading-(--touch-target)" - title={t(failTitleKey(job))} - > - · {t(failCopyKeys(job, "short"))}{" "} - - {t("downloads.errors.fail.detailsLink")} - - - - ) : null} -
- {/* Wide, this wrapper is `contents`: the source and the actions are grid items of their own - (src under the meta, act down the right). Stacked, it is ONE wrapping line: the link and - the actions side by side while they fit, the actions under the link only when they don't - (at 375). On touch each is a 44px box, so separate lines stacked 44px gaps (the owner, - 2026-09-27: "what are those huge empty parts?"). */} -
- {(!!sourceLink || !!job.extra_links?.length) && ( -
- {sourceLink && } - {job.extra_links?.map((url) => ( - - ))} -
- )} - {/* The row's visible actions — its primary one and the save link — then its "…": the md - rung here (the row stands alone), the sm rung in the modals, one IconBtn for both. */} -
- {children} - {more} -
-
- {running && ( -
- {job.phase && !DOWNLOAD_PHASES.includes(job.phase) ? ( - // ffmpeg post-steps aren't byte-progress — show an indeterminate pulse, no %. -
-
-
- ) : ( - <> -
-
-
-
- {job.progress}% - {job.speed_bps ? {formatSpeed(job.speed_bps, lang)} : null} - {job.eta_s ? {formatEta(t, job.eta_s)} : null} -
- - )} -
- )} -
-
- ); -} diff --git a/frontend/src/components/downloadTitle.ts b/frontend/src/components/downloadTitle.ts index 105e321c..df212595 100644 --- a/frontend/src/components/downloadTitle.ts +++ b/frontend/src/components/downloadTitle.ts @@ -1,7 +1,7 @@ import type { DownloadJob } from "../lib/api"; // A download job's display title (a user rename wins over the extracted asset title), shared by the -// row, the admin list and the library player. Its own module so downloadRow exports only components. +// tray, the Files list, the admin Storage tab and the library player. // With no name yet — a link queued before its details arrive, or one that failed first — it is the // link itself, shortened the way the row shows a link, never the raw ref (a URL, or a bare video id). export function jobTitle(job: DownloadJob): string { @@ -12,13 +12,8 @@ export function jobTitle(job: DownloadJob): string { ); } -/** The job has a name of its own (a rename or an extracted title), not the link standing in for one. */ -export function hasOwnTitle(job: DownloadJob): boolean { - return !!(job.display_name || job.asset?.title); -} - // Compact display of a URL: drop the protocol + trailing slash so "youtube.com/watch?v=…" fits. -export function displayUrl(url: string): string { +function displayUrl(url: string): string { try { const u = new URL(url); return (u.host + u.pathname + u.search).replace(/\/$/, ""); diff --git a/frontend/src/components/listNav.wiring.contract.test.ts b/frontend/src/components/listNav.wiring.contract.test.ts index c34899a0..9d20ef98 100644 --- a/frontend/src/components/listNav.wiring.contract.test.ts +++ b/frontend/src/components/listNav.wiring.contract.test.ts @@ -20,7 +20,6 @@ const CARDS = [ "EpisodeCard", "PosterTile", "ShelfTile", - "DownloadRow", ]; const LISTS = ["NavGrid", "Carousel", "VirtualGrid"]; diff --git a/frontend/src/components/moduleRegistry.tsx b/frontend/src/components/moduleRegistry.tsx index f0129bc4..27945fcf 100644 --- a/frontend/src/components/moduleRegistry.tsx +++ b/frontend/src/components/moduleRegistry.tsx @@ -28,7 +28,6 @@ const PlexSidebar = lazyWithPreload(() => import("./PlexSidebar")); const NotificationsPanel = lazyWithPreload(() => import("./NotificationsPanel")); const Files = lazyWithPreload(() => import("./Files")); const FilesRail = lazyWithPreload(() => import("./FilesRail")); -const DownloadCenter = lazyWithPreload(() => import("./DownloadCenter")); const Stats = lazyWithPreload(() => import("./Stats")); const Scheduler = lazyWithPreload(() => import("./Scheduler")); const ConfigPanel = lazyWithPreload(() => import("./ConfigPanel")); @@ -57,7 +56,6 @@ export const PAGE_CONTENT: Record = { plex: { Component: PlexBrowse, maxWidth: "100rem" }, notifications: { Component: NotificationsPanel, maxWidth: "48rem" }, files: { Component: Files, maxWidth: "80rem" }, - downloads: { Component: DownloadCenter, maxWidth: "56rem" }, stats: { Component: Stats }, scheduler: { Component: Scheduler }, config: { Component: ConfigPanel }, diff --git a/frontend/src/components/navigation.dom.test.ts b/frontend/src/components/navigation.dom.test.ts index 380693a6..5306a247 100644 --- a/frontend/src/components/navigation.dom.test.ts +++ b/frontend/src/components/navigation.dom.test.ts @@ -69,13 +69,23 @@ describe("loadInitialPage precedence", () => { // whatever you happened to close the app on is precisely the habit the anti-paralysis surface // exists to replace, so the remembered page only wins once the account opts out. it("a bare URL lands on Home even when another module is remembered", () => { - stored[LS.page] = "downloads"; + stored[LS.page] = "plex"; expect(loadInitialPage()).toBe("home"); }); it("opting out restores the account's stored landing page", () => { + stored[LS.startOnHome] = START_ON_HOME.off; + stored[LS.page] = "plex"; + expect(loadInitialPage()).toBe("plex"); + }); + // The Downloads page is gone; what it held is in Files. A bookmark and a remembered page both land + // there instead of on the feed. + it("a retired page lands where its content went — linked or remembered", () => { + setUrl("?page=downloads"); + expect(loadInitialPage()).toBe("files"); + setUrl("/"); stored[LS.startOnHome] = START_ON_HOME.off; stored[LS.page] = "downloads"; - expect(loadInitialPage()).toBe("downloads"); + expect(loadInitialPage()).toBe("files"); }); it("opted out with no / invalid stored page still defaults to feed", () => { stored[LS.startOnHome] = START_ON_HOME.off; diff --git a/frontend/src/components/navigationInit.ts b/frontend/src/components/navigationInit.ts index abc8a2d9..2db279ca 100644 --- a/frontend/src/components/navigationInit.ts +++ b/frontend/src/components/navigationInit.ts @@ -2,7 +2,7 @@ // provider file keeps react-refresh happy (component-only exports). `loadInitialPage` picks the landing // module from a bookmark's params; `channelViewFrom` is the shared _chan/?chan resolver behind BOTH the // channelView seed and the onPop handler. -import { hasFilterParams, isPage, readPage, type Page } from "../lib/urlState"; +import { hasFilterParams, readPage, resolvePage, type Page } from "../lib/urlState"; import { getAccountRaw, LS } from "../lib/storage"; export const PAGE_KEY = LS.page; @@ -36,8 +36,7 @@ export function loadInitialPage(): Page { // replace. The account can opt out (Settings → Appearance → Startup) and get the remembered // module back. if (getAccountRaw(START_HOME_KEY) !== START_ON_HOME.off) return "home"; - const stored = getAccountRaw(PAGE_KEY); - return isPage(stored) ? stored : "feed"; + return resolvePage(getAccountRaw(PAGE_KEY)) ?? "feed"; } /** Resolve the open channel page from a landed history entry: the in-app `_chan` id (with its optional diff --git a/frontend/src/components/ui/DataTable.cards.dom.test.tsx b/frontend/src/components/ui/DataTable.cards.dom.test.tsx new file mode 100644 index 00000000..abf57aef --- /dev/null +++ b/frontend/src/components/ui/DataTable.cards.dom.test.tsx @@ -0,0 +1,46 @@ +// @vitest-environment jsdom +// +// Below `md` a table is a list of cards, its columns flowing one after another. A column marked +// `cardRow` takes a line of its own there — a row's lone "…" otherwise landed at the end of one +// card's line and on the next line of another. +import { describe, it, expect, afterEach, vi } from "vitest"; +import { render, cleanup, screen } from "@testing-library/react"; +import DataTable, { type Column } from "./DataTable"; + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), +})); + +const narrow = (matches: boolean) => + vi.stubGlobal( + "matchMedia", + (query: string) => + ({ + matches, + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + }) as unknown as MediaQueryList, + ); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +type Row = { id: string }; +const columns: Column[] = [ + { key: "name", header: "Name", cardPrimary: true, render: (r) => r.id }, + { key: "size", header: "Size", render: () => "1 MB" }, + { key: "menu", header: "", cardLabel: false, cardRow: true, render: () => }, +]; + +describe("DataTable cards", () => { + it("gives a cardRow column a line of its own; the others flow", () => { + narrow(false); + render( r.id} />); + expect(screen.getByText("more").parentElement!.className).toContain("basis-full"); + expect(screen.getByText("1 MB").parentElement!.className).not.toContain("basis-full"); + }); +}); diff --git a/frontend/src/components/ui/DataTable.pager.dom.test.tsx b/frontend/src/components/ui/DataTable.pager.dom.test.tsx new file mode 100644 index 00000000..49d7621a --- /dev/null +++ b/frontend/src/components/ui/DataTable.pager.dom.test.tsx @@ -0,0 +1,39 @@ +// @vitest-environment jsdom +// +// A short list that never outgrows a screen opts out of paging: every row, and no pager — not even +// the rows-per-page choice, which a paged table shows whenever it has rows. A size remembered for +// the same table does not bring paging back. +import { describe, it, expect, afterEach, vi } from "vitest"; +import { render, cleanup, screen } from "@testing-library/react"; +import DataTable, { type Column } from "./DataTable"; + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), +})); + +afterEach(() => { + cleanup(); + localStorage.clear(); +}); + +type Row = { id: string }; +const columns: Column[] = [{ key: "id", header: "Id", render: (r) => r.id }]; +const rows: Row[] = Array.from({ length: 12 }, (_, i) => ({ id: `row ${i + 1}` })); + +describe("DataTable pager", () => { + it("pages by default, rows-per-page shown", () => { + render( r.id} pageSize={10} />); + expect(screen.queryByText("row 11")).toBeNull(); + expect(screen.getByText("datatable.rowsPerPage")).toBeTruthy(); + }); + + it("pager={false} shows every row and no pager, whatever size was remembered", () => { + localStorage.setItem("t", JSON.stringify({ size: 10, page: 0 })); + render( + r.id} pager={false} persistKey="t" />, + ); + expect(screen.getByText("row 12")).toBeTruthy(); + expect(screen.queryByText("datatable.rowsPerPage")).toBeNull(); + }); +}); diff --git a/frontend/src/components/ui/DataTable.tsx b/frontend/src/components/ui/DataTable.tsx index b258c389..af8f6045 100644 --- a/frontend/src/components/ui/DataTable.tsx +++ b/frontend/src/components/ui/DataTable.tsx @@ -94,6 +94,7 @@ export default function DataTable({ rowKey, pageSize = 10, pageSizeOptions = [10, 25, 50, 100], + pager = true, persistKey, emptyText, rowClassName, @@ -119,6 +120,10 @@ export default function DataTable({ rowKey: (row: T) => string; pageSize?: number; pageSizeOptions?: number[]; + // A short list that never outgrows a screen (a user's download formats) has nothing to page: false + // shows every row and no pager — not even the rows-per-page choice, which there would only be + // clutter. It overrides `pageSize` and a persisted size, so the two cannot disagree. + pager?: boolean; persistKey?: string; emptyText?: string; rowClassName?: (row: T) => string; @@ -191,7 +196,8 @@ export default function DataTable({ const [internalFilters, setInternalFilters] = useState(initial.filters); const filters = controlledFilters ? (filtersProp ?? EMPTY_FILTERS) : internalFilters; const [page, setPage] = useState(initial.page); - const [size, setSize] = useState(initial.size ?? pageSize); + // 0 = "All": an unpaged table is one page of every row. + const [size, setSize] = useState(pager ? (initial.size ?? pageSize) : 0); const wide = useMediaQuery("(min-width: 48rem)"); const bodyRef = useRef(null); const cardsRef = useRef(null); @@ -315,20 +321,21 @@ export default function DataTable({ // Stays NULLABLE: the wrappers below decide whether to render a controls row at all, and an always // -truthy element would leave an empty `my-3` band (and an empty in-band PageToolbar) on a table // with nothing to page. The emptiness rule itself lives in Pager, so the two can't drift apart. - const pagerNode = hasPagerContent(sorted.length, size) ? ( - { - setSize(s); - setPage(0); - }} - sizeLabel={t("datatable.rowsPerPage")} - /> - ) : null; + const pagerNode = + pager && hasPagerContent(sorted.length, size) ? ( + { + setSize(s); + setPage(0); + }} + sizeLabel={t("datatable.rowsPerPage")} + /> + ) : null; const controls = controlsLeading != null || pagerNode ? ( @@ -424,7 +431,10 @@ export default function DataTable({ {columns .filter((c) => !c.hideInCard && !c.cardPrimary) .map((col) => ( - + {col.cardLabel !== false && ( {col.header} )} diff --git a/frontend/src/components/ui/pressableSelectable.contract.test.ts b/frontend/src/components/ui/pressableSelectable.contract.test.ts index 177962f7..0ee75241 100644 --- a/frontend/src/components/ui/pressableSelectable.contract.test.ts +++ b/frontend/src/components/ui/pressableSelectable.contract.test.ts @@ -45,7 +45,6 @@ const PLAIN: Record = { "UploadDialog.tsx": [1, "the drop zone"], "VideoEditor.tsx": [2, "timeline glyphs"], "Welcome.tsx": [1, "a screenshot (zoom)"], - "downloadRow.tsx": [1, "the error-details link"], "playlistColumns.tsx": [1, "the thumbnail"], "plexDetailUi.tsx": [2, "a switch row and the poster"], "plexViews.tsx": [1, "the rating filter (a number)"], diff --git a/frontend/src/i18n/locales/en/downloads.json b/frontend/src/i18n/locales/en/downloads.json index 16bdb39d..2bd0e537 100644 --- a/frontend/src/i18n/locales/en/downloads.json +++ b/frontend/src/i18n/locales/en/downloads.json @@ -1,5 +1,4 @@ { - "navLabel": "Downloads", "menu": { "download": "Download…" }, @@ -19,9 +18,7 @@ "manage": "Manage formats…" }, "page": { - "paste": "Paste link from clipboard", - "upload": "Upload", - "manage": "Manage formats…" + "paste": "Paste link from clipboard" }, "upload": { "title": "Upload a file", @@ -37,10 +34,6 @@ "addedConverting": "File uploaded — converting it so it plays on any phone", "failed": "Couldn't upload the file" }, - "tabs": { - "queue": "Queue", - "system": "System" - }, "status": { "queued": "Queued", "running": "Downloading", @@ -69,7 +62,6 @@ "retry": "Retry", "download": "Save to my device", "play": "Play", - "rename": "Rename", "edit": "Edit / trim…", "redownload": "Re-download (rebuild file)", "removeShared": "Remove from my list" @@ -78,12 +70,8 @@ "unsupported": "Your browser can't play this file." }, "empty": { - "queue": "Nothing in the queue.", "admin": "No downloads on this instance yet." }, - "source": { - "open": "Open the original page in a new tab" - }, "edit": { "title": "Edit details", "channel": "Channel", @@ -157,7 +145,6 @@ "profiles": { "title": "Download formats", "builtin": "Built-in", - "yours": "Your formats", "new": "New format", "name": "Name", "namePlaceholder": "My format", @@ -175,7 +162,17 @@ "save": "Save", "create": "Create", "delete": "Delete", - "deleteConfirm": "Delete the format “{{name}}”?" + "deleteConfirm": "Delete the format “{{name}}”?", + "edit": "Edit", + "duplicate": "Duplicate", + "saves": "What it saves", + "rowMenu": "Format actions", + "short": { + "subs": "subtitles", + "chapters": "chapters", + "thumbnail": "thumbnail", + "sponsorblock": "SponsorBlock" + } }, "admin": { "filesOnDisk": "Files on disk", @@ -192,10 +189,27 @@ "reset": "Reset to default", "save": "Save", "quotaPickPlaceholder": "Set a user's quota…", - "noFootprint": "No downloads yet." + "noFootprint": "No downloads yet.", + "allDownloads": "Every download", + "columns": { + "user": "User", + "used": "Used", + "quota": "Of quota", + "files": "Files", + "title": "Title", + "status": "Status", + "size": "Size" + }, + "ofQuota": "of {{size}}", + "unlimitedShort": "Unlimited", + "userMenu": "User storage actions", + "jobMenu": "Download actions", + "openSource": "Open source", + "editUserQuota": "Edit this user's quota", + "resetTitle": "Reset the quota?", + "resetBody": "{{email}} goes back to the instance's default limits." }, "clipBadge": "Clip", - "rowMenu": "Download actions", "errors": { "quota": { "max_bytes": "You've used your download storage quota ({{size}} GB). Free up space first.", @@ -223,7 +237,6 @@ "title": "Couldn't download", "titleUpload": "The upload isn't ready", "titleEdit": "The clip isn't ready", - "detailsLink": "details", "detailsToggle": "Technical details", "upload": { "quota_exceeded": { diff --git a/frontend/src/i18n/locales/en/keys.json b/frontend/src/i18n/locales/en/keys.json index af882d3e..40e630af 100644 --- a/frontend/src/i18n/locales/en/keys.json +++ b/frontend/src/i18n/locales/en/keys.json @@ -28,7 +28,6 @@ "goPlex": "Go to Plex", "goNotifications": "Go to Notifications", "goFiles": "Go to Files", - "goDownloads": "Go to Downloads", "goStats": "Go to Stats", "goSettings": "Go to Settings", "search": "Jump to the search box", diff --git a/frontend/src/i18n/locales/en/nav.json b/frontend/src/i18n/locales/en/nav.json index 1146c97e..f8cc9142 100644 --- a/frontend/src/i18n/locales/en/nav.json +++ b/frontend/src/i18n/locales/en/nav.json @@ -14,7 +14,6 @@ "plex": "Plex", "notifications": "Notifications", "files": "Files", - "downloads": "Downloads", "stats": "Stats", "scheduler": "Scheduler", "config": "Config", diff --git a/frontend/src/i18n/locales/en/settings.json b/frontend/src/i18n/locales/en/settings.json index 64dd2144..9c3c097a 100644 --- a/frontend/src/i18n/locales/en/settings.json +++ b/frontend/src/i18n/locales/en/settings.json @@ -4,7 +4,8 @@ "appearance": "Appearance", "notifications": "Notifications", "account": "Account", - "diagnostics": "Diagnostics" + "diagnostics": "Diagnostics", + "downloads": "Downloads" }, "plexSync": { "title": "Plex watch sync", @@ -230,5 +231,9 @@ "clear": "Clear", "empty": "No event matches the filter.", "emptyAll": "No events yet." + }, + "downloads": { + "formats": "Download formats", + "formatsIntro": "The choices the download dialog offers. The built-in ones are fixed; duplicate one to make your own." } } diff --git a/frontend/src/i18n/locales/en/users.json b/frontend/src/i18n/locales/en/users.json index 11fec23c..78e8edd2 100644 --- a/frontend/src/i18n/locales/en/users.json +++ b/frontend/src/i18n/locales/en/users.json @@ -1,7 +1,8 @@ { "tabs": { "roles": "Users & roles", - "access": "Access requests" + "access": "Access requests", + "storage": "Storage" }, "pendingAccess": { "title": "Signed up, but still outside", diff --git a/frontend/src/i18n/locales/en/welcome.json b/frontend/src/i18n/locales/en/welcome.json index 32bbdc6c..bea41497 100644 --- a/frontend/src/i18n/locales/en/welcome.json +++ b/frontend/src/i18n/locales/en/welcome.json @@ -14,7 +14,7 @@ "channels": "Channels", "playlists": "Playlists", "player": "In-app player", - "downloads": "Downloads", + "files": "Your files", "search": "Search results", "plex": "Your Plex library" }, @@ -40,9 +40,9 @@ "title": "Watch in-app, resume anywhere", "body": "A built-in player picks each video up where you left off, with keyboard and scroll controls." }, - "downloads": { - "title": "Downloads", - "body": "Save videos to your server in a Plex-friendly layout, trim or join them, then share a link." + "files": { + "title": "Files", + "body": "Every file you keep in one library: paste a link anywhere to download it, trim or join a video, upload your own, then share a link that plays on any phone." }, "plex": { "title": "Your Plex library too", diff --git a/frontend/src/i18n/locales/hu/downloads.json b/frontend/src/i18n/locales/hu/downloads.json index 39e12648..85da3a2c 100644 --- a/frontend/src/i18n/locales/hu/downloads.json +++ b/frontend/src/i18n/locales/hu/downloads.json @@ -1,5 +1,4 @@ { - "navLabel": "Letöltések", "menu": { "download": "Letöltés…" }, @@ -19,9 +18,7 @@ "manage": "Formátumok kezelése…" }, "page": { - "paste": "Link beillesztése a vágólapról", - "upload": "Feltöltés", - "manage": "Formátumok kezelése…" + "paste": "Link beillesztése a vágólapról" }, "upload": { "title": "Fájl feltöltése", @@ -37,10 +34,6 @@ "addedConverting": "Fájl feltöltve — átalakítjuk, hogy minden telefonon lejátszható legyen", "failed": "Nem sikerült feltölteni a fájlt" }, - "tabs": { - "queue": "Sor", - "system": "Rendszer" - }, "status": { "queued": "Várakozik", "running": "Letöltés", @@ -69,7 +62,6 @@ "retry": "Újra", "download": "Mentés a gépemre", "play": "Lejátszás", - "rename": "Átnevezés", "edit": "Szerkesztés / vágás…", "redownload": "Újraletöltés (fájl újraépítése)", "removeShared": "Eltávolítás a listámból" @@ -78,12 +70,8 @@ "unsupported": "A böngésződ nem tudja lejátszani ezt a fájlt." }, "empty": { - "queue": "A sor üres.", "admin": "Ezen a példányon még nincs letöltés." }, - "source": { - "open": "Eredeti oldal megnyitása új lapon" - }, "edit": { "title": "Adatok szerkesztése", "channel": "Csatorna", @@ -157,7 +145,6 @@ "profiles": { "title": "Letöltési formátumok", "builtin": "Beépített", - "yours": "Saját formátumaid", "new": "Új formátum", "name": "Név", "namePlaceholder": "Saját formátum", @@ -175,7 +162,17 @@ "save": "Mentés", "create": "Létrehozás", "delete": "Törlés", - "deleteConfirm": "Törlöd a(z) „{{name}}” formátumot?" + "deleteConfirm": "Törlöd a(z) „{{name}}” formátumot?", + "edit": "Szerkesztés", + "duplicate": "Másolat készítése", + "saves": "Mit ment", + "rowMenu": "Formátum-műveletek", + "short": { + "subs": "felirat", + "chapters": "fejezetek", + "thumbnail": "bélyegkép", + "sponsorblock": "SponsorBlock" + } }, "admin": { "filesOnDisk": "Fájlok a lemezen", @@ -192,10 +189,27 @@ "reset": "Visszaállítás alapértékre", "save": "Mentés", "quotaPickPlaceholder": "Kvóta beállítása egy felhasználónak…", - "noFootprint": "Még nincs letöltés." + "noFootprint": "Még nincs letöltés.", + "allDownloads": "Minden letöltés", + "columns": { + "user": "Felhasználó", + "used": "Foglalt", + "quota": "A kvótából", + "files": "Fájlok", + "title": "Cím", + "status": "Állapot", + "size": "Méret" + }, + "ofQuota": "{{size}}-ból", + "unlimitedShort": "Korlátlan", + "userMenu": "Tárhely-műveletek", + "jobMenu": "Letöltés-műveletek", + "openSource": "Forrás megnyitása", + "editUserQuota": "A felhasználó kvótájának szerkesztése", + "resetTitle": "Visszaállítod a kvótát?", + "resetBody": "{{email}} visszakapja a példány alapértelmezett korlátait." }, "clipBadge": "Klip", - "rowMenu": "Letöltés műveletei", "errors": { "quota": { "max_bytes": "Elfogyott a letöltési tárhelykereted ({{size}} GB). Előbb szabadíts fel helyet.", @@ -223,7 +237,6 @@ "title": "Nem sikerült letölteni", "titleUpload": "A feltöltés nem készült el", "titleEdit": "A klip nem készült el", - "detailsLink": "részletek", "detailsToggle": "Technikai részletek", "upload": { "quota_exceeded": { diff --git a/frontend/src/i18n/locales/hu/keys.json b/frontend/src/i18n/locales/hu/keys.json index e4f9a689..d6282014 100644 --- a/frontend/src/i18n/locales/hu/keys.json +++ b/frontend/src/i18n/locales/hu/keys.json @@ -28,7 +28,6 @@ "goPlex": "Ugrás: Plex", "goNotifications": "Ugrás: Értesítések", "goFiles": "Ugrás a Fájlokhoz", - "goDownloads": "Ugrás: Letöltések", "goStats": "Ugrás: Statisztika", "goSettings": "Ugrás: Beállítások", "search": "Ugrás a keresőbe", diff --git a/frontend/src/i18n/locales/hu/nav.json b/frontend/src/i18n/locales/hu/nav.json index 1c4391ac..1300d07a 100644 --- a/frontend/src/i18n/locales/hu/nav.json +++ b/frontend/src/i18n/locales/hu/nav.json @@ -14,7 +14,6 @@ "plex": "Plex", "notifications": "Értesítések", "files": "Fájlok", - "downloads": "Letöltések", "stats": "Statisztika", "scheduler": "Ütemező", "config": "Konfig", diff --git a/frontend/src/i18n/locales/hu/settings.json b/frontend/src/i18n/locales/hu/settings.json index bcfada78..4be96686 100644 --- a/frontend/src/i18n/locales/hu/settings.json +++ b/frontend/src/i18n/locales/hu/settings.json @@ -4,7 +4,8 @@ "appearance": "Megjelenés", "notifications": "Értesítések", "account": "Fiók", - "diagnostics": "Diagnosztika" + "diagnostics": "Diagnosztika", + "downloads": "Letöltések" }, "plexSync": { "title": "Plex nézettség-szinkron", @@ -230,5 +231,9 @@ "clear": "Ürítés", "empty": "Nincs a szűrésnek megfelelő esemény.", "emptyAll": "Még nincs esemény." + }, + "downloads": { + "formats": "Letöltési formátumok", + "formatsIntro": "Ezek közül választhatsz a letöltés ablakában. A beépítettek nem módosíthatók; másold le valamelyiket, ha sajátot szeretnél." } } diff --git a/frontend/src/i18n/locales/hu/users.json b/frontend/src/i18n/locales/hu/users.json index 1e4330fe..57b6666e 100644 --- a/frontend/src/i18n/locales/hu/users.json +++ b/frontend/src/i18n/locales/hu/users.json @@ -1,7 +1,8 @@ { "tabs": { "roles": "Felhasználók és szerepek", - "access": "Hozzáférési kérelmek" + "access": "Hozzáférési kérelmek", + "storage": "Tárhely" }, "pendingAccess": { "title": "Regisztrált, de még kívül van", diff --git a/frontend/src/i18n/locales/hu/welcome.json b/frontend/src/i18n/locales/hu/welcome.json index 568176e8..39c46901 100644 --- a/frontend/src/i18n/locales/hu/welcome.json +++ b/frontend/src/i18n/locales/hu/welcome.json @@ -14,7 +14,7 @@ "channels": "Csatornák", "playlists": "Lejátszási listák", "player": "Beépített lejátszó", - "downloads": "Letöltések", + "files": "A fájljaid", "search": "Keresési találatok", "plex": "A Plex-könyvtárad" }, @@ -40,9 +40,9 @@ "title": "Nézd appon belül, folytasd bárhonnan", "body": "A beépített lejátszó onnan folytatja a videót, ahol abbahagytad — billentyű- és görgetés-vezérléssel." }, - "downloads": { - "title": "Letöltések", - "body": "Ments videókat a szerveredre Plex-barát elrendezésben, vágd vagy fűzd össze őket, majd oszd meg egy linkkel." + "files": { + "title": "Fájlok", + "body": "Minden megtartott fájlod egy könyvtárban: illessz be bárhová egy linket, és letölti; vágj vagy fűzz össze videót, tölts fel sajátot, majd ossz meg olyan linket, ami bármelyik telefonon lejátszható." }, "plex": { "title": "A Plex-könyvtárad is", diff --git a/frontend/src/lib/api/downloads.ts b/frontend/src/lib/api/downloads.ts index a12d4586..da3aeaee 100644 --- a/frontend/src/lib/api/downloads.ts +++ b/frontend/src/lib/api/downloads.ts @@ -112,6 +112,8 @@ export interface DownloadJob { expires_at: string | null; asset: DownloadAsset | null; shared?: boolean; + // Whose it is — only the admin Storage tab's listing of every user's downloads carries them. + user_id?: number; user_email?: string; // Where the viewer stopped in it (whole seconds; 0 = from the start). Only the two library listings // (`downloads`, `downloadsShared`) carry it — a job a mutation returns does not. @@ -147,12 +149,23 @@ export interface ShareLink { // languages is exactly the drift that CLAUDE.md's comment-vs-code rule is about. export const DISPLAY_NAME_MAX = 255; +/** One user's row on the admin Storage tab: their footprint against their own limit, and how many + * files their Files lists. */ +export interface AdminStorageUser { + user_id: number; + email: string | null; + footprint_bytes: number; + files: number; + max_bytes: number; + unlimited: boolean; +} + export interface AdminStorage { files_on_disk: number; total_bytes: number; total_assets: number; total_cap_bytes: number; - per_user: { user_id: number; email: string | null; footprint_bytes: number }[]; + per_user: AdminStorageUser[]; } export interface AdminDownloadQuota { diff --git a/frontend/src/lib/downloadQueue.ts b/frontend/src/lib/downloadQueue.ts index f59a605d..6e6133a6 100644 --- a/frontend/src/lib/downloadQueue.ts +++ b/frontend/src/lib/downloadQueue.ts @@ -4,9 +4,9 @@ import { createStore } from "./store"; // The download queue as this tab has watched it: which jobs it has seen finish. The tray shows them // ("Done · Open") until it is closed, and announces each once (the done notice). // -// Watched where the queue is FETCHED, not in a component's effect: every reader of the queue (the -// tray, the Downloads page) fetches through `fetchQueue`, so a finish is seen once whichever of them -// polled, and nothing has to diff the previous render's data. +// Watched where the queue is FETCHED, not in a component's effect: every reader of the queue fetches +// through `fetchQueue`, so a finish is seen once whichever of them polled, and nothing has to diff the +// previous render's data. /** The statuses a job leaves on its own: it is waiting, or working. */ export const IN_FLIGHT: ReadonlySet = new Set(["queued", "running", "paused"]); @@ -14,6 +14,13 @@ export const IN_FLIGHT: ReadonlySet = new Set(["queued", "running", "pau let lastSeen: Map | null = null; const finished = createStore([]); const unannounced = createStore([]); +const revealing = createStore(null); + +/** A job to show in the tray, asked for at `at` (ms). */ +export interface Reveal { + id: number; + at: number; +} /** Note a fresh queue: a job that is done now and was not on the last look has just finished. The * first look only learns what is there — a file finished before this tab opened is old news. */ @@ -51,9 +58,21 @@ export function takeUnannounced(): QueueJob[] { return list; } +/** "View" on a job just started (a download, a clip, an upload still converting): the tray opens on + * its row — unfolded, back if it was closed, the keyboard on the row. One the tray no longer lists + * because it is done already opens as a file. */ +export const showInTray = (id: number): void => revealing.set({ id, at: Date.now() }); + +/** The pending "View", until the tray has answered it. */ +export const useRevealing = (): Reveal | null => revealing.useValue(); + +/** The tray answered the "View" (or the job is not in the queue at all). */ +export const doneRevealing = (): void => revealing.set(null); + /** Forget what was watched (a test starts from a tab that has looked at nothing). */ export function resetQueueWatch(): void { lastSeen = null; finished.set([]); unannounced.set([]); + revealing.set(null); } diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index 11669904..9fe40ca6 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -45,12 +45,6 @@ export function formatBytes(bytes: number | null | undefined, lang: string): str return `${num} ${units[i]}`; } -/** Download speed from bytes/sec, e.g. "2.4 MB/s". */ -export function formatSpeed(bps: number | null | undefined, lang: string): string { - if (!bps) return ""; - return `${formatBytes(bps, lang)}/s`; -} - /** Relative-time tiers as [seconds-per-unit, Intl unit]. The unit is what the divisor produces, * and Intl.RelativeTimeFormat turns the pair into the localized words — so a divisor can no * longer be paired with the wrong label, which is exactly what shifted every tier by one from diff --git a/frontend/src/lib/keymap.sequence.test.ts b/frontend/src/lib/keymap.sequence.test.ts index 7d43cb1c..03ac5673 100644 --- a/frontend/src/lib/keymap.sequence.test.ts +++ b/frontend/src/lib/keymap.sequence.test.ts @@ -84,7 +84,7 @@ describe("two-step sequences", () => { dispatch(press({ key: "g" }), "page", pageHandlers(calls)); dispatch(press({ key: "Shift", shiftKey: true }), "page", pageHandlers(calls)); dispatch(press({ key: "d" }), "page", pageHandlers(calls)); - expect(calls).toEqual(["goDownloads"]); + expect(calls).toEqual(["goFiles"]); }); // No second step needs a modifier (see the page-level rows below), so a chord with one is the reader changing their mind: it diff --git a/frontend/src/lib/keymap.ts b/frontend/src/lib/keymap.ts index 5bf8219f..22409639 100644 --- a/frontend/src/lib/keymap.ts +++ b/frontend/src/lib/keymap.ts @@ -117,14 +117,8 @@ export const SHORTCUTS = [ labelKey: "keys.goNotifications", page: "notifications", }, - { id: "goFiles", scope: "page", keys: ["g l"], labelKey: "keys.goFiles", page: "files" }, - { - id: "goDownloads", - scope: "page", - keys: ["g d"], - labelKey: "keys.goDownloads", - page: "downloads", - }, + // "g d" is the retired Downloads page's chord: what it held is in Files now, so it goes there too. + { id: "goFiles", scope: "page", keys: ["g l", "g d"], labelKey: "keys.goFiles", page: "files" }, { id: "goStats", scope: "page", keys: ["g s"], labelKey: "keys.goStats", page: "stats" }, { id: "goSettings", scope: "page", keys: ["g ,"], labelKey: "keys.goSettings", page: "settings" }, diff --git a/frontend/src/lib/modules.ts b/frontend/src/lib/modules.ts index 1937d084..9d727440 100644 --- a/frontend/src/lib/modules.ts +++ b/frontend/src/lib/modules.ts @@ -3,7 +3,6 @@ import { BarChart3, Bell, Clapperboard, - Download, Home, ListVideo, ScrollText, @@ -95,13 +94,6 @@ const MODULES: readonly ModuleDef[] = [ railKey: "nav.rail.files", icon: FolderOpen, }, - { - id: "downloads", - labelKey: "downloads.navLabel", - railKey: "nav.rail.downloads", - icon: Download, - hasBadge: true, - }, { id: "stats", labelKey: "header.account.stats", diff --git a/frontend/src/lib/nav.ts b/frontend/src/lib/nav.ts index c008b50b..e985bfdf 100644 --- a/frontend/src/lib/nav.ts +++ b/frontend/src/lib/nav.ts @@ -1,21 +1,6 @@ import type { Page } from "./urlState"; -// Tiny decoupled navigator so deep components (a feed card's download toast, the Download -// Center) can switch pages without threading setPage through the whole tree. NavigationProvider -// registers its setPage on mount; navigateTo is a no-op until then. -let _navigate: ((p: Page) => void) | null = null; - -export function setNavigator(fn: (p: Page) => void): void { - _navigate = fn; -} - -/** The "View" on a "Download added" toast is clicked INSIDE the player (its toast host). That is - * safe because `setPage` closes every open overlay before it pushes (see - * `lib/history.closeOverlaysThenNavigate`) — without it the player's Back-to-close cleanup popped the - * entry the navigation had just pushed. */ -export function navigateTo(p: Page): void { - _navigate?.(p); -} +// Page links: a page's own address, and whether a click on one is the app's to handle. /** A page's own address, for a link to it (the nav rail's rows). Only the page: the rest of a page's * state — the feed's filters, Plex's library — a fresh tab restores from the account's own storage, diff --git a/frontend/src/lib/pageKeys.dom.test.tsx b/frontend/src/lib/pageKeys.dom.test.tsx index 526b6c9c..07d4507d 100644 --- a/frontend/src/lib/pageKeys.dom.test.tsx +++ b/frontend/src/lib/pageKeys.dom.test.tsx @@ -22,7 +22,7 @@ afterEach(() => { vi.useRealTimers(); }); -const ALL: Page[] = ["home", "feed", "channels", "playlists", "plex", "downloads", "settings"]; +const ALL: Page[] = ["home", "feed", "channels", "playlists", "plex", "files", "settings"]; function Host({ onNavigate, @@ -61,6 +61,15 @@ describe("the g chords", () => { expect(nav.mock.calls).toEqual([["feed"], ["settings"]]); }); + // The retired Downloads page's chord still answers — where its content went. + it("g d, the old Downloads chord, goes to Files", () => { + const nav = vi.fn(); + render(); + key({ key: "g" }); + key({ key: "d" }); + expect(nav.mock.calls).toEqual([["files"]]); + }); + // `h` alone opens the sheet; the `h` of `g h` must not ALSO do that. it("take the h of g h away from the help sheet", () => { const nav = vi.fn(); diff --git a/frontend/src/lib/pageMeta.test.ts b/frontend/src/lib/pageMeta.test.ts index dff34685..67d4b80a 100644 --- a/frontend/src/lib/pageMeta.test.ts +++ b/frontend/src/lib/pageMeta.test.ts @@ -12,7 +12,6 @@ const EXPECTED: Record = { playlists: "header.account.playlists", notifications: "inbox.navLabel", files: "files.navLabel", - downloads: "downloads.navLabel", stats: "header.usageStats", plex: "plex.navLabel", scheduler: "header.scheduler", diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index 360e846e..9f4c4bed 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -26,6 +26,9 @@ export const LS = { // The queue the reader closed the tray on (job ids, a failure marked) — it stays shut until that // changes. Per-account mirror of the server pref. downloadTrayClosed: "siftlode.downloadTrayClosed", + // The download format used last — the download dialog opens on it. Per-account mirror of the + // server pref. + downloadProfile: "siftlode.downloadProfile", hints: "siftlode.hints", lang: "siftlode.lang", filters: "siftlode.filters", diff --git a/frontend/src/lib/tableColumns.ts b/frontend/src/lib/tableColumns.ts index e464eb28..e4e10cc7 100644 --- a/frontend/src/lib/tableColumns.ts +++ b/frontend/src/lib/tableColumns.ts @@ -37,6 +37,9 @@ export interface Column { cardPrimary?: boolean; hideInCard?: boolean; cardLabel?: boolean; + // …and `cardRow` gives the value a line of its own: a row's lone "…" flowed in after the facts, + // at the end of one card's line and on the next line of another — never in the same place twice. + cardRow?: boolean; // A column a table WITHOUT the card fallback (the playlist's drag-reorderable hybrid) leaves out // while the TABLE is narrower than 42rem: its fixed widths alone overran the box and left the title // no width. The table's own width, not the viewport's (an `@container` round it): beside the diff --git a/frontend/src/lib/urlState.ts b/frontend/src/lib/urlState.ts index 8dfdb02d..09a62083 100644 --- a/frontend/src/lib/urlState.ts +++ b/frontend/src/lib/urlState.ts @@ -132,20 +132,32 @@ const PAGES = [ "logs", "notifications", "files", - "downloads", "plex", ] as const; export type Page = (typeof PAGES)[number]; /** Narrow an arbitrary string to a known Page (else null). */ -export function isPage(p: string | null | undefined): p is Page { +function isPage(p: string | null | undefined): p is Page { return !!p && (PAGES as readonly string[]).includes(p); } +// A page this build no longer has, and the page that took its place. A bookmark, the remembered last +// page and an old history entry can still name one; all three resolve through `resolvePage`, so a +// retired page lands where its content went instead of on the feed. +const RETIRED: Readonly> = { + // The download queue became the tray; what it downloaded lives in Files. + downloads: "files", +}; + +/** A page id as stored or linked: a live page, a retired page's successor, else null. */ +export function resolvePage(p: string | null | undefined): Page | null { + if (isPage(p)) return p; + return p && Object.prototype.hasOwnProperty.call(RETIRED, p) ? RETIRED[p]! : null; +} + export function readPage(): Page { - const p = new URLSearchParams(window.location.search).get("page"); - return isPage(p) ? p : "feed"; + return resolvePage(new URLSearchParams(window.location.search).get("page")) ?? "feed"; } /** Build a URL (path + query + fragment) from the CURRENT location with `updates` applied to the query —