feat(files): the Downloads page goes — the tray, a Storage tab and a Downloads settings tab take its place

- The Downloads module, its rail icon and badge are gone. A bookmark, the remembered page and an old
  history entry that name it land on Files (urlState.resolvePage); `g d` goes to Files too.
- Users page: a Storage tab — the disk in numbers, every user's use against their own limit with
  their file count, every user's downloads; aligned tables, one menu per row (edit / reset quota,
  open source, copy, the owner's quota). The admin storage rows carry quota + file count, the admin
  job list its owner.
- Settings: a Downloads tab — the download formats in one list (duplicate a built-in, edit or delete
  your own) and the site sign-ins, moved from Account.
- "View" on an added download, a clip or a converting upload opens the tray on that row (unfolded,
  back if closed; a finished one opens as a file; under a player it opens without taking focus).
- The download dialog opens on the format used last.
- DataTable: `pager={false}` for a short list, `cardRow` to give a lone "…" its own card line.
- Welcome and README speak of Files; a new Files screenshot.
This commit is contained in:
2026-10-11 19:37:13 +02:00
parent 1c4f0042d2
commit 078194731b
69 changed files with 1553 additions and 1147 deletions
+1 -1
View File
@@ -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 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. 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 **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 (yt-dlp/ffmpeg), one PO-token sidecar. React 19 (with the React Compiler) + react-query frontend, with a
router-free navigation provider. router-free navigation provider.
+4 -3
View File
@@ -22,10 +22,11 @@ blocker and SponsorBlock keep working.
tags to slice the feed by. tags to slice the feed by.
- **Playlists with two-way YouTube sync** — build them locally, keep them in sync in both directions. - **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. - **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 - **Files** — one library for everything you keep: paste a link anywhere in the app to download it
presets, per-user storage quota), trim / crop / split & join them in a built-in editor, play them 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 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 - **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. 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. - **Multi-user** with per-user private state, a shared catalog, and a fair daily API-quota guard.
+24 -7
View File
@@ -842,6 +842,7 @@ def admin_list_downloads(
out = [] out = []
for j in jobs: for j in jobs:
data = serialize(j, assets.get(j.asset_id) if j.asset_id is not None else None) 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) data["user_email"] = emails.get(j.user_id)
out.append(data) out.append(data)
return out 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)) .where(DownloadJob.asset_id.is_not(None))
.group_by(DownloadJob.user_id) .group_by(DownloadJob.user_id)
).all() ).all()
emails = { uids = [uid for uid, _ in per_user]
u.id: u.email emails = {u.id: u.email for u in db.execute(select(User).where(User.id.in_(uids))).scalars()}
for u in db.execute(select(User).where(User.id.in_([uid for uid, _ in per_user]))).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 { return {
"files_on_disk": on_disk[0], "files_on_disk": on_disk[0],
"total_bytes": int(on_disk[1]), "total_bytes": int(on_disk[1]),
"total_assets": total_assets, "total_assets": total_assets,
"total_cap_bytes": sysconfig.get_int(db, "download_total_max_bytes"), "total_cap_bytes": sysconfig.get_int(db, "download_total_max_bytes"),
"per_user": [ "per_user": [row(uid) for uid in uids],
{"user_id": uid, "email": emails.get(uid), "footprint_bytes": quota.footprint(db, uid)}
for uid, _ in per_user
],
} }
+23
View File
@@ -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} 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( @pytest.mark.parametrize(
("job_status", "asset_status", "ref_count"), ("job_status", "asset_status", "ref_count"),
[ [
+5 -5
View File
@@ -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, - A machine with **Docker** and the **Docker Compose plugin** (Docker Desktop on Windows/macOS,
or Docker Engine on Linux). 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 stores media too — budget disk for whatever you download (it's bounded by per-user quotas you
set as admin). set as admin).
- Optional: a domain name + reverse proxy if you want HTTPS / public access (see below). - 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 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). `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 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 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 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 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 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 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 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. `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 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. metadata are in the database, the files themselves are not.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

+6 -1
View File
@@ -52,8 +52,10 @@ import { MoreButton } from "./ui/MoreButton";
import type { ActionMenuItem } from "./ui/ActionMenu"; import type { ActionMenuItem } from "./ui/ActionMenu";
import { Input } from "./ui/Input"; import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces"; 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 // 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. // 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 // (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"), label: t("users.tabs.access"),
badge: me.pending_invites + me.pending_activations, 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"; const active = tabs.some((x) => x.id === tab) ? tab : "roles";
return ( return (
@@ -99,6 +103,7 @@ export default function AdminUsers() {
<PendingAccess /> <PendingAccess />
</> </>
)} )}
{active === "storage" && <StorageTab />}
</div> </div>
</> </>
); );
@@ -70,13 +70,13 @@ afterEach(() => {
describe("BootGate", () => { describe("BootGate", () => {
it("a new tab with Startup off opens on the page the account left — never on Home first", async () => { 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.startOnHome.5", "0");
localStorage.setItem("siftlode.page.5", "downloads"); localStorage.setItem("siftlode.page.5", "plex");
vi.spyOn(api, "me").mockResolvedValue(account()); vi.spyOn(api, "me").mockResolvedValue(account());
mount(); mount();
expect((await screen.findByTestId("app")).textContent).toBe("downloads"); expect((await screen.findByTestId("app")).textContent).toBe("plex");
expect(renders.map((r) => r.page)).toEqual(renders.map(() => "downloads")); expect(renders.map((r) => r.page)).toEqual(renders.map(() => "plex"));
expect(new URLSearchParams(window.location.search).get("page")).toBe("downloads"); expect(new URLSearchParams(window.location.search).get("page")).toBe("plex");
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5"); // the tab is pinned 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. // 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"); sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "9");
localStorage.setItem("siftlode.startOnHome.5", "0"); localStorage.setItem("siftlode.startOnHome.5", "0");
localStorage.setItem("siftlode.page.5", "downloads"); localStorage.setItem("siftlode.page.5", "plex");
vi.spyOn(api, "me").mockResolvedValue(account()); vi.spyOn(api, "me").mockResolvedValue(account());
mount(); mount();
expect((await screen.findByTestId("app")).textContent).toBe("downloads"); expect((await screen.findByTestId("app")).textContent).toBe("plex");
expect(renders.map((r) => r.page)).toEqual(renders.map(() => "downloads")); expect(renders.map((r) => r.page)).toEqual(renders.map(() => "plex"));
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5"); expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5");
}); });
@@ -25,6 +25,8 @@ vi.mock("../lib/notifications", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/notifications")>(); const actual = await importOriginal<typeof import("../lib/notifications")>();
return { ...actual, notify: (n: unknown) => notify(n) }; return { ...actual, notify: (n: unknown) => notify(n) };
}); });
const showInTray = vi.fn();
vi.mock("../lib/downloadQueue", () => ({ showInTray: (id: number) => showInTray(id) }));
const notifyActionError = vi.fn(); const notifyActionError = vi.fn();
vi.mock("../lib/actionErrors", async (importOriginal) => { vi.mock("../lib/actionErrors", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/actionErrors")>(); const actual = await importOriginal<typeof import("../lib/actionErrors")>();
@@ -188,6 +190,11 @@ describe("ClipPanel", () => {
expect(notify).toHaveBeenCalledWith( expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "success", message: "clip.queued" }), 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 () => { it("sends no audio track for a YouTube clip", async () => {
+3 -3
View File
@@ -7,7 +7,7 @@ import { DISPLAY_NAME_MAX } from "../lib/api/downloads";
import { invalidateDownloads } from "../lib/downloads"; import { invalidateDownloads } from "../lib/downloads";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { notifyActionError } from "../lib/actionErrors"; import { notifyActionError } from "../lib/actionErrors";
import { navigateTo } from "../lib/nav"; import { showInTray } from "../lib/downloadQueue";
import { log } from "../lib/log"; import { log } from "../lib/log";
import { formatTimecode } from "../lib/timecode"; import { formatTimecode } from "../lib/timecode";
import { handleBounds, markIn, markOut, zoomWindow, type ClipRange } from "../lib/clipRange"; import { handleBounds, markIn, markOut, zoomWindow, type ClipRange } from "../lib/clipRange";
@@ -192,13 +192,13 @@ export default function ClipPanel({
if (busy) return; if (busy) return;
setBusy(true); setBusy(true);
try { try {
await api.enqueueClip(body); const job = await api.enqueueClip(body);
invalidateDownloads(qc); invalidateDownloads(qc);
log.info("clip.queued", "clip queued", { source: src.source, seconds: Math.round(length) }); log.info("clip.queued", "clip queued", { source: src.source, seconds: Math.round(length) });
notify({ notify({
level: "success", level: "success",
message: t("clip.queued"), message: t("clip.queued"),
action: { label: t("clip.view"), onClick: () => navigateTo("downloads") }, action: { label: t("clip.view"), onClick: () => showInTray(job.id) },
}); });
onClose(); onClose();
} catch (err) { } catch (err) {
@@ -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<typeof import("react-i18next")>();
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<typeof import("./ui/PageShell")>()),
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<HTMLElement>("[data-nav-index]")!
.querySelector<HTMLElement>('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(
<QueryClientProvider client={new QueryClient()}>
<DownloadCenter />
</QueryClientProvider>,
);
// 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(
<QueryClientProvider client={new QueryClient()}>
<DownloadCenter />
</QueryClientProvider>,
);
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(
<QueryClientProvider client={new QueryClient()}>
<DownloadCenter />
</QueryClientProvider>,
);
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",
]);
});
});
-199
View File
@@ -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<DownloadJob | null>(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<DownloadJob>({ 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: (
<MoreButton
label={t("downloads.rowMenu")}
size="md"
expanded={mine}
menuProps={rowMenu.buttonProps(job)}
className={mine ? undefined : "reveal"}
/>
),
};
};
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. */}
<PageToolbar>
<div className="px-4 pt-2 pb-2 max-w-4xl w-full mx-auto flex flex-wrap items-center justify-between gap-2">
<Tabs
idBase={panel.idBase}
tabs={tabs}
active={tab}
onChange={setTab}
className="flex flex-wrap gap-1"
/>
<Button
variant="ghost"
size="sm"
icon={<Settings2 />}
onClick={() => setManage(true)}
className="shrink-0"
>
{t("downloads.page.manage")}
</Button>
</div>
</PageToolbar>
<div {...panel.panelProps(tab)} className="px-4 pb-4 pt-3 max-w-4xl w-full mx-auto">
{tab === "queue" && (
<>
{/* The field takes the whole line when the buttons would squeeze it (at 375 it had 57px). */}
<div className="flex flex-wrap gap-2 mb-4">
<LinkBox className="flex-[1_1_16rem]" />
{/* Local upload is a peer to "add a URL": a general file → the same library. */}
<Button
variant="outline"
size="sm"
icon={<UploadCloud className="w-4 h-4" />}
onClick={() => setUpload(true)}
className="shrink-0"
>
{t("downloads.page.upload")}
</Button>
</div>
<NavGrid className="space-y-2">
{queue.map((job, i) => (
<DownloadRow
key={job.id}
job={job}
navIndex={i}
onShowError={setErrorJob}
{...rowMenuProps(job)}
>
{/* The queue's primary action stays in sight; cancel moved into the "…". */}
{job.status === "queued" || job.status === "running" ? (
<IconBtn
onClick={() => actions.run(job, "pause")}
title={t("downloads.actions.pause")}
>
<Pause className="w-4 h-4" />
</IconBtn>
) : (
<IconBtn
onClick={() => 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" ? (
<RefreshCw className="w-4 h-4" />
) : (
<Play className="w-4 h-4" />
)}
</IconBtn>
)}
</DownloadRow>
))}
{queue.length === 0 &&
(jobsQ.isError && !jobsQ.data ? (
<StateMessage tone="error" onRetry={() => jobsQ.refetch()}>
{t("common.loadError")}
</StateMessage>
) : jobsQ.isLoading ? (
<LoadingState shape="list" />
) : (
<div className="text-sm text-muted py-10 text-center">
{t("downloads.empty.queue")}
</div>
))}
</NavGrid>
</>
)}
{tab === "system" && me.role === "admin" && <AdminSystem />}
<ContextMenu menu={rowMenu} ariaLabel={t("downloads.rowMenu")} items={menuItems} />
<Suspense fallback={null}>
{upload && <UploadDialog onClose={() => setUpload(false)} />}
{manage && <ProfileEditor onClose={() => setManage(false)} />}
{errorJob && (
<ErrorModal
job={errorJob}
onRetry={() => actions.run(errorJob, "resume")}
onClose={() => setErrorJob(null)}
/>
)}
</Suspense>
</div>
</>
);
}
@@ -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<string, unknown>(),
notify: vi.fn(),
showInTray: vi.fn(),
}));
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
}));
vi.mock("../lib/usePref", () => ({
usePref: <T,>(key: string, initial: T) => {
const [value, setValue] = useState<T>(() =>
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<typeof import("../lib/notifications")>()),
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(
<QueryClientProvider client={new QueryClient()}>
<DownloadDialog source="https://example.com/v" onClose={() => {}} />
</QueryClientProvider>,
);
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);
});
});
+18 -6
View File
@@ -11,13 +11,18 @@ import { invalidateDownloads } from "../lib/downloads";
const ProfileEditor = lazy(() => import("./ProfileEditor")); const ProfileEditor = lazy(() => import("./ProfileEditor"));
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { notifyActionError } from "../lib/actionErrors"; import { notifyActionError } from "../lib/actionErrors";
import { navigateTo } from "../lib/nav"; import { showInTray } from "../lib/downloadQueue";
import { profileName } from "../lib/vocab"; import { profileName } from "../lib/vocab";
import { LS } from "../lib/storage";
import { usePref } from "../lib/usePref";
import FilterSelect from "./ui/FilterSelect"; import FilterSelect from "./ui/FilterSelect";
import { Input } from "./ui/Input"; import { Input } from "./ui/Input";
// Preset picker shown when the user hits Download on a card / in the player, or adds a URL from // Preset picker shown when the user hits Download on a card / in the player, or brings a link (the
// the Download Center. Enqueues via the API and points the user at the Download Center. // 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({ export default function DownloadDialog({
source, source,
title, title,
@@ -36,23 +41,30 @@ export default function DownloadDialog({
const [manage, setManage] = useState(false); const [manage, setManage] = useState(false);
const profiles = profilesQ.data ?? []; const profiles = profilesQ.data ?? [];
const chosen = profileId ?? profiles[0]?.id ?? null; const [lastProfile, setLastProfile] = usePref<number | null>("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 () => { const submit = async () => {
if (!chosen || busy) return; if (!chosen || busy) return;
setBusy(true); setBusy(true);
const displayName = name.trim() || undefined; const displayName = name.trim() || undefined;
try { try {
await api.enqueueDownload({ const job = await api.enqueueDownload({
source, source,
profile_id: chosen, profile_id: chosen,
display_name: displayName, display_name: displayName,
}); });
invalidateDownloads(qc); invalidateDownloads(qc);
setLastProfile(chosen);
notify({ notify({
level: "success", level: "success",
message: t("downloads.dialog.added"), 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(); onClose();
} catch (e) { } catch (e) {
@@ -12,6 +12,7 @@ import { api, type QueueJob } from "../lib/api";
const h = vi.hoisted(() => ({ const h = vi.hoisted(() => ({
jobs: [] as unknown[], jobs: [] as unknown[],
updatedAt: 0,
openFile: vi.fn(), openFile: vi.fn(),
share: vi.fn(async () => "https://x/watch/t"), share: vi.fn(async () => "https://x/watch/t"),
notify: vi.fn(), notify: vi.fn(),
@@ -27,7 +28,13 @@ vi.mock("react-i18next", async (importOriginal) => ({
}), }),
})); }));
vi.mock("../lib/useLiveQuery", () => ({ 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 // 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). // 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 DownloadTray from "./DownloadTray";
import { noteQueue, resetQueueWatch } from "../lib/downloadQueue"; import { noteQueue, resetQueueWatch, showInTray } from "../lib/downloadQueue";
import { pushLayer, removeLayer } from "../lib/layerStack";
beforeEach(() => { beforeEach(() => {
resetQueueWatch(); resetQueueWatch();
h.prefs.clear(); h.prefs.clear();
h.updatedAt = 0;
globalThis.ResizeObserver ??= class { globalThis.ResizeObserver ??= class {
observe() {} observe() {}
unobserve() {} unobserve() {}
@@ -265,4 +274,62 @@ describe("DownloadTray", () => {
fireEvent.click(screen.getByRole("button", { name: "tray.close" })); fireEvent.click(screen.getByRole("button", { name: "tray.close" }));
expect(document.activeElement?.id).toBe("content"); 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();
});
});
}); });
@@ -1,18 +1,24 @@
// @vitest-environment jsdom // @vitest-environment jsdom
// //
// The Download Center's queue: a row keeps its primary action (pause / resume / retry) in sight, and // The tray's row menu: the copies and cancel of a waiting, running or failed job are its ONE menu,
// the copies and cancel are its ONE menu, opened by the row's "…" (it shows on hover or focus) or a // opened by the row's "…" (it shows on hover or focus) or a right-click on the row; a failed job's
// right-click on the row. A finished download's row is the Files page's (Files.menu.dom.test.tsx). // 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. // i18n is key-passthrough, so labels read as keys.
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import { api, type DownloadJob } from "../lib/api"; import { api, type DownloadJob } from "../lib/api";
import { menuGroups } from "./ui/menuGroups"; import { menuGroups } from "./ui/menuGroups";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
jobs: [] as unknown[], 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) => { vi.mock("react-i18next", async (importOriginal) => {
@@ -31,13 +37,18 @@ vi.mock("../lib/useLiveQuery", () => ({
refetch: vi.fn(), refetch: vi.fn(),
}), }),
})); }));
vi.mock("../lib/useMe", () => ({ useMe: () => ({ role: "user" }) })); vi.mock("../lib/usePref", () => ({
vi.mock("./ui/PageShell", async (importOriginal) => ({ usePref: <T,>(_key: string, initial: T) => useState<T>(initial),
...(await importOriginal<typeof import("./ui/PageShell")>()),
PageToolbar: ({ children }: { children: React.ReactNode }) => <>{children}</>,
})); }));
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 = ( const job = (
id: number, id: number,
@@ -68,6 +79,7 @@ const job = (
extra_links: [], extra_links: [],
can_download: status === "done", can_download: status === "done",
job_kind: "download", job_kind: "download",
source_key: "youtube",
asset: { media_kind: "video", duration_s: 60 }, asset: { media_kind: "video", duration_s: 60 },
...extra, ...extra,
}) as unknown as DownloadJob; }) as unknown as DownloadJob;
@@ -78,25 +90,23 @@ afterEach(() => {
mocks.confirm.mockClear(); mocks.confirm.mockClear();
}); });
function mount(tab?: string) { function mount() {
const qc = new QueryClient(); const qc = new QueryClient();
const tree = () => ( const tree = () => (
<QueryClientProvider client={qc}> <QueryClientProvider client={qc}>
<DownloadCenter /> <DownloadTray />
</QueryClientProvider> </QueryClientProvider>
); );
const { rerender } = render(tree()); const { rerender } = render(tree());
mounted.rerender = () => rerender(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 mounted = { rerender: () => {} };
const rowOf = (text: string) => screen.getByText(text).closest<HTMLElement>("[data-nav-index]")!; const rowOf = (text: string) => screen.getByText(text).closest<HTMLElement>("[data-nav-index]")!;
const moreOf = (text: string) => const moreOf = (text: string) =>
rowOf(text).querySelector<HTMLElement>('button[aria-label="downloads.rowMenu"]')!; rowOf(text).querySelector<HTMLElement>('button[aria-label="tray.rowMenu"]')!;
const menuRows = () => const menuRows = () =>
screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent ?? ""); screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent ?? "");
async function openMenu(open: () => void) { async function openMenu(open: () => void) {
@@ -105,16 +115,10 @@ async function openMenu(open: () => void) {
expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0), expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0),
); );
} }
const rowButtons = (text: string) => describe("DownloadTray · the row menu", () => {
[...rowOf(text).querySelectorAll<HTMLElement>("button, a[aria-label]")].map((b) => it("the '…' hides until hover or focus", () => {
b.getAttribute("aria-label"),
);
describe("DownloadCenter · the queue row", () => {
it("shows pause and its '…' only; the '…' hides until hover or focus", async () => {
mocks.jobs = [job(1, "running")]; mocks.jobs = [job(1, "running")];
mount(); mount();
expect(rowButtons("Clip 1")).toEqual(["downloads.actions.pause", "downloads.rowMenu"]);
expect(moreOf("Clip 1").classList.contains("reveal")).toBe(true); expect(moreOf("Clip 1").classList.contains("reveal")).toBe(true);
expect(rowOf("Clip 1").classList.contains("group")).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 () => { it("draws Retry as 'do it again', never the Play glyph", async () => {
mocks.jobs = [job(1, "error"), job(2, "paused")]; mocks.jobs = [job(1, "error")];
mount(); mount();
const glyph = (el: Element) => el.querySelector("svg")?.getAttribute("class") ?? ""; 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"))); await openMenu(() => fireEvent.contextMenu(rowOf("Clip 1")));
const row = screen const row = screen
.getAllByRole("menuitem", { hidden: true }) .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", () => { describe("menuGroups", () => {
const row = (id: string) => ({ id, label: id, onSelect: () => {} }); const row = (id: string) => ({ id, label: id, onSelect: () => {} });
it("rules between non-empty groups only", () => { it("rules between non-empty groups only", () => {
+35 -3
View File
@@ -18,12 +18,15 @@ import { failCopyKeys, failTitleKey } from "../lib/downloadFailure";
import { import {
IN_FLIGHT, IN_FLIGHT,
clearFinished, clearFinished,
doneRevealing,
fetchQueue, fetchQueue,
takeUnannounced, takeUnannounced,
useFinished, useFinished,
useRevealing,
useUnannounced, useUnannounced,
} from "../lib/downloadQueue"; } 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 { notify } from "../lib/notifications";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { LS } from "../lib/storage"; import { LS } from "../lib/storage";
@@ -57,7 +60,8 @@ type Fold = "open" | "folded";
const parseFold = (v: unknown): Fold => (v === "folded" ? "folded" : "open"); const parseFold = (v: unknown): Fold => (v === "folded" ? "folded" : "open");
const parseClosed = (v: unknown): string | null => (typeof v === "string" ? v : null); 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 BYTE_PHASES = ["video", "audio", "editing", "converting"];
const keyOf = (job: QueueJob) => job.id; const keyOf = (job: QueueJob) => job.id;
@@ -132,6 +136,35 @@ export default function DownloadTray() {
else if (target === "open") foldButton.current?.focus(); else if (target === "open") foldButton.current?.focus();
}, [fold]); }, [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<HTMLDivElement>(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 ------------------------------------------------------------------------ // --- the done notice ------------------------------------------------------------------------
const announce = useEffectEvent((job: QueueJob) => { const announce = useEffectEvent((job: QueueJob) => {
const title = jobTitle(job); const title = jobTitle(job);
@@ -153,7 +186,6 @@ export default function DownloadTray() {
}, [fresh]); }, [fresh]);
// --- Back to top steps up over the tray while it shows ----------------------------------------- // --- Back to top steps up over the tray while it shows -----------------------------------------
const box = useRef<HTMLDivElement>(null);
useLayoutEffect(() => { useLayoutEffect(() => {
const el = box.current; const el = box.current;
if (!el) { if (!el) {
+1
View File
@@ -411,6 +411,7 @@ export default function Files() {
width: "10rem", width: "10rem",
align: "right", align: "right",
cardLabel: false, cardLabel: false,
cardRow: true,
render: (f) => { render: (f) => {
const a = f.shared ? sharedRowActions(f) : libraryRowActions(f); const a = f.shared ? sharedRowActions(f) : libraryRowActions(f);
const mine = rowMenu.open && rowMenu.target?.id === f.id; const mine = rowMenu.open && rowMenu.target?.id === f.id;
+1 -1
View File
@@ -36,7 +36,7 @@ export default function LibraryPlayer({
const qc = useQueryClient(); const qc = useQueryClient();
const fileUrl = useMemo(() => api.fileUrl(job.id), [job.id]); 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 — // 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). // 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. // Only fall back to the old dimension+container heuristic for a legacy asset with a null media_kind.
const asset = job.asset; const asset = job.asset;
-7
View File
@@ -120,12 +120,6 @@ export default function NavSidebar({
// events (the former separate bell is now folded into the inbox page). // events (the former separate bell is now folded into the inbox page).
const clientUnread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount); const clientUnread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount);
const unread = (unreadQuery.data?.count ?? 0) + clientUnread; 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, // 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. // 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). // badge NUMBERS are the only nav-only concern (they need the live polling queries above).
const BADGE: Partial<Record<Page, number>> = { const BADGE: Partial<Record<Page, number>> = {
notifications: unread, notifications: unread,
downloads: dlActive,
}; };
const META = (p: Page): Omit<NavItem, "page"> => ({ const META = (p: Page): Omit<NavItem, "page"> => ({
icon: moduleDef(p).icon, icon: moduleDef(p).icon,
@@ -206,6 +206,18 @@ describe("NavigationProvider · Back", () => {
expect(window.location.search).toBe("?keep=1"); 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 // 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. // 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", () => { it("does not put back the focus stamp the landing spent", () => {
+8 -11
View File
@@ -2,12 +2,11 @@ import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react
import { useLatest } from "../lib/useLatest"; import { useLatest } from "../lib/useLatest";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMe } from "../lib/useMe"; 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 { LOGS_KEYS } from "../lib/logsUrlState";
import { pageTitleKey } from "../lib/pageMeta"; import { pageTitleKey } from "../lib/pageMeta";
import { useDocumentObjectTitle } from "../lib/documentTitle"; import { useDocumentObjectTitle } from "../lib/documentTitle";
import { setAccountRaw } from "../lib/storage"; import { setAccountRaw } from "../lib/storage";
import { setNavigator } from "../lib/nav";
import { import {
backStaysInDocument, backStaysInDocument,
closeOverlaysThenNavigate, 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 // 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 // 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 // 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) { function onPop(e: PopStateEvent) {
// An entry can name a page this build no longer has (one pushed before a module was removed, // 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. // reached by Back after a reload onto the new build); that lands where the page's content went
// The entry is rewritten to say so: left alone, the address bar would keep naming the dead page // (`resolvePage`), else on the feed, not on a crash. The entry is rewritten to say so: left
// and a click on Feed would be a no-op (setPage's same-page guard). It spreads the LIVE entry, // alone, the address bar would keep naming the dead page and a click on the page it landed on
// not `e.state`: lib/history's listener ran first and already took the spent `sfNav` off it. // 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 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) if (typeof sf === "string" && sf !== p)
window.history.replaceState( window.history.replaceState(
{ ...window.history.state, sfPage: p }, { ...window.history.state, sfPage: p },
"", "",
urlWithParams({ page: null }), urlWithParams({ page: p === "feed" ? null : p }),
); );
setPageState(p); setPageState(p);
setAccountRaw(PAGE_KEY, p); setAccountRaw(PAGE_KEY, p);
@@ -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<typeof import("react-i18next")>()),
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(
<QueryClientProvider client={new QueryClient()}>
<DownloadFormats />
</QueryClientProvider>,
);
await screen.findByText("Phone 480");
}
const rows = () => [...document.querySelectorAll<HTMLElement>("[data-nav-index]")];
const rowOf = (text: string) => screen.getByText(text).closest<HTMLElement>("[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" })),
);
});
});
+171 -45
View File
@@ -1,8 +1,9 @@
import { useState } from "react"; import { useState } from "react";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 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 Modal from "./ui/Modal";
import { useConfirm } from "./confirmContext"; import { useConfirm } from "./confirmContext";
import { api, type DownloadProfile, type DownloadSpec } from "../lib/api"; import { api, type DownloadProfile, type DownloadSpec } from "../lib/api";
@@ -10,11 +11,15 @@ import { profileName } from "../lib/vocab";
import { Button } from "./ui/Button"; import { Button } from "./ui/Button";
import SegmentedToggle from "./ui/SegmentedToggle"; import SegmentedToggle from "./ui/SegmentedToggle";
import FilterSelect from "./ui/FilterSelect"; import FilterSelect from "./ui/FilterSelect";
import { IconButton } from "./ui/IconButton";
import { CheckRow } from "./ui/NativeControls"; import { CheckRow } from "./ui/NativeControls";
import { Input } from "./ui/Input"; import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces"; import { type ActionMenuItem } from "./ui/ActionMenu";
import { SectionLabel } from "./ui/Headings"; 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]; 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<DownloadSpec>): 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 { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const confirm = useConfirm(); const confirm = useConfirm();
const profilesQ = useQuery({ queryKey: qk.downloadProfiles(), queryFn: api.downloadProfiles }); const profilesQ = useQuery({ queryKey: qk.downloadProfiles(), queryFn: api.downloadProfiles });
const profiles = profilesQ.data ?? []; // Built-ins first, then the user's own — the order the dialog's menu lists them in.
const builtins = profiles.filter((p) => p.is_builtin); const all = profilesQ.data ?? [];
const mine = profiles.filter((p) => !p.is_builtin); const profiles = [...all.filter((p) => p.is_builtin), ...all.filter((p) => !p.is_builtin)];
const [editing, setEditing] = useState<DownloadProfile | null>(null); const [editing, setEditing] = useState<DownloadProfile | null>(null);
const [name, setName] = useState(""); const [name, setName] = useState("");
@@ -73,6 +107,13 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
setSpec({ ...BLANK, ...p.spec }); setSpec({ ...BLANK, ...p.spec });
setFormOpen(true); 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({ const save = useMutation({
mutationFn: () => mutationFn: () =>
@@ -106,43 +147,112 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
const patch = (p: Partial<DownloadSpec>) => setSpec((s) => ({ ...s, ...p })); const patch = (p: Partial<DownloadSpec>) => setSpec((s) => ({ ...s, ...p }));
const menu = useContextMenu<DownloadProfile>({ keyOf: (p) => p.id });
const items = (p: DownloadProfile): ActionMenuItem[] =>
menuGroups(
p.is_builtin
? []
: [
{
id: "edit",
label: t("downloads.profiles.edit"),
icon: <Pencil className="w-4 h-4" />,
onSelect: () => startEdit(p),
},
],
[
{
id: "copy",
label: t("downloads.profiles.duplicate"),
icon: <CopyPlus className="w-4 h-4" />,
onSelect: () => startCopy(p),
},
],
p.is_builtin
? []
: [
{
id: "delete",
label: t("downloads.profiles.delete"),
icon: <Trash2 className="w-4 h-4" />,
danger: true,
onSelect: () => void onDelete(p),
},
],
);
const columns: Column<DownloadProfile>[] = [
{
key: "name",
header: t("downloads.profiles.name"),
cardPrimary: true,
render: (p) => (
<span className="flex min-w-0 items-center gap-1.5">
<span className="truncate">{profileName(t, p)}</span>
{p.is_builtin && (
<Lock
className="w-3.5 h-3.5 shrink-0 text-muted"
aria-label={t("downloads.profiles.builtin")}
/>
)}
</span>
),
},
{
key: "spec",
header: t("downloads.profiles.saves"),
render: (p) => {
const summary = specSummary(t, p.spec);
return (
<span className="block truncate text-muted" title={summary}>
{summary}
</span>
);
},
},
{
key: "menu",
header: "",
width: "3rem",
align: "right",
cardLabel: false,
cardRow: true,
render: (p) => (
<MoreButton
label={t("downloads.profiles.rowMenu")}
size="md"
expanded={menu.open && menu.target?.id === p.id}
menuProps={menu.buttonProps(p)}
/>
),
},
];
return ( return (
<Modal <div>
initialFocus="close" {profilesQ.isError && !profilesQ.data ? (
title={t("downloads.profiles.title")} <StateMessage tone="error" onRetry={() => void profilesQ.refetch()}>
onClose={onClose} {t("common.loadError")}
maxWidth="max-w-xl" </StateMessage>
> ) : !profilesQ.data ? (
{/* Existing formats */} <LoadingState shape="list" />
<div className="space-y-1.5 mb-4"> ) : (
<SectionLabel level={3}>{t("downloads.profiles.builtin")}</SectionLabel> <div className="mb-4">
{builtins.map((p) => ( <DataTable
<div key={p.id} className={`${SURFACE.card} flex items-center gap-2 text-sm px-3 py-1.5`}> rows={profiles}
<Lock className="w-3.5 h-3.5 text-muted shrink-0" /> columns={columns}
<span className="flex-1 truncate">{profileName(t, p)}</span> rowKey={(p) => String(p.id)}
</div> pager={false}
))} keyboardNav
{mine.length > 0 && ( fixedLayout
<SectionLabel level={3} className="pt-2"> rowProps={(p) => menu.triggerProps(p)}
{t("downloads.profiles.yours")} />
</SectionLabel> </div>
)} )}
{mine.map((p) => ( <ContextMenu
<div key={p.id} className={`${SURFACE.card} flex items-center gap-2 text-sm px-3 py-1.5`}> menu={menu}
<span className="flex-1 truncate">{p.name}</span> ariaLabel={t("downloads.profiles.rowMenu")}
<IconButton onClick={() => startEdit(p)} label={t("downloads.actions.rename")}> items={menu.target ? items(menu.target) : []}
<Pencil /> />
</IconButton>
<IconButton
variant="danger"
onClick={() => onDelete(p)}
label={t("downloads.profiles.delete")}
>
<Trash2 />
</IconButton>
</div>
))}
</div>
{!formOpen ? ( {!formOpen ? (
<Button variant="primary" size="sm" icon={<Plus className="w-4 h-4" />} onClick={startNew}> <Button variant="primary" size="sm" icon={<Plus className="w-4 h-4" />} onClick={startNew}>
@@ -218,7 +328,9 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
mp4/H.264/AAC compat profile (the only universally browser-playable combo) so a mp4/H.264/AAC compat profile (the only universally browser-playable combo) so a
shared link plays everywhere, iOS/Safari included. Enforced in backend `normalize`. */} shared link plays everywhere, iOS/Safari included. Enforced in backend `normalize`. */}
</div> </div>
<div className="space-y-1.5 pt-1"> {/* One option a line: a CheckRow is inline, and in the wide Settings column four of them
ran together on one line. */}
<div className="flex flex-col items-start gap-1.5 pt-1">
{spec.mode !== "a" && ( {spec.mode !== "a" && (
<Check <Check
label={t("downloads.profiles.embedSubs")} label={t("downloads.profiles.embedSubs")}
@@ -257,6 +369,20 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
</div> </div>
</div> </div>
)} )}
</div>
);
}
export default function ProfileEditor({ onClose }: { onClose: () => void }) {
const { t } = useTranslation();
return (
<Modal
initialFocus="close"
title={t("downloads.profiles.title")}
onClose={onClose}
maxWidth="max-w-xl"
>
<DownloadFormats />
</Modal> </Modal>
); );
} }
@@ -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<typeof import("react-i18next")>()),
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
}));
vi.mock("../lib/storage", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/storage")>()),
// 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(
<QueryClientProvider client={new QueryClient()}>
<SettingsPanel />
</QueryClientProvider>,
);
}
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();
});
});
+33 -5
View File
@@ -4,7 +4,18 @@ import { qk } from "../lib/queryKeys";
import { formatDate, formatDateTime, formatUnit } from "../lib/format"; import { formatDate, formatDateTime, formatUnit } from "../lib/format";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 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 DiagnosticsSettings from "./DiagnosticsSettings";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme"; import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import { api, HttpError, type Me } from "../lib/api"; import { api, HttpError, type Me } from "../lib/api";
@@ -50,10 +61,11 @@ export interface PrefsController {
saveState: PrefsSaveState; saveState: PrefsSaveState;
} }
type TabId = "appearance" | "notifications" | "account" | "diagnostics"; type TabId = "appearance" | "notifications" | "downloads" | "account" | "diagnostics";
const TABS: { id: TabId; icon: typeof Monitor }[] = [ const TABS: { id: TabId; icon: typeof Monitor }[] = [
{ id: "appearance", icon: Monitor }, { id: "appearance", icon: Monitor },
{ id: "notifications", icon: Bell }, { id: "notifications", icon: Bell },
{ id: "downloads", icon: Download },
{ id: "account", icon: User }, { id: "account", icon: User },
{ id: "diagnostics", icon: Activity }, { id: "diagnostics", icon: Activity },
]; ];
@@ -101,6 +113,7 @@ export default function SettingsPanel() {
<div {...panel.panelProps(tab)} className="flex-1 min-w-0 p-4"> <div {...panel.panelProps(tab)} className="flex-1 min-w-0 p-4">
{tab === "appearance" && <Appearance prefs={prefs} />} {tab === "appearance" && <Appearance prefs={prefs} />}
{tab === "notifications" && <Notifications prefs={prefs} />} {tab === "notifications" && <Notifications prefs={prefs} />}
{tab === "downloads" && <Downloads />}
{tab === "account" && <Account me={me} onOpenWizard={openWizard} />} {tab === "account" && <Account me={me} onOpenWizard={openWizard} />}
{tab === "diagnostics" && <DiagnosticsSettings />} {tab === "diagnostics" && <DiagnosticsSettings />}
</div> </div>
@@ -615,7 +628,7 @@ function PlexWatchSync() {
// (e.g. a private Facebook group you belong to) so downloads from it use YOUR login. Write-only — // (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. // 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). // 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() { export function DownloadAuth() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const qc = useQueryClient(); 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 (
<>
<Section title={t("settings.downloads.formats")}>
<p className="text-xs text-muted leading-relaxed mb-2">
{t("settings.downloads.formatsIntro")}
</p>
<DownloadFormats />
</Section>
<DownloadAuth />
</>
);
}
function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) { function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
const confirm = useConfirm(); const confirm = useConfirm();
@@ -825,8 +855,6 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
{me.role === "admin" && me.plex_enabled && <PlexWatchSync />} {me.role === "admin" && me.plex_enabled && <PlexWatchSync />}
<DownloadAuth />
{me.google_enabled ? ( {me.google_enabled ? (
// YouTube access requires Google OAuth; hidden when this instance has no Google configured. // YouTube access requires Google OAuth; hidden when this instance has no Google configured.
<Section title={t("settings.account.youtubeAccess")}> <Section title={t("settings.account.youtubeAccess")}>
@@ -15,7 +15,7 @@ const h = vi.hoisted(() => ({
// user's bandwidth on it. Generous by default; the cap test below lowers it. // user's bandwidth on it. Generous by default; the cap test below lowers it.
filesUsage: vi.fn(async () => ({ upload_max_bytes: 1_000_000 })), filesUsage: vi.fn(async () => ({ upload_max_bytes: 1_000_000 })),
notify: vi.fn(), notify: vi.fn(),
navigateTo: vi.fn(), showInTray: vi.fn(),
openFile: vi.fn(), openFile: vi.fn(),
})); }));
@@ -35,7 +35,7 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/notifications")>()), ...(await importOriginal<typeof import("../lib/notifications")>()),
notify: h.notify, notify: h.notify,
})); }));
vi.mock("../lib/nav", () => ({ navigateTo: h.navigateTo })); vi.mock("../lib/downloadQueue", () => ({ showInTray: h.showInTray }));
import UploadDialog from "./UploadDialog"; import UploadDialog from "./UploadDialog";
import { FilesActionsContext } from "./filesContext"; import { FilesActionsContext } from "./filesContext";
@@ -67,7 +67,7 @@ const fileInput = () => document.querySelector('input[type="file"]') as HTMLInpu
beforeEach(() => { beforeEach(() => {
h.uploadFile.mockClear(); h.uploadFile.mockClear();
h.openFile.mockClear(); h.openFile.mockClear();
h.navigateTo.mockClear(); h.showInTray.mockClear();
h.notify.mockClear(); h.notify.mockClear();
h.filesUsage.mockClear(); h.filesUsage.mockClear();
h.filesUsage.mockResolvedValue({ upload_max_bytes: 1_000_000 }); 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 () => { 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 // Done: Files opens on the file itself, in its folder. Still converting: the tray opens on it.
// queue until it is done.
h.uploadFile.mockResolvedValueOnce({ id: 42, status: "done", folder_id: 5 } as never); h.uploadFile.mockResolvedValueOnce({ id: 42, status: "done", folder_id: 5 } as never);
wrap(vi.fn(), 5); wrap(vi.fn(), 5);
fireEvent.change(fileInput(), { target: { files: [new File(["x"], "a.mp4")] } }); 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); 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(); (h.notify.mock.calls[0]![0] as { action: { onClick: () => void } }).action.onClick();
expect(h.openFile).toHaveBeenLastCalledWith(42, 5); expect(h.openFile).toHaveBeenLastCalledWith(42, 5);
expect(h.navigateTo).not.toHaveBeenCalled(); expect(h.showInTray).not.toHaveBeenCalled();
cleanup(); cleanup();
h.notify.mockClear(); h.notify.mockClear();
@@ -135,7 +134,7 @@ describe("UploadDialog", () => {
fireEvent.click(screen.getByText("downloads.upload.submit")); fireEvent.click(screen.getByText("downloads.upload.submit"));
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1)); await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
(h.notify.mock.calls[0]![0] as { action: { onClick: () => void } }).action.onClick(); (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 () => { it("opens ready to send a file dropped on the page", async () => {
+3 -4
View File
@@ -10,7 +10,7 @@ import { isAborted } from "../lib/api/core";
import { notifyActionError } from "../lib/actionErrors"; import { notifyActionError } from "../lib/actionErrors";
import { invalidateDownloads } from "../lib/downloads"; import { invalidateDownloads } from "../lib/downloads";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { navigateTo } from "../lib/nav"; import { showInTray } from "../lib/downloadQueue";
import type { DownloadJob } from "../lib/api"; import type { DownloadJob } from "../lib/api";
import { useFilesActions } from "./filesContext"; import { useFilesActions } from "./filesContext";
import { formatBytes } from "../lib/format"; 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. // same reason, and `formAlerts.dom.test.tsx` is the test that exists because of it.
const [pickSeq, setPickSeq] = useState(0); const [pickSeq, setPickSeq] = useState(0);
const { openFile } = useFilesActions(); const { openFile } = useFilesActions();
// A done file is in Files, opened on it; one the worker still converts is in the download queue // A done file is in Files, opened on it; one the worker still converts is in the tray until it is.
// until it is.
const openUploaded = (job: DownloadJob) => { const openUploaded = (job: DownloadJob) => {
if (job.status === "done") openFile(job.id, job.folder_id ?? null); if (job.status === "done") openFile(job.id, job.folder_id ?? null);
else navigateTo("downloads"); else showInTray(job.id);
}; };
const pick_ = (f: File) => { const pick_ = (f: File) => {
setFile(f); setFile(f);
+3 -3
View File
@@ -7,7 +7,7 @@ import {
Clapperboard, Clapperboard,
Clock, Clock,
Code2, Code2,
Download, FolderOpen,
Expand, Expand,
Filter, Filter,
Inbox, Inbox,
@@ -182,7 +182,7 @@ const GALLERY: { key: string; src: string }[] = [
{ key: "playlists", src: "/welcome/playlists.webp" }, { key: "playlists", src: "/welcome/playlists.webp" },
{ key: "search", src: "/welcome/search.webp" }, { key: "search", src: "/welcome/search.webp" },
{ key: "player", src: "/welcome/player.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" }, { key: "plex", src: "/welcome/plex.webp" },
]; ];
@@ -192,7 +192,7 @@ const FEATURES: { key: string; icon: LucideIcon }[] = [
{ key: "search", icon: Search }, { key: "search", icon: Search },
{ key: "playlists", icon: ListVideo }, { key: "playlists", icon: ListVideo },
{ key: "player", icon: PlayCircle }, { key: "player", icon: PlayCircle },
{ key: "downloads", icon: Download }, { key: "files", icon: FolderOpen },
{ key: "plex", icon: Clapperboard }, { key: "plex", icon: Clapperboard },
{ key: "private", icon: Lock }, { key: "private", icon: Lock },
]; ];
@@ -252,6 +252,7 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
width: "3.25rem", width: "3.25rem",
nowrap: true, nowrap: true,
cardLabel: false, cardLabel: false,
cardRow: true,
// The row's full-history, hide and unsubscribe buttons went into the menu: the row // The row's full-history, hide and unsubscribe buttons went into the menu: the row
// keeps what is read or tuned all the time — priority, tags, the ✓ — and ends in its "…". // keeps what is read or tuned all the time — priority, tags, the ✓ — and ends in its "…".
render: more, render: more,
@@ -0,0 +1,160 @@
// @vitest-environment jsdom
//
// The admin Users page's Storage tab: every user's footprint against their OWN limit (or
// "Unlimited") with their file count, and every user's downloads — each list's row has one menu.
// A user's: Edit quota, Reset (behind a confirm). A download's: Open source, the copies, and Edit
// this user's quota — which opens the quota of the download's OWNER. 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 AdminStorage, type DownloadJob } from "../lib/api";
import { qk } from "../lib/queryKeys";
const mocks = vi.hoisted(() => ({
storage: undefined as unknown,
jobs: undefined as unknown,
confirm: vi.fn(async (_o?: unknown) => true),
}));
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
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<DownloadJob> = {}) =>
({
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(
<QueryClientProvider client={new QueryClient()}>
<StorageTab />
</QueryClientProvider>,
);
}
const rowOf = (text: string) =>
screen.getAllByText(text)[0]!.closest<HTMLElement>("[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));
});
});
+286 -55
View File
@@ -2,23 +2,33 @@ import { useState } from "react";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 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 Modal, { ModalFooter } from "./ui/Modal";
import { useLiveQuery } from "../lib/useLiveQuery"; 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 { formatBytes } from "../lib/format";
import { Button } from "./ui/Button"; import { Button } from "./ui/Button";
import { StatusPill } from "./downloadRow"; import { StatusPill } from "./downloadRow";
import { jobTitle } from "./downloadTitle"; 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 { Input } from "./ui/Input";
import { CheckRow } from "./ui/NativeControls"; import { CheckRow } from "./ui/NativeControls";
import { LoadingState, StateMessage } from "./ui/QueryState"; import { LoadingState, StateMessage } from "./ui/QueryState";
import { SURFACE } from "./ui/surfaces"; import { SURFACE } from "./ui/surfaces";
import UserPicker from "./UserPicker"; import UserPicker from "./UserPicker";
// The admin "System" tab — storage overview, per-user footprint, quota editor, and the global job // The admin Users page's "Storage" tab: the disk in three numbers, every user's footprint against
// list. Split out of DownloadCenter (self-contained, admin-only surface). // 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; const GiB = 1024 ** 3;
@@ -173,18 +183,201 @@ function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string }
</div> </div>
); );
} }
export function StorageTab() {
export function AdminSystem() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language; const lang = i18n.language;
const qc = useQueryClient();
const confirm = useConfirm();
const copy = useCopyItems();
const storage = useLiveQuery(qk.adminStorage(), api.adminDownloadStorage, { intervalMs: 5000 }); const storage = useLiveQuery(qk.adminStorage(), api.adminDownloadStorage, { intervalMs: 5000 });
const jobs = useLiveQuery(qk.adminDownloads(), api.adminDownloads, { intervalMs: 5000 }); const jobs = useLiveQuery(qk.adminDownloads(), api.adminDownloads, { intervalMs: 5000 });
const [quotaFor, setQuotaFor] = useState<{ id: number; email: string } | null>(null); const [quotaFor, setQuotaFor] = useState<{ id: number; email: string } | null>(null);
const s = storage.data; 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<AdminStorageUser>({ keyOf: (u) => u.user_id });
const userItems = (u: AdminStorageUser): ActionMenuItem[] => [
{
id: "quota",
label: t("downloads.admin.editQuota"),
icon: <Settings2 className="w-4 h-4" />,
onSelect: () => setQuotaFor({ id: u.user_id, email: u.email ?? "" }),
},
{
id: "reset",
label: t("downloads.admin.reset"),
icon: <RotateCcw className="w-4 h-4" />,
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<AdminStorageUser>[] = [
{
key: "user",
header: t("downloads.admin.columns.user"),
cardPrimary: true,
sortable: true,
sortValue: (u) => u.email ?? "",
render: (u) => <span className="block truncate">{u.email}</span>,
},
{
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) => <QuotaFill user={u} lang={lang} />,
},
{
key: "used",
header: t("downloads.admin.columns.used"),
width: "6.5rem",
align: "right",
sortable: true,
sortValue: (u) => u.footprint_bytes,
render: (u) => <span className="tabular-nums">{formatBytes(u.footprint_bytes, lang)}</span>,
},
{
key: "files",
header: t("downloads.admin.columns.files"),
width: "4.5rem",
align: "right",
sortable: true,
sortValue: (u) => u.files,
render: (u) => <span className="tabular-nums">{u.files}</span>,
},
{
key: "menu",
header: "",
width: "3rem",
align: "right",
cardLabel: false,
cardRow: true,
render: (u) => (
<MoreButton
label={t("downloads.admin.userMenu")}
size="md"
expanded={userMenu.open && userMenu.target?.user_id === u.user_id}
menuProps={userMenu.buttonProps(u)}
/>
),
},
];
// --- every download ----------------------------------------------------------------------------
const jobMenu = useContextMenu<DownloadJob>({ 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: <ExternalLink className="w-4 h-4" />,
// 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: <Settings2 className="w-4 h-4" />,
onSelect: () => setQuotaFor({ id: owner, email: j.user_email ?? "" }),
},
]
: [],
);
};
const jobColumns: Column<DownloadJob>[] = [
{
key: "title",
header: t("downloads.admin.columns.title"),
cardPrimary: true,
sortable: true,
sortValue: (j) => jobTitle(j),
render: (j) => (
<span className="block truncate" title={jobTitle(j)}>
{jobTitle(j)}
</span>
),
},
{
key: "user",
header: t("downloads.admin.columns.user"),
width: "11rem",
sortable: true,
sortValue: (j) => j.user_email ?? "",
narrowHidden: true,
render: (j) => <span className="block truncate text-muted">{j.user_email}</span>,
},
{
key: "status",
header: t("downloads.admin.columns.status"),
width: "7rem",
sortable: true,
sortValue: (j) => j.status,
render: (j) => <StatusPill job={j} />,
},
{
key: "size",
header: t("downloads.admin.columns.size"),
width: "6rem",
align: "right",
sortable: true,
sortValue: (j) => j.asset?.size_bytes ?? 0,
render: (j) => (
<span className="tabular-nums text-muted">
{j.asset?.size_bytes ? formatBytes(j.asset.size_bytes, lang) : "—"}
</span>
),
},
{
key: "menu",
header: "",
width: "3rem",
align: "right",
cardLabel: false,
cardRow: true,
render: (j) => (
<MoreButton
label={t("downloads.admin.jobMenu")}
size="md"
expanded={jobMenu.open && jobMenu.target?.id === j.id}
menuProps={jobMenu.buttonProps(j)}
/>
),
},
];
return ( return (
<div> <div>
{s && ( {s && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-5"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-4">
{[ {[
[t("downloads.admin.filesOnDisk"), String(s.files_on_disk)], [t("downloads.admin.filesOnDisk"), String(s.files_on_disk)],
[t("downloads.admin.totalSize"), formatBytes(s.total_bytes, lang)], [t("downloads.admin.totalSize"), formatBytes(s.total_bytes, lang)],
@@ -195,65 +388,103 @@ export function AdminSystem() {
].map(([label, val]) => ( ].map(([label, val]) => (
<div key={label} className={`${SURFACE.card} p-3`}> <div key={label} className={`${SURFACE.card} p-3`}>
<div className="text-xs text-muted">{label}</div> <div className="text-xs text-muted">{label}</div>
<div className="text-lg font-semibold mt-0.5">{val}</div> <div className="text-lg font-semibold mt-0.5 tabular-nums">{val}</div>
</div> </div>
))} ))}
</div> </div>
)} )}
{s && ( <Section card title={t("downloads.admin.perUser")}>
<div className="mb-6"> {/* Set a quota for any user, regardless of whether they've downloaded anything. */}
<div className="flex items-center justify-between gap-3 mb-2 flex-wrap"> <div className="mb-3 flex justify-end">
<div className="text-sm font-medium">{t("downloads.admin.perUser")}</div> <QuotaUserPicker onPick={setQuotaFor} />
{/* Set a quota for any user, regardless of whether they've downloaded anything. */}
<QuotaUserPicker onPick={setQuotaFor} />
</div>
<div className="space-y-1">
{s.per_user.map((u) => (
<div
key={u.user_id}
className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg bg-card/40"
>
<span className="flex-1 truncate">{u.email}</span>
<span className="text-muted">{formatBytes(u.footprint_bytes, lang)}</span>
<IconButton
onClick={() => setQuotaFor({ id: u.user_id, email: u.email ?? "" })}
label={t("downloads.admin.editQuota")}
>
<Settings2 />
</IconButton>
</div>
))}
{s.per_user.length === 0 && (
<div className="text-xs text-muted px-3 py-2">{t("downloads.admin.noFootprint")}</div>
)}
</div>
</div> </div>
)} {storage.isError && !s ? (
<StateMessage tone="error" onRetry={() => void storage.refetch()}>
<div className="text-sm font-medium mb-2">{t("downloads.tabs.system")}</div> {t("common.loadError")}
<div className="space-y-1.5"> </StateMessage>
{(jobs.data ?? []).map((j) => ( ) : !s ? (
<div <LoadingState shape="list" />
key={j.id} ) : (
className="flex items-center gap-3 text-sm px-3 py-1.5 rounded-lg bg-card/40" <div className="@container">
> <DataTable
<span className="flex-1 truncate">{jobTitle(j)}</span> rows={s.per_user}
<span className="text-muted truncate w-40">{j.user_email}</span> columns={userColumns}
<StatusPill job={j} /> rowKey={(u) => String(u.user_id)}
<span className="text-muted w-16 text-right"> pageSize={25}
{j.asset?.size_bytes ? formatBytes(j.asset.size_bytes, lang) : ""} keyboardNav
</span> fixedLayout
rowProps={(u) => userMenu.triggerProps(u)}
emptyText={t("downloads.admin.noFootprint")}
/>
</div> </div>
))}
{jobs.data && jobs.data.length === 0 && (
<div className="text-sm text-muted py-6 text-center">{t("downloads.empty.admin")}</div>
)} )}
</div> </Section>
<Section card title={t("downloads.admin.allDownloads")}>
{jobs.isError && !jobs.data ? (
<StateMessage tone="error" onRetry={() => void jobs.refetch()}>
{t("common.loadError")}
</StateMessage>
) : !jobs.data ? (
<LoadingState shape="list" />
) : (
<div className="@container">
<DataTable
rows={jobs.data}
columns={jobColumns}
rowKey={(j) => String(j.id)}
pageSize={25}
keyboardNav
fixedLayout
rowProps={(j) => jobMenu.triggerProps(j)}
emptyText={t("downloads.empty.admin")}
/>
</div>
)}
</Section>
<ContextMenu
menu={userMenu}
ariaLabel={t("downloads.admin.userMenu")}
items={userMenu.target ? userItems(userMenu.target) : []}
/>
<ContextMenu
menu={jobMenu}
ariaLabel={t("downloads.admin.jobMenu")}
items={jobMenu.target ? jobItems(jobMenu.target) : []}
/>
{quotaFor && ( {quotaFor && (
<QuotaModal userId={quotaFor.id} email={quotaFor.email} onClose={() => setQuotaFor(null)} /> <QuotaModal userId={quotaFor.id} email={quotaFor.email} onClose={() => setQuotaFor(null)} />
)} )}
</div> </div>
); );
} }
/** 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 <span className="text-xs text-muted">{t("downloads.admin.unlimitedShort")}</span>;
const pct = Math.min(100, Math.round((user.footprint_bytes / user.max_bytes) * 100));
return (
<span className="flex flex-col gap-1">
<span
className="h-1.5 overflow-hidden rounded-full bg-fg/10"
role="meter"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={pct}
aria-label={t("downloads.admin.columns.quota")}
>
<span
className={clsx("block h-full rounded-full", pct >= 90 ? "bg-danger" : "bg-accent")}
style={{ width: `${pct}%` }}
/>
</span>
<span className="text-2xs text-muted tabular-nums">
{t("downloads.admin.ofQuota", { size: formatBytes(user.max_bytes, lang) })}
</span>
</span>
);
}
+2 -2
View File
@@ -10,8 +10,8 @@ import { IconBtn } from "./downloadRow";
import { failCopyKeys, failTitleKey } from "../lib/downloadFailure"; import { failCopyKeys, failTitleKey } from "../lib/downloadFailure";
import { Input } from "./ui/Input"; import { Input } from "./ui/Input";
// Per-item user modals split out of DownloadCenter (self-contained, prop-driven): rename/metadata // Per-item user modals (self-contained, prop-driven): a library file's details (Files) and a failed
// editing and the failure-detail window. // download's reasons (the tray).
// Edit a library file's details: its channel (name + link) and extra reference links. Display-only // 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. // overrides — the on-disk file is never touched. Its name is renamed in place in the Files list.
@@ -1,11 +1,9 @@
// @vitest-environment jsdom // @vitest-environment jsdom
// //
// Library-row metadata: the completed row surfaces the concrete container/format alongside the // A job's picture (the Files list and grid): with none at all, a quiet glyph of what it is; a remote
// already-shown size. The format chip reads the raw `asset.container` (uppercased in the UI), so it must // thumbnail that fails falls back to the same glyph. i18n is key-passthrough here.
// 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.
import { describe, it, expect, afterEach, vi } from "vitest"; 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) => { vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>(); const actual = await importOriginal<typeof import("react-i18next")>();
@@ -15,7 +13,7 @@ vi.mock("react-i18next", async (importOriginal) => {
}; };
}); });
import { DownloadRow, IconBtn } from "./downloadRow"; import { JobThumb } from "./downloadRow";
import type { DownloadJob } from "../lib/api"; import type { DownloadJob } from "../lib/api";
const baseJob: DownloadJob = { const baseJob: DownloadJob = {
@@ -82,201 +80,15 @@ const withAsset = (
afterEach(cleanup); afterEach(cleanup);
describe("DownloadRow format chip", () => {
it("shows the uppercased container next to the size when ready", () => {
render(<DownloadRow job={withAsset("mp4")} />);
// Raw container is stored lowercase; the row uppercases it via CSS, so the text node stays "mp4".
expect(screen.getByText(/·\s*mp4/i)).toBeTruthy();
expect(screen.getByText(/142 MB/)).toBeTruthy();
});
it("omits the format chip when the asset has no container yet", () => {
const { container } = render(<DownloadRow job={withAsset(null)} />);
expect(container.textContent).not.toMatch(/·\s*(mp4|m4a|webm)/i);
});
});
// A generic uploaded file (media_kind='file') has no video frame and no timeline: the row
// shows a neutral file glyph instead of a thumbnail <img>, and no duration chip (it stays media info
// like the container/size). Media rows keep their thumbnail path (an <img>, or empty until a poster).
describe("DownloadRow file kind", () => {
it("renders a file glyph, not a thumbnail <img>, for a generic file", () => {
const { container } = render(
<DownloadRow
job={withAsset("pdf", { media_kind: "file", duration_s: null, size_bytes: 2_400_000 })}
/>,
);
// The file glyph is an inline svg; no <img> element is emitted for a file.
expect(container.querySelector("img")).toBeNull();
expect(container.querySelector("svg")).not.toBeNull();
// File info still reads (container + size), but there is no duration chip.
expect(screen.getByText(/·\s*pdf/i)).toBeTruthy();
expect(screen.getByText(/2\.3 MB/)).toBeTruthy();
});
it("keeps the thumbnail path (an <img> when a poster exists) for a media asset", () => {
const { container } = render(
<DownloadRow job={{ ...withAsset("mp4"), poster_url: "/api/downloads/1/poster.jpg" }} />,
);
expect(container.querySelector("img")).not.toBeNull();
});
});
describe("DownloadRow · keyboard", () => {
it("is a list item only when it is given a position", () => {
const { container, rerender } = render(<DownloadRow job={baseJob} navIndex={3} />);
expect(container.firstElementChild?.getAttribute("data-nav-index")).toBe("3");
rerender(<DownloadRow job={baseJob} />);
expect(container.firstElementChild?.hasAttribute("data-nav-index")).toBe(false);
});
it("marks the action that opens the row, and only that one", () => {
render(
<DownloadRow job={baseJob} navIndex={0}>
<IconBtn open onClick={() => {}} title="play">
p
</IconBtn>
<IconBtn onClick={() => {}} title="delete">
d
</IconBtn>
</DownloadRow>,
);
expect(screen.getByTitle("play").hasAttribute("data-nav-open")).toBe(true);
expect(screen.getByTitle("delete").hasAttribute("data-nav-open")).toBe(false);
});
// The row once resized its actions with a `[&_button]:size-8` override, which a save LINK in the
// same row escaped (28px beside 32px buttons). The size is the primitive's own rung, set by IconBtn.
it("sizes its actions through the primitive's rung, not a descendant override", () => {
const { container } = render(
<DownloadRow job={baseJob}>
<IconBtn onClick={() => {}} title="play">
p
</IconBtn>
</DownloadRow>,
);
expect(screen.getByTitle("play").className).toContain("size-8");
expect(container.innerHTML).not.toContain("[&amp;_button]");
});
});
// The row answers to its own width (a container query) and its meta line never wraps — on
// the iPad, zoomed in, its "18.9 / MB" pieces broke into a jumble (the owner, 2026-09-27). jsdom lays
// nothing out, so this holds the structure the layout rests on; the widths were measured in a browser.
describe("DownloadRow · narrow", () => {
it("keeps the meta line one cut line of inline text, not a flex row that wraps its pieces", () => {
const { container } = render(
<DownloadRow job={{ ...withAsset("mp4"), created_at: "2026-09-20T10:00:00Z" }} />,
);
const meta = container.querySelector("[data-dl-meta]")!;
expect(meta.className.split(/\s+/)).toContain("truncate");
expect(meta.className).not.toMatch(/(^|\s)(inline-)?flex(\s|$)/);
for (const el of meta.querySelectorAll("*"))
expect(el.className.toString()).not.toMatch(/(^|\s)(inline-)?flex(\s|$)/);
});
it("is its own size container and stacks below 40rem", () => {
const { container } = render(<DownloadRow job={withAsset("mp4")} />);
const row = container.firstElementChild as HTMLElement;
expect(row.className).toContain("@container/dlrow");
const grid = row.firstElementChild as HTMLElement;
expect(grid.className).toContain(
"@max-[40rem]/dlrow:[grid-template-areas:'thumb_head''meta_meta''foot_foot''prog_prog']",
);
});
it("stacked, puts the source link and the actions on ONE wrapping line, not a line each", () => {
const { container } = render(
<DownloadRow job={{ ...withAsset("mp4"), source_url: "https://example.com/v" }}>
<IconBtn onClick={() => {}} title="play">
x
</IconBtn>
</DownloadRow>,
);
const link = container.querySelector("a[href='https://example.com/v']")!;
const foot = screen.getByTitle("play").closest(".contents")!;
expect(foot.contains(link)).toBe(true);
for (const c of ["@max-[40rem]/dlrow:[grid-area:foot]", "@max-[40rem]/dlrow:flex-wrap"])
expect(foot.className).toContain(c);
});
it("gives the failure link a real 24px line on a mouse, not the text's 20 (DESIGN B1)", () => {
render(
<DownloadRow job={{ ...baseJob, status: "error", error: "boom" }} onShowError={() => {}} />,
);
const link = screen.getByTitle("downloads.errors.fail.title");
const cls = link.className.split(/\s+/);
expect(cls).toEqual(expect.arrayContaining(["inline-block", "min-h-6", "leading-6"]));
});
it("draws nothing for an empty link list (a bare 0 once leaked into the row)", () => {
const { container } = render(<DownloadRow job={{ ...withAsset("mp4"), extra_links: [] }} />);
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
const texts: string[] = [];
while (walker.nextNode()) texts.push(walker.currentNode.textContent ?? "");
expect(texts).not.toContain("0");
});
});
// A link queued (or failed) before its details: the title, the channel line and the link line all
// fell back to the same URL (the owner, 2026-09-27). Now the title IS the short link, once.
describe("DownloadRow · a link with no details yet", () => {
const bare: DownloadJob = {
...baseJob,
status: "error",
source_kind: "url",
source_ref: "https://www.facebook.com/watch?v=12345",
source_url: "https://www.facebook.com/watch?v=12345",
display_name: null,
asset: null,
};
it("shows the short link once, as the title's link, with no channel line", () => {
const { container } = render(<DownloadRow job={bare} />);
const links = [...container.querySelectorAll("a")].filter((a) => a.href === bare.source_url);
expect(links.map((a) => a.textContent)).toEqual(["www.facebook.com/watch?v=12345"]);
expect(container.textContent).not.toContain("https://");
});
// Measured at 21px on the Downloads page: every link in the row stands on a 24px line on
// a mouse (DESIGN B1), as the link line and the failure link already did.
it("gives the title link and the channel link a 24px line", () => {
const { container } = render(
<DownloadRow
job={{
...bare,
display_uploader: "Someone",
display_uploader_url: "https://example.com/c",
}}
/>,
);
const title = [...container.querySelectorAll("a")].find((a) => a.href === bare.source_url)!;
const channel = container.querySelector<HTMLAnchorElement>("a[href='https://example.com/c']")!;
// inline-block: an inline link's box is the font's height (21px) whatever its line height.
for (const a of [title, channel])
expect(a.className.split(/\s+/)).toEqual(
expect.arrayContaining(["inline-block", "leading-6"]),
);
});
it("keeps the named row as it was: title text, and the link on its own line", () => {
const named = { ...bare, display_name: "A clip" };
const { container } = render(<DownloadRow job={named} />);
expect(screen.getByText("A clip").closest("a")).toBeNull();
const links = [...container.querySelectorAll("a")].filter((a) => a.href === bare.source_url);
expect(links.map((a) => a.textContent)).toEqual(["www.facebook.com/watch?v=12345"]);
});
});
// No picture at all: a quiet glyph of what it is instead of an empty box (the owner, 2026-09-27). // 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", () => { it("draws a film glyph for a video, a note for audio", () => {
const { container, unmount } = render(<DownloadRow job={withAsset("mp4")} />); const { container, unmount } = render(<JobThumb job={withAsset("mp4")} />);
expect(container.querySelector("img")).toBeNull(); expect(container.querySelector("img")).toBeNull();
expect(container.querySelector("svg.lucide-film")).not.toBeNull(); expect(container.querySelector("svg.lucide-film")).not.toBeNull();
unmount(); unmount();
const { container: audio } = render( const { container: audio } = render(
<DownloadRow job={withAsset("m4a", { media_kind: "audio" })} />, <JobThumb job={withAsset("m4a", { media_kind: "audio" })} />,
); );
expect(audio.querySelector("svg.lucide-music")).not.toBeNull(); 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. // 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", () => { it("falls back to the glyph when the remote picture fails too", () => {
const { container } = render( const { container } = render(
<DownloadRow job={withAsset("mp4", { thumbnail_url: "https://cdn.example/dead.jpg" })} />, <JobThumb job={withAsset("mp4", { thumbnail_url: "https://cdn.example/dead.jpg" })} />,
); );
const img = container.querySelector("img")!; const img = container.querySelector("img")!;
expect(img.getAttribute("src")).toBe("https://cdn.example/dead.jpg"); 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(); expect(container.querySelector("svg.lucide-film")).not.toBeNull();
}); });
}); });
// The phone-safe conversion reports ffmpeg's real progress, so it gets the % bar a download has;
// a yt-dlp post-step reports none and keeps the pulse.
describe("DownloadRow · a running conversion", () => {
const running = (phase: string): DownloadJob => ({
...baseJob,
status: "running",
progress: 40,
phase,
});
it("shows its phase and its %", () => {
const { container } = render(<DownloadRow job={running("converting")} />);
expect(screen.getByText("downloads.phase.converting")).toBeTruthy();
expect(screen.getByText("40%")).toBeTruthy();
expect(container.querySelector(".animate-pulse")).toBeNull();
});
it("a post-step with no % keeps the pulse", () => {
const { container } = render(<DownloadRow job={running("merging")} />);
expect(screen.queryByText("40%")).toBeNull();
expect(container.querySelector(".animate-pulse")).not.toBeNull();
});
});
+4 -230
View File
@@ -1,27 +1,14 @@
import { useState, type ReactNode } from "react"; import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next"; 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 clsx from "clsx";
import { api, type DownloadJob } from "../lib/api"; 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 { IconButton } from "./ui/IconButton";
import { MediaProgress } from "./ui/MediaCard"; 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 // The small shared primitives of a download (status pill, icon button, the job's picture), all purely
// picture), split out of DownloadCenter (all purely prop-driven, no shared state). Rendered by the // prop-driven: the admin Storage tab shows the status, the Files list and grid the picture, the
// queue and the admin tab; the Files list borrows the picture. // details dialog the icon button.
const STATUS_CLS: Record<string, string> = { const STATUS_CLS: Record<string, string> = {
queued: "text-muted", queued: "text-muted",
@@ -32,10 +19,6 @@ const STATUS_CLS: Record<string, string> = {
canceled: "text-muted", 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 = [ const PHASE_KEYS = [
"video", "video",
"audio", "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({ export function IconBtn({
onClick, onClick,
title, title,
@@ -179,202 +152,3 @@ export function IconBtn({
</IconButton> </IconButton>
); );
} }
// 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 (
<div className="flex items-center gap-1 text-sm text-muted min-w-0">
<Link2 className="w-3.5 h-3.5 shrink-0" />
<a
href={url}
target="_blank"
rel="noopener noreferrer"
title={`${t("downloads.source.open")} — ${url}`}
className="truncate leading-6 hover:text-accent hover:underline pointer-coarse:leading-(--touch-target)"
{...nativeMenu}
>
{displayUrl(url)}
</a>
</div>
);
}
// 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 (
<div className="text-sm mt-0.5 truncate">
<a
href={url}
target="_blank"
rel="noopener noreferrer"
title={url}
className="inline-block max-w-full truncate align-top leading-6 text-muted hover:text-accent hover:underline pointer-coarse:leading-(--touch-target)"
{...nativeMenu}
>
{name}
</a>
</div>
);
}
return <div className="text-sm text-muted truncate mt-0.5">{name}</div>;
}
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<string, unknown>;
/** 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 (
<div
{...(navIndex !== undefined && navItemAttrs(navIndex, jobTitle(job)))}
{...rowProps}
className={`${SURFACE.card} @container/dlrow group p-2.5 glass-hover transition`}
>
<div
className={`grid grid-cols-[auto_minmax(0,1fr)_auto] gap-x-3 gap-y-1 [grid-template-areas:'thumb_head_act''thumb_meta_act''thumb_src_act''thumb_prog_act'] @max-[40rem]/dlrow:grid-cols-[auto_minmax(0,1fr)] @max-[40rem]/dlrow:[grid-template-areas:'thumb_head''meta_meta''foot_foot''prog_prog']`}
>
<div className="[grid-area:thumb]">
<JobThumb job={job} />
</div>
<div className="[grid-area:head] min-w-0">
<div
className={`font-medium leading-snug break-words line-clamp-1 @max-[40rem]/dlrow:line-clamp-2`}
>
{(job.job_kind === "edit" || job.job_kind === "clip") && (
<span className="mr-1.5 inline-flex items-center gap-1 px-1.5 py-0.5 align-middle rounded-sm text-2xs font-semibold uppercase tracking-wide bg-accent/15 text-accent">
<Scissors className="w-3 h-3" /> {t("downloads.clipBadge")}
</span>
)}
{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).
<a
href={job.source_url!}
target="_blank"
rel="noopener noreferrer"
title={`${t("downloads.source.open")} — ${job.source_url}`}
className="inline-block max-w-full truncate align-top leading-6 hover:text-accent hover:underline pointer-coarse:leading-(--touch-target)"
{...nativeMenu}
>
{jobTitle(job)}
</a>
) : (
jobTitle(job)
)}
</div>
<ChannelLine job={job} />
</div>
{/* 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. */}
<div data-dl-meta className="[grid-area:meta] min-w-0 truncate text-sm">
<StatusPill job={job} />
{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.
<span className="text-muted uppercase"> · {job.asset.container}</span>
) : null}
{job.asset?.size_bytes ? (
<span className="text-muted"> · {formatBytes(job.asset.size_bytes, lang)}</span>
) : null}
{job.asset?.duration_s ? (
<span className="text-muted"> · {formatDuration(job.asset.duration_s)}</span>
) : null}
{job.status === "done" && job.created_at ? (
<span className="text-muted" title={formatDateTime(job.created_at, lang)}>
{" · "}
<CalendarClock className="inline w-3.5 h-3.5 align-[-0.15em]" />{" "}
{relativeTime(job.created_at, lang, t)}
</span>
) : null}
{job.status === "error" ? (
<>
{" "}
<Pressable
onClick={() => 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"))}{" "}
<span className="text-muted underline">
{t("downloads.errors.fail.detailsLink")}
</span>
</Pressable>
</>
) : null}
</div>
{/* 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?"). */}
<div className="contents @max-[40rem]/dlrow:[grid-area:foot] @max-[40rem]/dlrow:flex @max-[40rem]/dlrow:flex-wrap @max-[40rem]/dlrow:items-center @max-[40rem]/dlrow:gap-x-2">
{(!!sourceLink || !!job.extra_links?.length) && (
<div className="[grid-area:src] min-w-0 @max-[40rem]/dlrow:min-w-32 @max-[40rem]/dlrow:flex-1">
{sourceLink && <SourceRef url={sourceLink} />}
{job.extra_links?.map((url) => (
<SourceRef key={url} url={url} />
))}
</div>
)}
{/* 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. */}
<div className="[grid-area:act] flex items-start gap-1 shrink-0 @max-[40rem]/dlrow:ml-auto">
{children}
{more}
</div>
</div>
{running && (
<div className="[grid-area:prog] mt-0.5">
{job.phase && !DOWNLOAD_PHASES.includes(job.phase) ? (
// ffmpeg post-steps aren't byte-progress — show an indeterminate pulse, no %.
<div className="h-1.5 rounded-full bg-surface overflow-hidden">
<div className="h-full w-full bg-accent/60 animate-pulse" />
</div>
) : (
<>
<div className="h-1.5 rounded-full bg-surface overflow-hidden">
<div
className="h-full bg-accent transition-all"
style={{ width: `${job.progress}%` }}
/>
</div>
<div className="text-xs text-muted mt-0.5 flex gap-2">
<span>{job.progress}%</span>
{job.speed_bps ? <span>{formatSpeed(job.speed_bps, lang)}</span> : null}
{job.eta_s ? <span>{formatEta(t, job.eta_s)}</span> : null}
</div>
</>
)}
</div>
)}
</div>
</div>
);
}
+2 -7
View File
@@ -1,7 +1,7 @@
import type { DownloadJob } from "../lib/api"; import type { DownloadJob } from "../lib/api";
// A download job's display title (a user rename wins over the extracted asset title), shared by the // 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 // 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). // 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 { 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. // 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 { try {
const u = new URL(url); const u = new URL(url);
return (u.host + u.pathname + u.search).replace(/\/$/, ""); return (u.host + u.pathname + u.search).replace(/\/$/, "");
@@ -20,7 +20,6 @@ const CARDS = [
"EpisodeCard", "EpisodeCard",
"PosterTile", "PosterTile",
"ShelfTile", "ShelfTile",
"DownloadRow",
]; ];
const LISTS = ["NavGrid", "Carousel", "VirtualGrid"]; const LISTS = ["NavGrid", "Carousel", "VirtualGrid"];
@@ -28,7 +28,6 @@ const PlexSidebar = lazyWithPreload(() => import("./PlexSidebar"));
const NotificationsPanel = lazyWithPreload(() => import("./NotificationsPanel")); const NotificationsPanel = lazyWithPreload(() => import("./NotificationsPanel"));
const Files = lazyWithPreload(() => import("./Files")); const Files = lazyWithPreload(() => import("./Files"));
const FilesRail = lazyWithPreload(() => import("./FilesRail")); const FilesRail = lazyWithPreload(() => import("./FilesRail"));
const DownloadCenter = lazyWithPreload(() => import("./DownloadCenter"));
const Stats = lazyWithPreload(() => import("./Stats")); const Stats = lazyWithPreload(() => import("./Stats"));
const Scheduler = lazyWithPreload(() => import("./Scheduler")); const Scheduler = lazyWithPreload(() => import("./Scheduler"));
const ConfigPanel = lazyWithPreload(() => import("./ConfigPanel")); const ConfigPanel = lazyWithPreload(() => import("./ConfigPanel"));
@@ -57,7 +56,6 @@ export const PAGE_CONTENT: Record<Page, PageDef> = {
plex: { Component: PlexBrowse, maxWidth: "100rem" }, plex: { Component: PlexBrowse, maxWidth: "100rem" },
notifications: { Component: NotificationsPanel, maxWidth: "48rem" }, notifications: { Component: NotificationsPanel, maxWidth: "48rem" },
files: { Component: Files, maxWidth: "80rem" }, files: { Component: Files, maxWidth: "80rem" },
downloads: { Component: DownloadCenter, maxWidth: "56rem" },
stats: { Component: Stats }, stats: { Component: Stats },
scheduler: { Component: Scheduler }, scheduler: { Component: Scheduler },
config: { Component: ConfigPanel }, config: { Component: ConfigPanel },
+12 -2
View File
@@ -69,13 +69,23 @@ describe("loadInitialPage precedence", () => {
// whatever you happened to close the app on is precisely the habit the anti-paralysis surface // 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. // 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", () => { 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"); expect(loadInitialPage()).toBe("home");
}); });
it("opting out restores the account's stored landing page", () => { 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.startOnHome] = START_ON_HOME.off;
stored[LS.page] = "downloads"; stored[LS.page] = "downloads";
expect(loadInitialPage()).toBe("downloads"); expect(loadInitialPage()).toBe("files");
}); });
it("opted out with no / invalid stored page still defaults to feed", () => { it("opted out with no / invalid stored page still defaults to feed", () => {
stored[LS.startOnHome] = START_ON_HOME.off; stored[LS.startOnHome] = START_ON_HOME.off;
+2 -3
View File
@@ -2,7 +2,7 @@
// provider file keeps react-refresh happy (component-only exports). `loadInitialPage` picks the landing // 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 // module from a bookmark's params; `channelViewFrom` is the shared _chan/?chan resolver behind BOTH the
// channelView seed and the onPop handler. // 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"; import { getAccountRaw, LS } from "../lib/storage";
export const PAGE_KEY = LS.page; 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 // replace. The account can opt out (Settings → Appearance → Startup) and get the remembered
// module back. // module back.
if (getAccountRaw(START_HOME_KEY) !== START_ON_HOME.off) return "home"; if (getAccountRaw(START_HOME_KEY) !== START_ON_HOME.off) return "home";
const stored = getAccountRaw(PAGE_KEY); return resolvePage(getAccountRaw(PAGE_KEY)) ?? "feed";
return isPage(stored) ? stored : "feed";
} }
/** Resolve the open channel page from a landed history entry: the in-app `_chan` id (with its optional /** Resolve the open channel page from a landed history entry: the in-app `_chan` id (with its optional
@@ -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<typeof import("react-i18next")>()),
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<Row>[] = [
{ 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: () => <button>more</button> },
];
describe("DataTable cards", () => {
it("gives a cardRow column a line of its own; the others flow", () => {
narrow(false);
render(<DataTable rows={[{ id: "a" }]} columns={columns} rowKey={(r) => r.id} />);
expect(screen.getByText("more").parentElement!.className).toContain("basis-full");
expect(screen.getByText("1 MB").parentElement!.className).not.toContain("basis-full");
});
});
@@ -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<typeof import("react-i18next")>()),
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
}));
afterEach(() => {
cleanup();
localStorage.clear();
});
type Row = { id: string };
const columns: Column<Row>[] = [{ 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(<DataTable rows={rows} columns={columns} rowKey={(r) => 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(
<DataTable rows={rows} columns={columns} rowKey={(r) => r.id} pager={false} persistKey="t" />,
);
expect(screen.getByText("row 12")).toBeTruthy();
expect(screen.queryByText("datatable.rowsPerPage")).toBeNull();
});
});
+26 -16
View File
@@ -94,6 +94,7 @@ export default function DataTable<T>({
rowKey, rowKey,
pageSize = 10, pageSize = 10,
pageSizeOptions = [10, 25, 50, 100], pageSizeOptions = [10, 25, 50, 100],
pager = true,
persistKey, persistKey,
emptyText, emptyText,
rowClassName, rowClassName,
@@ -119,6 +120,10 @@ export default function DataTable<T>({
rowKey: (row: T) => string; rowKey: (row: T) => string;
pageSize?: number; pageSize?: number;
pageSizeOptions?: 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; persistKey?: string;
emptyText?: string; emptyText?: string;
rowClassName?: (row: T) => string; rowClassName?: (row: T) => string;
@@ -191,7 +196,8 @@ export default function DataTable<T>({
const [internalFilters, setInternalFilters] = useState<FilterMap>(initial.filters); const [internalFilters, setInternalFilters] = useState<FilterMap>(initial.filters);
const filters = controlledFilters ? (filtersProp ?? EMPTY_FILTERS) : internalFilters; const filters = controlledFilters ? (filtersProp ?? EMPTY_FILTERS) : internalFilters;
const [page, setPage] = useState(initial.page); 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 wide = useMediaQuery("(min-width: 48rem)");
const bodyRef = useRef<HTMLTableSectionElement>(null); const bodyRef = useRef<HTMLTableSectionElement>(null);
const cardsRef = useRef<HTMLDivElement>(null); const cardsRef = useRef<HTMLDivElement>(null);
@@ -315,20 +321,21 @@ export default function DataTable<T>({
// Stays NULLABLE: the wrappers below decide whether to render a controls row at all, and an always // 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 // -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. // with nothing to page. The emptiness rule itself lives in Pager, so the two can't drift apart.
const pagerNode = hasPagerContent(sorted.length, size) ? ( const pagerNode =
<Pager pager && hasPagerContent(sorted.length, size) ? (
page={safePage} <Pager
size={size} page={safePage}
total={sorted.length} size={size}
pageSizeOptions={pageSizeOptions} total={sorted.length}
onPageChange={setPage} pageSizeOptions={pageSizeOptions}
onSizeChange={(s) => { onPageChange={setPage}
setSize(s); onSizeChange={(s) => {
setPage(0); setSize(s);
}} setPage(0);
sizeLabel={t("datatable.rowsPerPage")} }}
/> sizeLabel={t("datatable.rowsPerPage")}
) : null; />
) : null;
const controls = const controls =
controlsLeading != null || pagerNode ? ( controlsLeading != null || pagerNode ? (
@@ -424,7 +431,10 @@ export default function DataTable<T>({
{columns {columns
.filter((c) => !c.hideInCard && !c.cardPrimary) .filter((c) => !c.hideInCard && !c.cardPrimary)
.map((col) => ( .map((col) => (
<span key={col.key} className="inline-flex items-center gap-1"> <span
key={col.key}
className={`inline-flex items-center gap-1${col.cardRow ? " basis-full" : ""}`}
>
{col.cardLabel !== false && ( {col.cardLabel !== false && (
<span className="text-muted text-xs">{col.header}</span> <span className="text-muted text-xs">{col.header}</span>
)} )}
@@ -45,7 +45,6 @@ const PLAIN: Record<string, [number, string]> = {
"UploadDialog.tsx": [1, "the drop zone"], "UploadDialog.tsx": [1, "the drop zone"],
"VideoEditor.tsx": [2, "timeline glyphs"], "VideoEditor.tsx": [2, "timeline glyphs"],
"Welcome.tsx": [1, "a screenshot (zoom)"], "Welcome.tsx": [1, "a screenshot (zoom)"],
"downloadRow.tsx": [1, "the error-details link"],
"playlistColumns.tsx": [1, "the thumbnail"], "playlistColumns.tsx": [1, "the thumbnail"],
"plexDetailUi.tsx": [2, "a switch row and the poster"], "plexDetailUi.tsx": [2, "a switch row and the poster"],
"plexViews.tsx": [1, "the rating filter (a number)"], "plexViews.tsx": [1, "the rating filter (a number)"],
+31 -18
View File
@@ -1,5 +1,4 @@
{ {
"navLabel": "Downloads",
"menu": { "menu": {
"download": "Download…" "download": "Download…"
}, },
@@ -19,9 +18,7 @@
"manage": "Manage formats…" "manage": "Manage formats…"
}, },
"page": { "page": {
"paste": "Paste link from clipboard", "paste": "Paste link from clipboard"
"upload": "Upload",
"manage": "Manage formats…"
}, },
"upload": { "upload": {
"title": "Upload a file", "title": "Upload a file",
@@ -37,10 +34,6 @@
"addedConverting": "File uploaded — converting it so it plays on any phone", "addedConverting": "File uploaded — converting it so it plays on any phone",
"failed": "Couldn't upload the file" "failed": "Couldn't upload the file"
}, },
"tabs": {
"queue": "Queue",
"system": "System"
},
"status": { "status": {
"queued": "Queued", "queued": "Queued",
"running": "Downloading", "running": "Downloading",
@@ -69,7 +62,6 @@
"retry": "Retry", "retry": "Retry",
"download": "Save to my device", "download": "Save to my device",
"play": "Play", "play": "Play",
"rename": "Rename",
"edit": "Edit / trim…", "edit": "Edit / trim…",
"redownload": "Re-download (rebuild file)", "redownload": "Re-download (rebuild file)",
"removeShared": "Remove from my list" "removeShared": "Remove from my list"
@@ -78,12 +70,8 @@
"unsupported": "Your browser can't play this file." "unsupported": "Your browser can't play this file."
}, },
"empty": { "empty": {
"queue": "Nothing in the queue.",
"admin": "No downloads on this instance yet." "admin": "No downloads on this instance yet."
}, },
"source": {
"open": "Open the original page in a new tab"
},
"edit": { "edit": {
"title": "Edit details", "title": "Edit details",
"channel": "Channel", "channel": "Channel",
@@ -157,7 +145,6 @@
"profiles": { "profiles": {
"title": "Download formats", "title": "Download formats",
"builtin": "Built-in", "builtin": "Built-in",
"yours": "Your formats",
"new": "New format", "new": "New format",
"name": "Name", "name": "Name",
"namePlaceholder": "My format", "namePlaceholder": "My format",
@@ -175,7 +162,17 @@
"save": "Save", "save": "Save",
"create": "Create", "create": "Create",
"delete": "Delete", "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": { "admin": {
"filesOnDisk": "Files on disk", "filesOnDisk": "Files on disk",
@@ -192,10 +189,27 @@
"reset": "Reset to default", "reset": "Reset to default",
"save": "Save", "save": "Save",
"quotaPickPlaceholder": "Set a user's quota…", "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", "clipBadge": "Clip",
"rowMenu": "Download actions",
"errors": { "errors": {
"quota": { "quota": {
"max_bytes": "You've used your download storage quota ({{size}} GB). Free up space first.", "max_bytes": "You've used your download storage quota ({{size}} GB). Free up space first.",
@@ -223,7 +237,6 @@
"title": "Couldn't download", "title": "Couldn't download",
"titleUpload": "The upload isn't ready", "titleUpload": "The upload isn't ready",
"titleEdit": "The clip isn't ready", "titleEdit": "The clip isn't ready",
"detailsLink": "details",
"detailsToggle": "Technical details", "detailsToggle": "Technical details",
"upload": { "upload": {
"quota_exceeded": { "quota_exceeded": {
-1
View File
@@ -28,7 +28,6 @@
"goPlex": "Go to Plex", "goPlex": "Go to Plex",
"goNotifications": "Go to Notifications", "goNotifications": "Go to Notifications",
"goFiles": "Go to Files", "goFiles": "Go to Files",
"goDownloads": "Go to Downloads",
"goStats": "Go to Stats", "goStats": "Go to Stats",
"goSettings": "Go to Settings", "goSettings": "Go to Settings",
"search": "Jump to the search box", "search": "Jump to the search box",
-1
View File
@@ -14,7 +14,6 @@
"plex": "Plex", "plex": "Plex",
"notifications": "Notifications", "notifications": "Notifications",
"files": "Files", "files": "Files",
"downloads": "Downloads",
"stats": "Stats", "stats": "Stats",
"scheduler": "Scheduler", "scheduler": "Scheduler",
"config": "Config", "config": "Config",
+6 -1
View File
@@ -4,7 +4,8 @@
"appearance": "Appearance", "appearance": "Appearance",
"notifications": "Notifications", "notifications": "Notifications",
"account": "Account", "account": "Account",
"diagnostics": "Diagnostics" "diagnostics": "Diagnostics",
"downloads": "Downloads"
}, },
"plexSync": { "plexSync": {
"title": "Plex watch sync", "title": "Plex watch sync",
@@ -230,5 +231,9 @@
"clear": "Clear", "clear": "Clear",
"empty": "No event matches the filter.", "empty": "No event matches the filter.",
"emptyAll": "No events yet." "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."
} }
} }
+2 -1
View File
@@ -1,7 +1,8 @@
{ {
"tabs": { "tabs": {
"roles": "Users & roles", "roles": "Users & roles",
"access": "Access requests" "access": "Access requests",
"storage": "Storage"
}, },
"pendingAccess": { "pendingAccess": {
"title": "Signed up, but still outside", "title": "Signed up, but still outside",
+4 -4
View File
@@ -14,7 +14,7 @@
"channels": "Channels", "channels": "Channels",
"playlists": "Playlists", "playlists": "Playlists",
"player": "In-app player", "player": "In-app player",
"downloads": "Downloads", "files": "Your files",
"search": "Search results", "search": "Search results",
"plex": "Your Plex library" "plex": "Your Plex library"
}, },
@@ -40,9 +40,9 @@
"title": "Watch in-app, resume anywhere", "title": "Watch in-app, resume anywhere",
"body": "A built-in player picks each video up where you left off, with keyboard and scroll controls." "body": "A built-in player picks each video up where you left off, with keyboard and scroll controls."
}, },
"downloads": { "files": {
"title": "Downloads", "title": "Files",
"body": "Save videos to your server in a Plex-friendly layout, trim or join them, then share a link." "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": { "plex": {
"title": "Your Plex library too", "title": "Your Plex library too",
+31 -18
View File
@@ -1,5 +1,4 @@
{ {
"navLabel": "Letöltések",
"menu": { "menu": {
"download": "Letöltés…" "download": "Letöltés…"
}, },
@@ -19,9 +18,7 @@
"manage": "Formátumok kezelése…" "manage": "Formátumok kezelése…"
}, },
"page": { "page": {
"paste": "Link beillesztése a vágólapról", "paste": "Link beillesztése a vágólapról"
"upload": "Feltöltés",
"manage": "Formátumok kezelése…"
}, },
"upload": { "upload": {
"title": "Fájl feltöltése", "title": "Fájl feltöltése",
@@ -37,10 +34,6 @@
"addedConverting": "Fájl feltöltve — átalakítjuk, hogy minden telefonon lejátszható legyen", "addedConverting": "Fájl feltöltve — átalakítjuk, hogy minden telefonon lejátszható legyen",
"failed": "Nem sikerült feltölteni a fájlt" "failed": "Nem sikerült feltölteni a fájlt"
}, },
"tabs": {
"queue": "Sor",
"system": "Rendszer"
},
"status": { "status": {
"queued": "Várakozik", "queued": "Várakozik",
"running": "Letöltés", "running": "Letöltés",
@@ -69,7 +62,6 @@
"retry": "Újra", "retry": "Újra",
"download": "Mentés a gépemre", "download": "Mentés a gépemre",
"play": "Lejátszás", "play": "Lejátszás",
"rename": "Átnevezés",
"edit": "Szerkesztés / vágás…", "edit": "Szerkesztés / vágás…",
"redownload": "Újraletöltés (fájl újraépítése)", "redownload": "Újraletöltés (fájl újraépítése)",
"removeShared": "Eltávolítás a listámból" "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." "unsupported": "A böngésződ nem tudja lejátszani ezt a fájlt."
}, },
"empty": { "empty": {
"queue": "A sor üres.",
"admin": "Ezen a példányon még nincs letöltés." "admin": "Ezen a példányon még nincs letöltés."
}, },
"source": {
"open": "Eredeti oldal megnyitása új lapon"
},
"edit": { "edit": {
"title": "Adatok szerkesztése", "title": "Adatok szerkesztése",
"channel": "Csatorna", "channel": "Csatorna",
@@ -157,7 +145,6 @@
"profiles": { "profiles": {
"title": "Letöltési formátumok", "title": "Letöltési formátumok",
"builtin": "Beépített", "builtin": "Beépített",
"yours": "Saját formátumaid",
"new": "Új formátum", "new": "Új formátum",
"name": "Név", "name": "Név",
"namePlaceholder": "Saját formátum", "namePlaceholder": "Saját formátum",
@@ -175,7 +162,17 @@
"save": "Mentés", "save": "Mentés",
"create": "Létrehozás", "create": "Létrehozás",
"delete": "Törlé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": { "admin": {
"filesOnDisk": "Fájlok a lemezen", "filesOnDisk": "Fájlok a lemezen",
@@ -192,10 +189,27 @@
"reset": "Visszaállítás alapértékre", "reset": "Visszaállítás alapértékre",
"save": "Mentés", "save": "Mentés",
"quotaPickPlaceholder": "Kvóta beállítása egy felhasználónak…", "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", "clipBadge": "Klip",
"rowMenu": "Letöltés műveletei",
"errors": { "errors": {
"quota": { "quota": {
"max_bytes": "Elfogyott a letöltési tárhelykereted ({{size}} GB). Előbb szabadíts fel helyet.", "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", "title": "Nem sikerült letölteni",
"titleUpload": "A feltöltés nem készült el", "titleUpload": "A feltöltés nem készült el",
"titleEdit": "A klip nem készült el", "titleEdit": "A klip nem készült el",
"detailsLink": "részletek",
"detailsToggle": "Technikai részletek", "detailsToggle": "Technikai részletek",
"upload": { "upload": {
"quota_exceeded": { "quota_exceeded": {
-1
View File
@@ -28,7 +28,6 @@
"goPlex": "Ugrás: Plex", "goPlex": "Ugrás: Plex",
"goNotifications": "Ugrás: Értesítések", "goNotifications": "Ugrás: Értesítések",
"goFiles": "Ugrás a Fájlokhoz", "goFiles": "Ugrás a Fájlokhoz",
"goDownloads": "Ugrás: Letöltések",
"goStats": "Ugrás: Statisztika", "goStats": "Ugrás: Statisztika",
"goSettings": "Ugrás: Beállítások", "goSettings": "Ugrás: Beállítások",
"search": "Ugrás a keresőbe", "search": "Ugrás a keresőbe",
-1
View File
@@ -14,7 +14,6 @@
"plex": "Plex", "plex": "Plex",
"notifications": "Értesítések", "notifications": "Értesítések",
"files": "Fájlok", "files": "Fájlok",
"downloads": "Letöltések",
"stats": "Statisztika", "stats": "Statisztika",
"scheduler": "Ütemező", "scheduler": "Ütemező",
"config": "Konfig", "config": "Konfig",
+6 -1
View File
@@ -4,7 +4,8 @@
"appearance": "Megjelenés", "appearance": "Megjelenés",
"notifications": "Értesítések", "notifications": "Értesítések",
"account": "Fiók", "account": "Fiók",
"diagnostics": "Diagnosztika" "diagnostics": "Diagnosztika",
"downloads": "Letöltések"
}, },
"plexSync": { "plexSync": {
"title": "Plex nézettség-szinkron", "title": "Plex nézettség-szinkron",
@@ -230,5 +231,9 @@
"clear": "Ürítés", "clear": "Ürítés",
"empty": "Nincs a szűrésnek megfelelő esemény.", "empty": "Nincs a szűrésnek megfelelő esemény.",
"emptyAll": "Még nincs 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."
} }
} }
+2 -1
View File
@@ -1,7 +1,8 @@
{ {
"tabs": { "tabs": {
"roles": "Felhasználók és szerepek", "roles": "Felhasználók és szerepek",
"access": "Hozzáférési kérelmek" "access": "Hozzáférési kérelmek",
"storage": "Tárhely"
}, },
"pendingAccess": { "pendingAccess": {
"title": "Regisztrált, de még kívül van", "title": "Regisztrált, de még kívül van",
+4 -4
View File
@@ -14,7 +14,7 @@
"channels": "Csatornák", "channels": "Csatornák",
"playlists": "Lejátszási listák", "playlists": "Lejátszási listák",
"player": "Beépített lejátszó", "player": "Beépített lejátszó",
"downloads": "Letöltések", "files": "A fájljaid",
"search": "Keresési találatok", "search": "Keresési találatok",
"plex": "A Plex-könyvtárad" "plex": "A Plex-könyvtárad"
}, },
@@ -40,9 +40,9 @@
"title": "Nézd appon belül, folytasd bárhonnan", "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." "body": "A beépített lejátszó onnan folytatja a videót, ahol abbahagytad — billentyű- és görgetés-vezérléssel."
}, },
"downloads": { "files": {
"title": "Letöltések", "title": "Fájlok",
"body": "Ments videókat a szerveredre Plex-barát elrendezésben, vágd vagy fűzd össze őket, majd oszd meg egy linkkel." "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": { "plex": {
"title": "A Plex-könyvtárad is", "title": "A Plex-könyvtárad is",
+14 -1
View File
@@ -112,6 +112,8 @@ export interface DownloadJob {
expires_at: string | null; expires_at: string | null;
asset: DownloadAsset | null; asset: DownloadAsset | null;
shared?: boolean; 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; user_email?: string;
// Where the viewer stopped in it (whole seconds; 0 = from the start). Only the two library listings // 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. // (`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. // languages is exactly the drift that CLAUDE.md's comment-vs-code rule is about.
export const DISPLAY_NAME_MAX = 255; 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 { export interface AdminStorage {
files_on_disk: number; files_on_disk: number;
total_bytes: number; total_bytes: number;
total_assets: number; total_assets: number;
total_cap_bytes: number; total_cap_bytes: number;
per_user: { user_id: number; email: string | null; footprint_bytes: number }[]; per_user: AdminStorageUser[];
} }
export interface AdminDownloadQuota { export interface AdminDownloadQuota {
+22 -3
View File
@@ -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 // 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). // ("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 // Watched where the queue is FETCHED, not in a component's effect: every reader of the queue fetches
// tray, the Downloads page) fetches through `fetchQueue`, so a finish is seen once whichever of them // through `fetchQueue`, so a finish is seen once whichever of them polled, and nothing has to diff the
// polled, and nothing has to diff the previous render's data. // previous render's data.
/** The statuses a job leaves on its own: it is waiting, or working. */ /** The statuses a job leaves on its own: it is waiting, or working. */
export const IN_FLIGHT: ReadonlySet<string> = new Set(["queued", "running", "paused"]); export const IN_FLIGHT: ReadonlySet<string> = new Set(["queued", "running", "paused"]);
@@ -14,6 +14,13 @@ export const IN_FLIGHT: ReadonlySet<string> = new Set(["queued", "running", "pau
let lastSeen: Map<number, string> | null = null; let lastSeen: Map<number, string> | null = null;
const finished = createStore<QueueJob[]>([]); const finished = createStore<QueueJob[]>([]);
const unannounced = createStore<QueueJob[]>([]); const unannounced = createStore<QueueJob[]>([]);
const revealing = createStore<Reveal | null>(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 /** 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. */ * 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; 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). */ /** Forget what was watched (a test starts from a tab that has looked at nothing). */
export function resetQueueWatch(): void { export function resetQueueWatch(): void {
lastSeen = null; lastSeen = null;
finished.set([]); finished.set([]);
unannounced.set([]); unannounced.set([]);
revealing.set(null);
} }
-6
View File
@@ -45,12 +45,6 @@ export function formatBytes(bytes: number | null | undefined, lang: string): str
return `${num} ${units[i]}`; 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, /** 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 * 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 * longer be paired with the wrong label, which is exactly what shifted every tier by one from
+1 -1
View File
@@ -84,7 +84,7 @@ describe("two-step sequences", () => {
dispatch(press({ key: "g" }), "page", pageHandlers(calls)); dispatch(press({ key: "g" }), "page", pageHandlers(calls));
dispatch(press({ key: "Shift", shiftKey: true }), "page", pageHandlers(calls)); dispatch(press({ key: "Shift", shiftKey: true }), "page", pageHandlers(calls));
dispatch(press({ key: "d" }), "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 // No second step needs a modifier (see the page-level rows below), so a chord with one is the reader changing their mind: it
+2 -8
View File
@@ -117,14 +117,8 @@ export const SHORTCUTS = [
labelKey: "keys.goNotifications", labelKey: "keys.goNotifications",
page: "notifications", page: "notifications",
}, },
{ id: "goFiles", scope: "page", keys: ["g l"], labelKey: "keys.goFiles", page: "files" }, // "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: "goDownloads",
scope: "page",
keys: ["g d"],
labelKey: "keys.goDownloads",
page: "downloads",
},
{ id: "goStats", scope: "page", keys: ["g s"], labelKey: "keys.goStats", page: "stats" }, { id: "goStats", scope: "page", keys: ["g s"], labelKey: "keys.goStats", page: "stats" },
{ id: "goSettings", scope: "page", keys: ["g ,"], labelKey: "keys.goSettings", page: "settings" }, { id: "goSettings", scope: "page", keys: ["g ,"], labelKey: "keys.goSettings", page: "settings" },
-8
View File
@@ -3,7 +3,6 @@ import {
BarChart3, BarChart3,
Bell, Bell,
Clapperboard, Clapperboard,
Download,
Home, Home,
ListVideo, ListVideo,
ScrollText, ScrollText,
@@ -95,13 +94,6 @@ const MODULES: readonly ModuleDef[] = [
railKey: "nav.rail.files", railKey: "nav.rail.files",
icon: FolderOpen, icon: FolderOpen,
}, },
{
id: "downloads",
labelKey: "downloads.navLabel",
railKey: "nav.rail.downloads",
icon: Download,
hasBadge: true,
},
{ {
id: "stats", id: "stats",
labelKey: "header.account.stats", labelKey: "header.account.stats",
+1 -16
View File
@@ -1,21 +1,6 @@
import type { Page } from "./urlState"; import type { Page } from "./urlState";
// Tiny decoupled navigator so deep components (a feed card's download toast, the Download // Page links: a page's own address, and whether a click on one is the app's to handle.
// 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);
}
/** A page's own address, for a link to it (the nav rail's rows). Only the page: the rest of a page's /** 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, * state — the feed's filters, Plex's library — a fresh tab restores from the account's own storage,
+10 -1
View File
@@ -22,7 +22,7 @@ afterEach(() => {
vi.useRealTimers(); vi.useRealTimers();
}); });
const ALL: Page[] = ["home", "feed", "channels", "playlists", "plex", "downloads", "settings"]; const ALL: Page[] = ["home", "feed", "channels", "playlists", "plex", "files", "settings"];
function Host({ function Host({
onNavigate, onNavigate,
@@ -61,6 +61,15 @@ describe("the g chords", () => {
expect(nav.mock.calls).toEqual([["feed"], ["settings"]]); 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(<Host onNavigate={nav} />);
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. // `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", () => { it("take the h of g h away from the help sheet", () => {
const nav = vi.fn(); const nav = vi.fn();
-1
View File
@@ -12,7 +12,6 @@ const EXPECTED: Record<Page, string> = {
playlists: "header.account.playlists", playlists: "header.account.playlists",
notifications: "inbox.navLabel", notifications: "inbox.navLabel",
files: "files.navLabel", files: "files.navLabel",
downloads: "downloads.navLabel",
stats: "header.usageStats", stats: "header.usageStats",
plex: "plex.navLabel", plex: "plex.navLabel",
scheduler: "header.scheduler", scheduler: "header.scheduler",
+3
View File
@@ -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 // 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. // changes. Per-account mirror of the server pref.
downloadTrayClosed: "siftlode.downloadTrayClosed", 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", hints: "siftlode.hints",
lang: "siftlode.lang", lang: "siftlode.lang",
filters: "siftlode.filters", filters: "siftlode.filters",
+3
View File
@@ -37,6 +37,9 @@ export interface Column<T> {
cardPrimary?: boolean; cardPrimary?: boolean;
hideInCard?: boolean; hideInCard?: boolean;
cardLabel?: 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 // 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 // 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 // no width. The table's own width, not the viewport's (an `@container` round it): beside the
+16 -4
View File
@@ -132,20 +132,32 @@ const PAGES = [
"logs", "logs",
"notifications", "notifications",
"files", "files",
"downloads",
"plex", "plex",
] as const; ] as const;
export type Page = (typeof PAGES)[number]; export type Page = (typeof PAGES)[number];
/** Narrow an arbitrary string to a known Page (else null). */ /** 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); 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<Record<string, Page>> = {
// 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 { export function readPage(): Page {
const p = new URLSearchParams(window.location.search).get("page"); return resolvePage(new URLSearchParams(window.location.search).get("page")) ?? "feed";
return isPage(p) ? p : "feed";
} }
/** Build a URL (path + query + fragment) from the CURRENT location with `updates` applied to the query — /** Build a URL (path + query + fragment) from the CURRENT location with `updates` applied to the query —