feat(design): the download, notification and Scheduler action lists (C1) — the rows' "…", the band's and the strip's "…" (E28 S3e3)

A download row keeps play (pause / resume / retry in the queue) and the save link; the editors,
sharing, re-download, the copies and cancel / delete / remove moved into its "…"
(useDownloadActions). A link inside the row keeps the browser's own right-click menu. A
notification row keeps its link and its unread mark and shows its dismiss on hover; an activity
notice's Find in feed and Undo are in its "…". The inbox band keeps Mark all read and puts Clear
everything, now behind a confirm, in its "…". The Scheduler strip keeps Run all and Pause and puts
Purge discovery in its "…". Polled lists key their menus by id (keyOf). ui/menuGroups draws a
rule only between two non-empty groups. Find in feed also drops the tag, search and age filters
that could hide the video it looks for.
This commit is contained in:
2026-09-26 07:10:02 +02:00
parent c4917619ee
commit a2d17d693c
16 changed files with 1455 additions and 324 deletions
@@ -77,6 +77,24 @@ afterEach(() => {
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"), job(2, "done")];
@@ -86,12 +104,15 @@ describe("DownloadCenter confirm dialogs", () => {
</QueryClientProvider>,
);
fireEvent.click(screen.getByTitle("downloads.actions.cancel"));
// E28 C1: 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));
// The tab's label shares its button with a count badge.
fireEvent.click(screen.getByRole("button", { name: /downloads\.tabs\.done/ }));
fireEvent.click(await screen.findByTitle("downloads.actions.redownload"));
fireEvent.click(screen.getByTitle("downloads.actions.delete"));
await screen.findByText("Clip 2");
await pick("Clip 2", "downloads.actions.redownload");
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(2));
await pick("Clip 2", "downloads.actions.delete");
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(3));
expect(mocks.confirm.mock.calls.map(([o]) => [o.title, o.confirmLabel])).toEqual([
@@ -0,0 +1,301 @@
// @vitest-environment jsdom
//
// E28 C1 on the Download Center: a download row keeps its primary action (play; pause / resume /
// retry in the queue) and the save link in sight, and everything else — the editors, sharing,
// re-download, the copies, cancel / delete / remove — is its ONE menu, opened by the row's "…" (it
// shows on hover or focus) or a right-click on the row. A link inside the row keeps the browser's own
// menu. i18n is key-passthrough, so labels read as keys.
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { api, type DownloadJob } from "../lib/api";
import { menuGroups } from "./ui/menuGroups";
const mocks = vi.hoisted(() => ({
jobs: [] as unknown[],
confirm: vi.fn(async (_o?: unknown) => true),
}));
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ 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" }) }));
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"],
extra: Partial<DownloadJob> = {},
): 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" },
display_name: `Clip ${id}`,
display_uploader: null,
display_uploader_url: null,
extra_links: [],
can_download: status === "done",
expired: false,
job_kind: "download",
asset: { media_kind: "video", duration_s: 60 },
...extra,
}) as unknown as DownloadJob;
afterEach(() => {
cleanup();
vi.restoreAllMocks();
mocks.confirm.mockClear();
});
function mount(tab?: string) {
render(
<QueryClientProvider client={new QueryClient()}>
<DownloadCenter />
</QueryClientProvider>,
);
if (tab)
fireEvent.click(screen.getByRole("button", { name: new RegExp(`downloads\\.tabs\\.${tab}`) }));
}
const rowOf = (text: string) => screen.getByText(text).closest<HTMLElement>("[data-nav-index]")!;
const moreOf = (text: string) =>
rowOf(text).querySelector<HTMLElement>('button[aria-label="downloads.rowMenu"]')!;
const menuRows = () =>
screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent ?? "");
async function openMenu(open: () => void) {
act(open);
await waitFor(() =>
expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0),
);
}
const rowButtons = (text: string) =>
[...rowOf(text).querySelectorAll<HTMLElement>("button, a[aria-label]")].map((b) =>
b.getAttribute("aria-label"),
);
describe("DownloadCenter · the queue row", () => {
it("shows pause and its '…' only; the '…' hides until hover or focus", async () => {
mocks.jobs = [job(1, "running")];
mount();
expect(rowButtons("Clip 1")).toEqual(["downloads.actions.pause", "downloads.rowMenu"]);
expect(moreOf("Clip 1").classList.contains("reveal")).toBe(true);
expect(rowOf("Clip 1").classList.contains("group")).toBe(true);
});
it("holds pause, the copies and cancel; a failed job's first row is Retry", async () => {
mocks.jobs = [job(1, "running", { source_url: "https://youtu.be/x" }), job(2, "error")];
mount();
await openMenu(() => fireEvent.click(moreOf("Clip 1")));
expect(menuRows()).toEqual([
"downloads.actions.pause",
"common.copy.title",
"common.copy.link",
"downloads.actions.cancel",
]);
expect(moreOf("Clip 1").getAttribute("aria-expanded")).toBe("true");
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
expect(menuRows()).toEqual([
"downloads.actions.retry",
"common.copy.title",
"downloads.actions.cancel",
]);
});
it("closes on a second press of the same '…'", async () => {
mocks.jobs = [job(1, "running")];
mount();
await openMenu(() => fireEvent.click(moreOf("Clip 1")));
// The press lands outside the panel, which floating-ui's dismiss closes on; the click after
// it must not open it again.
act(() => {
fireEvent.pointerDown(moreOf("Clip 1"));
fireEvent.mouseDown(moreOf("Clip 1"));
fireEvent.click(moreOf("Clip 1"), { detail: 1 });
});
await waitFor(() =>
expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0),
);
expect(moreOf("Clip 1").getAttribute("aria-expanded")).toBe("false");
});
it("still closes when a poll replaced the job under the open menu", async () => {
mocks.jobs = [job(1, "running")];
mount();
await openMenu(() => fireEvent.click(moreOf("Clip 1")));
// The queue polls while a job runs: the next render lists a NEW object for the same job.
mocks.jobs = [job(1, "running", { progress: 40 })];
fireEvent.change(screen.getByPlaceholderText("downloads.page.addUrl"), {
target: { value: "x" },
});
act(() => {
fireEvent.pointerDown(moreOf("Clip 1"));
fireEvent.mouseDown(moreOf("Clip 1"));
fireEvent.click(moreOf("Clip 1"), { detail: 1 });
});
await waitFor(() =>
expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0),
);
});
it("cancels only through the confirm", async () => {
mocks.jobs = [job(1, "queued")];
const cancel = vi.spyOn(api, "cancelDownload").mockResolvedValue({} as never);
mount();
const pickCancel = async () => {
await openMenu(() => fireEvent.click(moreOf("Clip 1")));
fireEvent.click(
screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "downloads.actions.cancel")!,
);
};
mocks.confirm.mockResolvedValueOnce(false);
await pickCancel();
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
expect(mocks.confirm).toHaveBeenCalledWith(expect.objectContaining({ danger: true }));
expect(cancel).not.toHaveBeenCalled();
await pickCancel();
await waitFor(() => expect(cancel).toHaveBeenCalledWith(1));
});
});
describe("DownloadCenter · the library row", () => {
it("shows play, save and its '…'; the editors, sharing and delete are in the menu", async () => {
mocks.jobs = [job(2, "done", { source_url: "https://youtu.be/x" })];
mount("done");
await screen.findByText("Clip 2");
expect(rowButtons("Clip 2")).toEqual([
"downloads.actions.play",
"downloads.actions.download",
"downloads.rowMenu",
]);
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
expect(menuRows()).toEqual([
"downloads.actions.play",
"downloads.actions.download",
"downloads.actions.edit",
"downloads.actions.editDetails",
"downloads.actions.redownload",
"downloads.actions.share",
"downloads.actions.shareManage",
"common.copy.title",
"common.copy.link",
"downloads.actions.delete",
]);
});
it("saves the file through the same link as the row's save button", async () => {
mocks.jobs = [job(2, "done")];
const followed: string[] = [];
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (
this: HTMLAnchorElement,
) {
followed.push(this.href);
});
mount("done");
await screen.findByText("Clip 2");
await openMenu(() => fireEvent.click(moreOf("Clip 2")));
fireEvent.click(
screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "downloads.actions.download")!,
);
const button = rowOf("Clip 2").querySelector<HTMLAnchorElement>(
'a[aria-label="downloads.actions.download"]',
)!;
expect(followed).toEqual([button.href]);
});
it("leaves out what a file or a clip cannot do, and no rule is left behind", async () => {
mocks.jobs = [job(3, "done", { job_kind: "upload", asset: { media_kind: "file" } } as never)];
mount("done");
await screen.findByText("Clip 3");
await openMenu(() => fireEvent.click(moreOf("Clip 3")));
expect(menuRows()).toEqual([
"downloads.actions.download",
"downloads.actions.share",
"downloads.actions.shareManage",
"common.copy.title",
"downloads.actions.delete",
]);
});
it("gives a link inside the row the browser's own menu", async () => {
mocks.jobs = [job(2, "done", { source_url: "https://youtu.be/x" })];
mount("done");
const link = await screen.findByText("youtu.be/x");
const ev = new MouseEvent("contextmenu", { bubbles: true, cancelable: true });
act(() => void link.dispatchEvent(ev));
expect(ev.defaultPrevented).toBe(false);
expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0);
});
});
describe("DownloadCenter · the shared row", () => {
it("holds play, save, edit, the copies and Remove from my list", async () => {
mocks.jobs = [];
vi.spyOn(api, "downloadsShared").mockResolvedValue([
job(4, "done", { can_download: true }),
] as never);
const remove = vi.spyOn(api, "removeSharedDownload").mockResolvedValue({} as never);
mount("shared");
await screen.findByText("Clip 4");
await openMenu(() => fireEvent.click(moreOf("Clip 4")));
expect(menuRows()).toEqual([
"downloads.actions.play",
"downloads.actions.download",
"downloads.actions.edit",
"common.copy.title",
"downloads.actions.removeShared",
]);
fireEvent.click(
screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "downloads.actions.removeShared")!,
);
await waitFor(() => expect(remove).toHaveBeenCalledWith(4));
});
});
describe("menuGroups", () => {
const row = (id: string) => ({ id, label: id, onSelect: () => {} });
it("rules between non-empty groups only", () => {
const out = menuGroups([], [row("a"), row("b")], [], [row("c")]);
expect(out.map((r) => [r.id, !!r.separatorBefore])).toEqual([
["a", false],
["b", false],
["c", true],
]);
});
});
+62 -169
View File
@@ -1,22 +1,15 @@
import { lazy, Suspense, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import {
Ban,
ClipboardPaste,
Download,
Link2,
Pause,
Play,
PlayCircle,
Plus,
RotateCcw,
Scissors,
Settings2,
Share2,
Pencil,
Trash2,
UploadCloud,
} from "lucide-react";
import Tabs from "./ui/Tabs";
@@ -30,12 +23,9 @@ const ProfileEditor = lazy(() => import("./ProfileEditor"));
const VideoEditor = lazy(() => import("./VideoEditor"));
const ShareDialog = lazy(() => import("./ShareDialog"));
const LibraryPlayer = lazy(() => import("./LibraryPlayer"));
import { useConfirm } from "./confirmContext";
import { useLiveQuery } from "../lib/useLiveQuery";
import { useMe } from "../lib/useMe";
import { api, type DownloadJob } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { invalidateDownloads, quickShareLink } from "../lib/downloads";
import { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
@@ -45,12 +35,16 @@ import { AdminSystem } from "./downloadAdmin";
import NavGrid from "./ui/NavGrid";
import { Input } from "./ui/Input";
import { IconLink } from "./ui/LinkButton";
import { useContextMenu } from "./ui/useContextMenu";
import { ContextMenu } from "./ui/ContextMenu";
import { MoreButton } from "./ui/MoreButton";
import { useDownloadActions, type DownloadTab } from "./useDownloadActions";
const jobKey = (job: DownloadJob) => job.id;
export default function DownloadCenter() {
const me = useMe();
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
// 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");
@@ -80,56 +74,35 @@ export default function DownloadCenter() {
const queue = jobs.filter((j) => ["queued", "running", "paused", "error"].includes(j.status));
const library = jobs.filter((j) => j.status === "done");
const invalidate = () => invalidateDownloads(qc);
const act = useMutation({
mutationFn: ({
id,
op,
}: {
id: number;
op: "pause" | "resume" | "cancel" | "delete" | "redownload";
}) =>
op === "pause"
? api.pauseDownload(id)
: op === "resume"
? api.resumeDownload(id)
: op === "cancel"
? api.cancelDownload(id)
: op === "redownload"
? api.redownloadDownload(id)
: api.deleteDownload(id),
onSuccess: invalidate,
// A 400/409/422/500 opens the global error dialog with the backend's own message (and is then
// skipped here); a job that is gone (404) or a lost connection is said here, naming the action.
onError: (e, v) => notifyActionError(e, t(`errors.writeFailed.job.${v.op}`)),
// --- The rows' ONE menu (E28 C1) -------------------------------------------------------------
// 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. The tab is the one on screen.
const rowMenu = useContextMenu<DownloadJob>({ keyOf: jobKey });
const actions = useDownloadActions({
onPlay: setPlayJob,
onEdit: setEditJob,
onEditDetails: setMetaJob,
onManageShares: setShareJob,
});
// Remove a shared-with-me item from your list (only your grant; the owner's file is untouched).
const removeShared = useMutation({
mutationFn: (id: number) => api.removeSharedDownload(id),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.downloadsShared() }),
meta: { failTitle: "errors.writeFailed.removeShared" },
});
// The confirm button names the ACTION ("Remove"), never the dialog's question: labelled with the title
// it read "Re-download this file?", and the cancel dialog's own "Cancel" would sit beside "Cancel".
const confirmThen = async (title: string, body: string, action: string, fn: () => void) => {
if (await confirm({ title, message: body, confirmLabel: action, danger: true })) fn();
};
// One-click share (E7 S3 · D2-A): ensure a downloadable link, copy it, toast with "open in new tab"
// (see quickShareLink). Re-clicking reuses the existing link; a per-job in-flight guard also stops a
// double-click from racing two POSTs into two links before the cache is primed. Advanced controls
// (password, expiry, revoke, share-with-a-user) live in the Share dialog, via the manage-share button.
const [sharingId, setSharingId] = useState<number | null>(null);
const quickShare = async (job: DownloadJob) => {
if (sharingId !== null) return;
setSharingId(job.id);
try {
await quickShareLink(qc, job, t);
} finally {
setSharingId(null);
}
const menuTarget = rowMenu.target;
const listTab: DownloadTab | null =
tab === "queue" || tab === "done" || tab === "shared" ? tab : null;
const menuItems = menuTarget && listTab ? actions.items(menuTarget, listTab) : [];
// 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"}
/>
),
};
};
// Pull a link off the clipboard into the add-URL box. Clipboard reads need a secure context +
@@ -226,18 +199,24 @@ export default function DownloadCenter() {
</div>
<NavGrid className="space-y-2">
{queue.map((job, i) => (
<DownloadRow key={job.id} job={job} navIndex={i} onShowError={setErrorJob}>
{(job.status === "queued" || job.status === "running") && (
<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={() => act.mutate({ id: job.id, op: "pause" })}
onClick={() => actions.run(job, "pause")}
title={t("downloads.actions.pause")}
>
<Pause className="w-4 h-4" />
</IconBtn>
)}
{(job.status === "paused" || job.status === "error") && (
) : (
<IconBtn
onClick={() => act.mutate({ id: job.id, op: "resume" })}
onClick={() => actions.run(job, "resume")}
title={t(
job.status === "error"
? "downloads.actions.retry"
@@ -247,20 +226,6 @@ export default function DownloadCenter() {
<Play className="w-4 h-4" />
</IconBtn>
)}
<IconBtn
onClick={() =>
confirmThen(
t("downloads.confirm.cancelTitle"),
t("downloads.confirm.cancelBody"),
t("downloads.confirm.cancelAction"),
() => act.mutate({ id: job.id, op: "cancel" }),
)
}
title={t("downloads.actions.cancel")}
danger
>
<Ban className="w-4 h-4" />
</IconBtn>
</DownloadRow>
))}
{queue.length === 0 &&
@@ -283,75 +248,20 @@ export default function DownloadCenter() {
<>
<UsageBar />
<NavGrid className="space-y-2">
{library.map((job, i) => {
// Row actions gated on the stored media_kind + job_kind (see downloadActions).
const a = libraryRowActions(job);
return (
<DownloadRow key={job.id} job={job} navIndex={i}>
{a.play && (
<IconBtn
open
onClick={() => setPlayJob(job)}
title={t("downloads.actions.play")}
>
<PlayCircle className="w-4 h-4" />
</IconBtn>
)}
{saveBtn(job)}
{a.edit && (
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
<Scissors className="w-4 h-4" />
</IconBtn>
)}
{a.editDetails && (
<IconBtn
onClick={() => setMetaJob(job)}
title={t("downloads.actions.editDetails")}
>
<Pencil className="w-4 h-4" />
</IconBtn>
)}
<IconBtn onClick={() => quickShare(job)} title={t("downloads.actions.share")}>
<Share2 className="w-4 h-4" />
</IconBtn>
{library.map((job, i) => (
<DownloadRow key={job.id} job={job} navIndex={i} {...rowMenuProps(job)}>
{libraryRowActions(job).play && (
<IconBtn
onClick={() => setShareJob(job)}
title={t("downloads.actions.shareManage")}
open
onClick={() => setPlayJob(job)}
title={t("downloads.actions.play")}
>
<Link2 className="w-4 h-4" />
<PlayCircle className="w-4 h-4" />
</IconBtn>
{a.redownload && (
<IconBtn
onClick={() =>
confirmThen(
t("downloads.confirm.redownloadTitle"),
t("downloads.confirm.redownloadBody"),
t("downloads.confirm.redownloadAction"),
() => act.mutate({ id: job.id, op: "redownload" }),
)
}
title={t("downloads.actions.redownload")}
>
<RotateCcw className="w-4 h-4" />
</IconBtn>
)}
<IconBtn
onClick={() =>
confirmThen(
t("downloads.confirm.deleteTitle"),
t("downloads.confirm.deleteBody"),
t("downloads.actions.delete"),
() => act.mutate({ id: job.id, op: "delete" }),
)
}
title={t("downloads.actions.delete")}
danger
>
<Trash2 className="w-4 h-4" />
</IconBtn>
</DownloadRow>
);
})}
)}
{saveBtn(job)}
</DownloadRow>
))}
{library.length === 0 &&
(jobsQ.isError && !jobsQ.data ? (
<StateMessage tone="error" onRetry={() => jobsQ.refetch()}>
@@ -374,7 +284,7 @@ export default function DownloadCenter() {
// Same media_kind + can_download gate as the library, via the shared helper (S5b).
const a = sharedRowActions(job);
return (
<DownloadRow key={job.id} job={job} navIndex={i}>
<DownloadRow key={job.id} job={job} navIndex={i} {...rowMenuProps(job)}>
{a.play && (
<IconBtn
open
@@ -385,25 +295,6 @@ export default function DownloadCenter() {
</IconBtn>
)}
{a.save && saveBtn(job)}
{a.edit && (
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
<Scissors className="w-4 h-4" />
</IconBtn>
)}
<IconBtn
onClick={() =>
confirmThen(
t("downloads.confirm.removeSharedTitle"),
t("downloads.confirm.removeSharedBody"),
t("downloads.actions.removeShared"),
() => removeShared.mutate(job.id),
)
}
title={t("downloads.actions.removeShared")}
danger
>
<Trash2 className="w-4 h-4" />
</IconBtn>
</DownloadRow>
);
})}
@@ -424,6 +315,8 @@ export default function DownloadCenter() {
{tab === "system" && me.role === "admin" && <AdminSystem />}
<ContextMenu menu={rowMenu} ariaLabel={t("downloads.rowMenu")} items={menuItems} />
<Suspense fallback={null}>
{addSource && (
<DownloadDialog
@@ -443,7 +336,7 @@ export default function DownloadCenter() {
{errorJob && (
<ErrorModal
job={errorJob}
onRetry={() => act.mutate({ id: errorJob.id, op: "resume" })}
onRetry={() => actions.run(errorJob, "resume")}
onClose={() => setErrorJob(null)}
/>
)}
@@ -0,0 +1,269 @@
// @vitest-environment jsdom
//
// E28 C1 on the notification center: a row keeps its link and its unread mark in sight, its dismiss
// shows on hover (on touch it is in the "…"), and everything it can do is its ONE menu — opened by
// the row's "…" or a right-click; a hidden / watched notice's Find in feed and Undo live only there.
// The band keeps Mark all read; Clear everything moved into the header's "…" and asks first.
// i18n is key-passthrough, so labels read as keys.
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { api, type AppNotification } from "../lib/api";
import type { Notification as ClientNotif } from "../lib/notifications";
const h = vi.hoisted(() => ({
server: [] as unknown[],
client: [] as unknown[],
setPage: vi.fn(),
setFilters: vi.fn(),
remove: vi.fn(),
clearAll: vi.fn(),
confirm: vi.fn(async () => true),
}));
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("./confirmContext", () => ({ useConfirm: () => h.confirm }));
vi.mock("../lib/useLiveQuery", () => ({
useLiveQuery: () => ({
data: { items: h.server, total: h.server.length },
isError: false,
isLoading: false,
refetch: vi.fn(),
}),
}));
vi.mock("../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/notifications")>()),
subscribe: () => () => {},
getNotifications: () => h.client,
getUnreadCount: () => 0,
markAllRead: () => {},
remove: (id: number) => h.remove(id),
clearAll: () => h.clearAll(),
}));
vi.mock("./navigationContext", () => ({ useNavigationActions: () => ({ setPage: h.setPage }) }));
vi.mock("./channelsContext", () => ({ useChannelsActions: () => ({ focusChannel: vi.fn() }) }));
vi.mock("./feedFiltersContext", () => ({
useFeedFilters: () => ({ tags: [54], untagged: true, q: "rust", maxAgeDays: 30, sort: "newest" }),
useFeedFiltersActions: () => ({ setFilters: h.setFilters }),
}));
vi.mock("./ui/PageShell", async (importOriginal) => ({
...(await importOriginal<typeof import("./ui/PageShell")>()),
PageToolbar: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
import NotificationsPanel from "./NotificationsPanel";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
for (const f of [h.setPage, h.setFilters, h.remove, h.clearAll, h.confirm]) f.mockClear();
});
const server = (id: number, extra: Partial<AppNotification> = {}) =>
({
id,
type: "download_done",
title: `Server ${id}`,
body: `Body ${id}`,
data: { title: `Ready ${id}` },
read: false,
created_at: new Date().toISOString(),
...extra,
}) as unknown as AppNotification;
const client = (id: number, extra: Partial<ClientNotif> = {}) =>
({
id,
level: "success",
message: `Activity ${id}`,
requiresInteraction: false,
ts: Date.now(),
read: true,
dismissed: true,
transient: false,
repeat: 1,
...extra,
}) as unknown as ClientNotif;
function mount() {
const client = new QueryClient();
const tree = () => (
<QueryClientProvider client={client}>
<NotificationsPanel />
</QueryClientProvider>
);
const { rerender } = render(tree());
return { rerender: () => rerender(tree()) };
}
const rowOf = (text: string) => screen.getByText(text).closest<HTMLElement>("[data-nav-index]")!;
const moreOf = (text: string) =>
rowOf(text).querySelector<HTMLElement>('button[aria-label="inbox.rowMenu"]')!;
const menuRows = () =>
screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent ?? "");
async function openMenu(open: () => void) {
act(open);
await waitFor(() =>
expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0),
);
}
const choose = (label: string) =>
fireEvent.click(
screen.getAllByRole("menuitem", { hidden: true }).find((b) => b.textContent === label)!,
);
describe("NotificationsPanel · a server row", () => {
it("keeps its link and mark read in sight; dismiss shows on hover, then its '…'", () => {
h.server = [server(1)];
h.client = [];
mount();
const row = rowOf("Ready 1");
expect(row.classList.contains("group")).toBe(true);
const buttons = [...row.querySelectorAll("button")];
expect(buttons.map((b) => b.getAttribute("aria-label") ?? b.textContent)).toEqual([
"inbox.openDownloads",
"inbox.markRead",
"inbox.dismiss",
"inbox.rowMenu",
]);
// Hover-only on a mouse (and gone on touch, where the menu holds it).
expect(buttons[2]!.className).toContain("reveal-hover");
expect(moreOf("Ready 1").classList.contains("reveal")).toBe(true);
});
it("holds the link, mark read and dismiss", async () => {
h.server = [server(1)];
h.client = [];
const dismiss = vi.spyOn(api, "dismissNotification").mockResolvedValue({} as never);
mount();
await openMenu(() => fireEvent.click(moreOf("Ready 1")));
expect(menuRows()).toEqual(["inbox.openDownloads", "inbox.markRead", "inbox.dismiss"]);
choose("inbox.dismiss");
await waitFor(() => expect(dismiss).toHaveBeenCalledWith(1, expect.anything()));
});
it("closes on a second press of the same '…', on a server and an activity row", async () => {
h.server = [server(1)];
h.client = [client(8)];
mount();
for (const name of ["Ready 1", "Activity 8"]) {
await openMenu(() => fireEvent.click(moreOf(name)));
act(() => {
fireEvent.pointerDown(moreOf(name));
fireEvent.mouseDown(moreOf(name));
fireEvent.click(moreOf(name), { detail: 1 });
});
await waitFor(() =>
expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0),
);
}
});
it("still closes when a poll replaced the notification under the open menu", async () => {
h.server = [server(1)];
h.client = [];
const { rerender } = mount();
await openMenu(() => fireEvent.click(moreOf("Ready 1")));
h.server = [server(1)]; // the next poll: a NEW object for the same notification
rerender();
act(() => {
fireEvent.pointerDown(moreOf("Ready 1"));
fireEvent.mouseDown(moreOf("Ready 1"));
fireEvent.click(moreOf("Ready 1"), { detail: 1 });
});
await waitFor(() =>
expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0),
);
});
it("leaves mark read out of a read row's menu", async () => {
h.server = [server(2, { read: true, type: "maintenance", data: {} } as never)];
h.client = [];
mount();
await openMenu(() => fireEvent.contextMenu(rowOf("Server 2")));
expect(menuRows()).toEqual(["inbox.dismiss"]);
});
});
describe("NotificationsPanel · an activity row", () => {
it("puts a hidden notice's Find in feed and Undo into its menu only", async () => {
h.server = [];
// Its toast is still up (not dismissed), so it carries the toast's Undo too: the row shows
// neither that nor Find in feed — only its dismiss and its "…".
h.client = [
client(7, {
dismissed: false,
action: { label: "feed.undo", onClick: () => {} },
meta: { kind: "video-hidden", videoId: "v", channelId: "c" },
} as never),
];
const setState = vi.spyOn(api, "setState").mockResolvedValue({} as never);
mount();
expect(
[...rowOf("Activity 7").querySelectorAll("button")].map((b) => b.getAttribute("aria-label")),
).toEqual(["inbox.dismiss", "inbox.rowMenu"]);
await openMenu(() => fireEvent.contextMenu(rowOf("Activity 7")));
expect(menuRows()).toEqual([
"notifications.findInFeed",
"notifications.unhide",
"inbox.dismiss",
]);
choose("notifications.unhide");
await waitFor(() => expect(setState).toHaveBeenCalledWith("v", "new"));
});
it("finds a watched notice's video with nothing narrowing the feed past it", async () => {
h.server = [];
h.client = [
client(9, { meta: { kind: "video-watched", videoId: "v", channelId: "c" } } as never),
];
mount();
await openMenu(() => fireEvent.contextMenu(rowOf("Activity 9")));
choose("notifications.findInFeed");
await waitFor(() => expect(h.setFilters).toHaveBeenCalled());
expect(h.setFilters).toHaveBeenCalledWith({
tags: [],
untagged: false,
q: "",
maxAgeDays: undefined,
sort: "newest",
show: "watched",
channelIds: ["c"],
});
expect(h.setPage).toHaveBeenCalledWith("feed");
});
it("dismisses a plain activity row from its menu", async () => {
h.server = [];
h.client = [client(8)];
mount();
await openMenu(() => fireEvent.click(moreOf("Activity 8")));
expect(menuRows()).toEqual(["inbox.dismiss"]);
choose("inbox.dismiss");
await waitFor(() => expect(h.remove).toHaveBeenCalledWith(8));
});
});
describe("NotificationsPanel · the header's '…'", () => {
it("keeps Mark all read in the band and asks before Clear everything", async () => {
h.server = [server(1)];
h.client = [client(8)];
const clear = vi.spyOn(api, "clearNotifications").mockResolvedValue({} as never);
mount();
expect(screen.getByRole("button", { name: "inbox.markAllRead" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "inbox.clearAll" })).toBeNull();
await openMenu(() => fireEvent.click(screen.getByRole("button", { name: "inbox.menu" })));
expect(menuRows()).toEqual(["inbox.markAllRead", "inbox.clearAll"]);
h.confirm.mockResolvedValueOnce(false);
choose("inbox.clearAll");
await waitFor(() => expect(h.confirm).toHaveBeenCalledTimes(1));
expect(h.confirm).toHaveBeenCalledWith(expect.objectContaining({ danger: true }));
expect(clear).not.toHaveBeenCalled();
expect(h.clearAll).not.toHaveBeenCalled();
await openMenu(() => fireEvent.click(screen.getByRole("button", { name: "inbox.menu" })));
choose("inbox.clearAll");
await waitFor(() => expect(clear).toHaveBeenCalled());
expect(h.clearAll).toHaveBeenCalled();
});
});
+307 -106
View File
@@ -1,4 +1,4 @@
import { useEffect, useSyncExternalStore } from "react";
import { useEffect, useSyncExternalStore, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
@@ -47,6 +47,15 @@ import { useChannelsActions } from "./channelsContext";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
import { SURFACE } from "./ui/surfaces";
import { SectionLabel } from "./ui/Headings";
import NavGrid from "./ui/NavGrid";
import { navItemAttrs } from "../lib/listNav";
import { useContextMenu, type ContextMenuController } from "./ui/useContextMenu";
import { ContextMenu } from "./ui/ContextMenu";
import { MoreButton } from "./ui/MoreButton";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { menuGroups } from "./ui/menuGroups";
import { HOVER_REVEAL } from "./ui/reveal";
import { useConfirm } from "./confirmContext";
// The single notification center. "System" = durable, server-backed notifications (inbox
// phase 1). "Activity" = the client-side transient events (action confirmations, errors, with
@@ -54,6 +63,8 @@ import { SectionLabel } from "./ui/Headings";
// there's one indicator and one place to look. Transient toasts still pop via the Toaster.
const POLL_MS = 8000;
const byId = (n: { id: number }) => n.id;
export default function NotificationsPanel() {
const { t } = useTranslation();
const { setPage } = useNavigationActions();
@@ -63,6 +74,7 @@ export default function NotificationsPanel() {
// ChannelsProvider — read it here and pass it down to the notification rows.
const { focusChannel: onFocusChannel } = useChannelsActions();
const qc = useQueryClient();
const confirm = useConfirm();
const q = useLiveQuery<{ items: AppNotification[]; total: number }>(
qk.notifications(),
() => api.notifications(),
@@ -112,16 +124,31 @@ export default function NotificationsPanel() {
if (serverUnread) readAllMut.mutate();
else refresh();
}
function clearEverything() {
// It empties both lists for good (the server's are deleted), so it asks first — it moved from the
// band into the header's "…" (E28 C1), where a destructive row keeps its confirm.
async function clearEverything() {
const ok = await confirm({
title: t("inbox.clearConfirmTitle"),
message: t("inbox.clearConfirmBody"),
confirmLabel: t("inbox.clearAll"),
danger: true,
});
if (!ok) return;
clearClient();
clearMut.mutate();
}
// "Find in feed" / undo, ported from the old bell. Works for both hidden and watched
// notices — jump to the feed filtered to that state and the video's channel.
// The narrowing filters that could leave the video out (a tag, "no tag", a search, an age cap) are
// dropped: kept, a channel outside the reader's tag filter found an empty feed.
function locate(meta: VideoHiddenMeta | VideoWatchedMeta) {
setFilters({
...filters,
tags: [],
untagged: false,
q: "",
maxAgeDays: undefined,
show: meta.kind === "video-hidden" ? "hidden" : "watched",
channelIds: meta.channelId ? [meta.channelId] : [],
});
@@ -139,13 +166,165 @@ export default function NotificationsPanel() {
.catch((e) => notifyActionError(e, t("inbox.revertFailed")));
}
// --- The rows' ONE menu (E28 C1) -------------------------------------------------------------
// A row keeps its link and its unread mark in sight; its dismiss shows on hover. Everything it can
// do — the link again, find / undo on an activity row, mark read, dismiss — is its menu.
// One menu per list, keyed by the notification's id: the server list polls, and the "…" must still
// close the menu a poll replaced its row under.
const serverMenu = useContextMenu<AppNotification>({ keyOf: byId });
const clientMenu = useContextMenu<ClientNotif>({ keyOf: byId });
const openScheduler = () => setPage("scheduler");
const openDownloads = () => setPage("downloads");
const reviewAccess = () => {
focusAccessRequestsTab();
setPage("users");
};
const icon = (I: typeof Check) => <I className="w-4 h-4" />;
const dismissRow = (onSelect: () => void): ActionMenuItem => ({
id: "dismiss",
label: t("inbox.dismiss"),
icon: icon(X),
onSelect,
});
function serverRows(n: AppNotification): ActionMenuItem[] {
const kind = serverKind(n);
return menuGroups(
[
...(kind === "scheduler"
? [
{
id: "open",
label: t("inbox.openScheduler"),
icon: icon(Activity),
onSelect: openScheduler,
},
]
: kind === "download"
? [
{
id: "open",
label: t("inbox.openDownloads"),
icon: icon(Download),
onSelect: openDownloads,
},
]
: []),
...(!n.read
? [
{
id: "read",
label: t("inbox.markRead"),
icon: icon(Check),
onSelect: () => readMut.mutate(n.id),
},
]
: []),
],
[dismissRow(() => dismissMut.mutate(n.id))],
);
}
function clientRows(n: ClientNotif): ActionMenuItem[] {
const m = n.meta;
const own: ActionMenuItem[] =
m?.kind === "video-hidden" || m?.kind === "video-watched"
? [
{
id: "find",
label: t("notifications.findInFeed"),
icon: icon(Search),
onSelect: () => locate(m),
},
{
id: "revert",
label: t(
m.kind === "video-hidden" ? "notifications.unhide" : "notifications.unwatch",
),
icon: icon(m.kind === "video-hidden" ? Eye : RotateCcw),
onSelect: () => revertState(m),
},
]
: m?.kind === "channel-subscribed"
? [
{
id: "manager",
label: t("notifications.openInManager"),
icon: icon(Tv),
onSelect: () => onFocusChannel(m.channelName),
},
{
id: "youtube",
label: t("notifications.openOnYouTube"),
icon: icon(ExternalLink),
// A new tab only opens inside the click (a popup blocker refuses one a task later).
inGesture: true,
onSelect: () =>
void window.open(channelYouTubeUrl(m.channelId), "_blank", "noopener,noreferrer"),
},
]
: m?.kind === "access-requests"
? [
{
id: "review",
label: t("common.accessRequestsReview"),
icon: icon(UserPlus),
onSelect: reviewAccess,
},
]
: n.action && !n.dismissed
? [
{
id: "action",
label: n.action.label,
onSelect: () => {
n.action!.onClick();
dismissClient(n.id);
},
},
]
: [];
return menuGroups(own, [dismissRow(() => removeClient(n.id))]);
}
const more = <T extends { id: number }>(menu: ContextMenuController<T>, n: T) => {
const mine = menu.open && menu.target?.id === n.id;
return (
<MoreButton
label={t("inbox.rowMenu")}
expanded={mine}
menuProps={menu.buttonProps(n)}
className={mine ? undefined : "reveal"}
/>
);
};
// --- The page's own menu: the header's "…" ------------------------------------------------------
const headerMenu = useContextMenu<"inbox">();
const headerItems: ActionMenuItem[] = [
{
id: "read-all",
label: t("inbox.markAllRead"),
icon: icon(CheckCheck),
disabled: !hasUnread || readAllMut.isPending,
onSelect: markAll,
},
{
id: "clear",
label: t("inbox.clearAll"),
icon: icon(Trash2),
danger: true,
separatorBefore: true,
disabled: clearMut.isPending,
onSelect: () => void clearEverything(),
},
];
return (
<>
{/* The inbox band (B4): its two actions, fixed while the list scrolls. No title card: the nav
rail and the tab title name the page, and the shell's hidden h1 names it to a screen reader. */}
{/* The inbox band (B4): Mark all read in sight, Clear everything in its "…" (E28 C1), fixed
while the list scrolls. No title card: the nav rail and the tab title name the page, and the
shell's hidden h1 names it to a screen reader. */}
{hasAny && (
<PageToolbar>
<div className="px-4 pt-2 pb-1 max-w-3xl w-full mx-auto flex justify-end gap-2">
<div className="px-4 pt-2 pb-1 max-w-3xl w-full mx-auto flex justify-end items-center gap-2">
<Button
variant="ghost"
size="sm"
@@ -155,15 +334,11 @@ export default function NotificationsPanel() {
>
{t("inbox.markAllRead")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={clearEverything}
disabled={clearMut.isPending}
icon={<Trash2 />}
>
{t("inbox.clearAll")}
</Button>
<MoreButton
label={t("inbox.menu")}
expanded={headerMenu.open}
menuProps={headerMenu.buttonProps("inbox")}
/>
</div>
</PageToolbar>
)}
@@ -185,53 +360,112 @@ export default function NotificationsPanel() {
{serverItems.length > 0 && (
<section>
<SectionLabel className="mb-2">{t("inbox.sectionSystem")}</SectionLabel>
<div className="flex flex-col gap-2">
{serverItems.map((n) => (
<NotificationRow
key={n.id}
n={n}
t={t}
onRead={() => readMut.mutate(n.id)}
onDismiss={() => dismissMut.mutate(n.id)}
onOpenScheduler={() => setPage("scheduler")}
onOpenDownloads={() => setPage("downloads")}
/>
))}
</div>
<NavGrid className="flex flex-col gap-2">
{serverItems.map((n, i) => {
return (
<NotificationRow
key={n.id}
n={n}
t={t}
navIndex={i}
rowProps={serverMenu.triggerProps(n)}
more={more(serverMenu, n)}
onRead={() => readMut.mutate(n.id)}
onDismiss={() => dismissMut.mutate(n.id)}
onOpenScheduler={openScheduler}
onOpenDownloads={openDownloads}
/>
);
})}
</NavGrid>
</section>
)}
{clientItems.length > 0 && (
<section>
<SectionLabel className="mb-2">{t("inbox.sectionActivity")}</SectionLabel>
<div className="flex flex-col gap-2">
{clientItems.map((n) => (
<ClientActivityRow
key={n.id}
n={n}
t={t}
onFind={locate}
onRevert={revertState}
onFocusChannel={onFocusChannel}
onReviewAccess={() => {
focusAccessRequestsTab();
setPage("users");
}}
onRemove={() => removeClient(n.id)}
/>
))}
</div>
<NavGrid className="flex flex-col gap-2">
{clientItems.map((n, i) => {
return (
<ClientActivityRow
key={n.id}
n={n}
t={t}
navIndex={i}
rowProps={clientMenu.triggerProps(n)}
more={more(clientMenu, n)}
onFocusChannel={onFocusChannel}
onReviewAccess={reviewAccess}
onRemove={() => removeClient(n.id)}
/>
);
})}
</NavGrid>
</section>
)}
</div>
)}
</div>
<ContextMenu
menu={serverMenu}
ariaLabel={t("inbox.rowMenu")}
items={serverMenu.target ? serverRows(serverMenu.target) : []}
/>
<ContextMenu
menu={clientMenu}
ariaLabel={t("inbox.rowMenu")}
items={clientMenu.target ? clientRows(clientMenu.target) : []}
/>
<ContextMenu menu={headerMenu} ariaLabel={t("inbox.menu")} items={headerItems} />
</>
);
}
/** A server notification's link, from its type and typed payload (the row's button and its menu). */
function serverKind(n: AppNotification): "scheduler" | "download" | null {
if (n.type === "scheduler" && typeof n.data?.job_id === "string") return "scheduler";
if (n.type === "download_done") return "download";
return null;
}
/** A row's end: its unread mark (server rows), its dismiss on hover (mouse; on touch it is in the
* "…"), then its "…". */
function RowEnd({
onRead,
onDismiss,
more,
}: {
onRead?: () => void;
onDismiss: () => void;
more: ReactNode;
}) {
const { t } = useTranslation();
return (
<div className="flex items-center gap-1 shrink-0">
{onRead && (
<IconButton label={t("inbox.markRead")} size="sm" variant="ghost" onClick={onRead}>
<Check className="w-4 h-4" />
</IconButton>
)}
<IconButton
label={t("inbox.dismiss")}
size="sm"
variant="ghost"
onClick={onDismiss}
className={HOVER_REVEAL}
>
<X className="w-4 h-4" />
</IconButton>
{more}
</div>
);
}
function NotificationRow({
n,
t,
navIndex,
rowProps,
more,
onRead,
onDismiss,
onOpenScheduler,
@@ -239,6 +473,9 @@ function NotificationRow({
}: {
n: AppNotification;
t: TFunction;
navIndex: number;
rowProps: Record<string, unknown>;
more: ReactNode;
onRead: () => void;
onDismiss: () => void;
onOpenScheduler: () => void;
@@ -251,8 +488,8 @@ function NotificationRow({
// Known notification types are rendered from i18n (so they're trilingual) using the typed
// payload; the server-stored title/body are an English fallback for any unknown type.
const isMaintenance = n.type === "maintenance" && typeof n.data?.count === "number";
const isScheduler = n.type === "scheduler" && typeof n.data?.job_id === "string";
const isDownloadDone = n.type === "download_done";
const isScheduler = serverKind(n) === "scheduler";
const isDownloadDone = serverKind(n) === "download";
const isEmailUndelivered = n.type === "email_undelivered" && typeof n.data?.reason === "string";
let title = n.title;
let body = n.body;
@@ -289,7 +526,9 @@ function NotificationRow({
}
return (
<div
className={`${SURFACE.card} p-3.5 flex items-start gap-3 transition ${
{...navItemAttrs(navIndex, title)}
{...rowProps}
className={`${SURFACE.card} group p-3.5 flex items-start gap-3 transition ${
n.read ? "opacity-70" : ""
}`}
>
@@ -318,6 +557,7 @@ function NotificationRow({
<Button
variant="link"
className="text-sm mt-1.5"
data-nav-open=""
onClick={onOpenScheduler}
icon={<Activity className="w-3.5 h-3.5" />}
>
@@ -328,6 +568,7 @@ function NotificationRow({
<Button
variant="link"
className="text-sm mt-1.5"
data-nav-open=""
onClick={onOpenDownloads}
icon={<Download className="w-3.5 h-3.5" />}
>
@@ -335,16 +576,7 @@ function NotificationRow({
</Button>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
{!n.read && (
<IconButton label={t("inbox.markRead")} size="sm" variant="ghost" onClick={onRead}>
<Check className="w-4 h-4" />
</IconButton>
)}
<IconButton label={t("inbox.dismiss")} size="sm" variant="ghost" onClick={onDismiss}>
<X className="w-4 h-4" />
</IconButton>
</div>
<RowEnd onRead={n.read ? undefined : onRead} onDismiss={onDismiss} more={more} />
</div>
);
}
@@ -352,30 +584,34 @@ function NotificationRow({
function ClientActivityRow({
n,
t,
onFind,
onRevert,
navIndex,
rowProps,
more,
onFocusChannel,
onReviewAccess,
onRemove,
}: {
n: ClientNotif;
t: TFunction;
onFind: (meta: VideoHiddenMeta | VideoWatchedMeta) => void;
onRevert: (meta: VideoHiddenMeta | VideoWatchedMeta) => void;
navIndex: number;
rowProps: Record<string, unknown>;
more: ReactNode;
onFocusChannel: (name: string) => void;
onReviewAccess: () => void;
onRemove: () => void;
}) {
const { icon: Icon, color } = LEVEL_STYLE[n.level];
const needsAction = n.requiresInteraction && !n.dismissed;
const hidden = n.meta?.kind === "video-hidden" ? n.meta : null;
const watched = n.meta?.kind === "video-watched" ? n.meta : null;
// A hidden / watched notice's Find in feed and Undo are in its menu (E28 C1): the row reads.
const quiet = n.meta?.kind === "video-hidden" || n.meta?.kind === "video-watched";
const subscribed: ChannelSubscribedMeta | null =
n.meta?.kind === "channel-subscribed" ? n.meta : null;
const access = n.meta?.kind === "access-requests" ? n.meta : null;
return (
<div
className={`${SURFACE.card} p-3.5 flex items-start gap-3 ${needsAction ? "ring-1 ring-accent/30" : ""}`}
{...navItemAttrs(navIndex, n.title || n.message)}
{...rowProps}
className={`${SURFACE.card} group p-3.5 flex items-start gap-3 ${needsAction ? "ring-1 ring-accent/30" : ""}`}
>
<Icon className={`w-4 h-4 shrink-0 mt-0.5 ${color}`} />
<div className="min-w-0 flex-1">
@@ -396,49 +632,12 @@ function ClientActivityRow({
</span>
)}
</div>
{hidden ? (
<div className="flex items-center gap-3 mt-1">
<Button
variant="link"
className="text-sm"
onClick={() => onFind(hidden)}
icon={<Search className="w-3.5 h-3.5" />}
>
{t("notifications.findInFeed")}
</Button>
<Button
variant="link"
className="text-sm"
onClick={() => onRevert(hidden)}
icon={<Eye className="w-3.5 h-3.5" />}
>
{t("notifications.unhide")}
</Button>
</div>
) : watched ? (
<div className="flex items-center gap-3 mt-1">
<Button
variant="link"
className="text-sm"
onClick={() => onFind(watched)}
icon={<Search className="w-3.5 h-3.5" />}
>
{t("notifications.findInFeed")}
</Button>
<Button
variant="link"
className="text-sm"
onClick={() => onRevert(watched)}
icon={<RotateCcw className="w-3.5 h-3.5" />}
>
{t("notifications.unwatch")}
</Button>
</div>
) : subscribed ? (
{quiet ? null : subscribed ? (
<div className="flex items-center gap-3 mt-1">
<Button
variant="link"
className="text-sm"
data-nav-open=""
onClick={() => onFocusChannel(subscribed.channelName)}
icon={<Tv className="w-3.5 h-3.5" />}
>
@@ -449,6 +648,8 @@ function ClientActivityRow({
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
// The link keeps the browser's own right-click menu; the row's answers elsewhere.
onContextMenu={(e) => e.stopPropagation()}
>
<ExternalLink className="w-3.5 h-3.5" />
{t("notifications.openOnYouTube")}
@@ -458,6 +659,7 @@ function ClientActivityRow({
<Button
variant="link"
className="text-sm mt-1"
data-nav-open=""
onClick={() => onReviewAccess()}
icon={<UserPlus className="w-3.5 h-3.5" />}
>
@@ -469,6 +671,7 @@ function ClientActivityRow({
<Button
variant="link"
className="mt-1 text-sm"
data-nav-open=""
onClick={() => {
n.action!.onClick();
dismissClient(n.id);
@@ -479,9 +682,7 @@ function ClientActivityRow({
)
)}
</div>
<IconButton label={t("inbox.dismiss")} size="sm" variant="ghost" onClick={onRemove}>
<X className="w-4 h-4" />
</IconButton>
<RowEnd onDismiss={onRemove} more={more} />
</div>
);
}
@@ -0,0 +1,123 @@
// @vitest-environment jsdom
//
// E28 C1 on the Scheduler page: Run all and Pause stay in the status strip; the destructive Purge
// discovery left it for the strip's "…", which holds the page's whole list and asks before purging.
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const h = vi.hoisted(() => ({ confirm: vi.fn(async () => true) }));
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
});
vi.mock("./confirmContext", () => ({ useConfirm: () => h.confirm }));
import Scheduler from "./Scheduler";
import { api, type SchedulerStatus } from "../lib/api";
const status = (over: Partial<SchedulerStatus> = {}): SchedulerStatus => ({
running_here: true,
enabled: true,
paused: false,
jobs: [
{
id: "sync",
interval_minutes: 30,
next_run: null,
running: false,
status: "ok",
last_started: null,
last_finished: null,
last_result: null,
last_error: null,
progress: null,
},
],
queue: {
channels_recent_pending: 0,
channels_deep_pending: 0,
deep_eta_seconds: 0,
videos_pending_enrich: 0,
videos_pending_shorts: 0,
videos_live_refresh: 0,
},
quota: { used_today: 0, remaining_today: 100, daily_budget: 100, backfill_reserve: 0 },
maintenance: { revalidate_batch: 5000, revalidate_batch_default: 5000, min: 1000, max: 50000 },
...over,
});
let qc: QueryClient;
let current: SchedulerStatus;
async function mount(initial = status()) {
current = initial;
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<Scheduler />
</QueryClientProvider>,
);
await screen.findByText("scheduler.maintenance.batchLabel");
}
beforeEach(() => {
vi.spyOn(api, "schedulerStatus").mockImplementation(() => Promise.resolve(current));
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
h.confirm.mockClear();
});
const menuRows = () =>
screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent ?? "");
async function openMenu() {
act(() => fireEvent.click(screen.getByRole("button", { name: "scheduler.menu" })));
await waitFor(() =>
expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0),
);
}
const choose = (label: string) =>
fireEvent.click(
screen.getAllByRole("menuitem", { hidden: true }).find((b) => b.textContent === label)!,
);
describe("Scheduler · the status strip's '…'", () => {
it("keeps Run all and Pause in sight and Purge discovery only in the menu", async () => {
await mount();
expect(screen.getByRole("button", { name: /scheduler\.runAll$/ })).toBeTruthy();
expect(screen.getByRole("button", { name: /scheduler\.pause$/ })).toBeTruthy();
expect(screen.queryByRole("button", { name: /scheduler\.purgeDiscovery/ })).toBeNull();
await openMenu();
expect(menuRows()).toEqual(["scheduler.runAll", "scheduler.pause", "scheduler.purgeDiscovery"]);
});
it("purges only after the confirm says yes", async () => {
const purge = vi.spyOn(api, "purgeDiscovery").mockResolvedValue({
search_videos_deleted: 1,
videos_deleted: 0,
channels_deleted: 0,
} as never);
await mount();
h.confirm.mockResolvedValueOnce(false);
await openMenu();
choose("scheduler.purgeDiscovery");
await waitFor(() => expect(h.confirm).toHaveBeenCalledTimes(1));
expect(h.confirm).toHaveBeenCalledWith(expect.objectContaining({ danger: true }));
expect(purge).not.toHaveBeenCalled();
await openMenu();
choose("scheduler.purgeDiscovery");
await waitFor(() => expect(purge).toHaveBeenCalledTimes(1));
});
it("says why Run all is off while the scheduler is paused", async () => {
await mount(status({ paused: true }));
await openMenu();
expect(menuRows()).toEqual([
"scheduler.runAllscheduler.runAllPausedHint",
"scheduler.resume",
"scheduler.purgeDiscovery",
]);
});
});
+54 -19
View File
@@ -31,6 +31,10 @@ import { Pressable } from "./ui/Pressable";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
import { SectionLabel } from "./ui/Headings";
import { useContextMenu } from "./ui/useContextMenu";
import { ContextMenu } from "./ui/ContextMenu";
import { MoreButton } from "./ui/MoreButton";
import { type ActionMenuItem } from "./ui/ActionMenu";
const POLL_MS = 4000;
// While any job is running, poll faster so short scheduled runs aren't missed between ticks.
@@ -487,6 +491,10 @@ export default function Scheduler() {
},
});
// The page's own menu: the status strip's "…" (E28 C1). Run all and Pause stay in sight; the
// destructive Purge discovery lives only here, behind its confirm.
const pageMenu = useContextMenu<"scheduler">();
if (q.isError && !data)
return (
<StateMessage tone="error" onRetry={() => q.refetch()}>
@@ -496,6 +504,45 @@ export default function Scheduler() {
if (q.isLoading && !data) return <LoadingState label={t("scheduler.loading")} />;
if (!data) return <div className="p-8 text-muted">{t("common.somethingWrong")}</div>;
async function purge() {
const ok = await confirm({
title: t("scheduler.purgeConfirmTitle"),
message: t("scheduler.purgeDiscoveryHint"),
confirmLabel: t("scheduler.purgeDiscovery"),
danger: true,
});
if (ok) purgeMut.mutate();
}
const paused = data.paused;
const pageItems: ActionMenuItem[] = [
{
id: "run-all",
label: t("scheduler.runAll"),
icon: <Play className="w-4 h-4" />,
disabled: paused || runAllMut.isPending,
note: paused ? t("scheduler.runAllPausedHint") : undefined,
onSelect: () => runAllMut.mutate(),
},
{
id: "pause",
label: paused ? t("scheduler.resume") : t("scheduler.pause"),
title: t("scheduler.pauseHint"),
icon: paused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />,
disabled: pauseResume.isPending,
onSelect: () => pauseResume.mutate(paused),
},
{
id: "purge",
label: t("scheduler.purgeDiscovery"),
title: t("scheduler.purgeDiscoveryHint"),
icon: <Trash2 className="w-4 h-4" />,
danger: true,
separatorBefore: true,
disabled: purgeMut.isPending,
onSelect: () => void purge(),
},
];
const quotaPct = data.quota.daily_budget
? Math.min(100, Math.round((data.quota.used_today / data.quota.daily_budget) * 100))
: 0;
@@ -539,25 +586,6 @@ export default function Scheduler() {
{t("scheduler.runAll")}
</Button>
</Tooltip>
<Tooltip hint={t("scheduler.purgeDiscoveryHint")}>
<Button
variant="glass"
size="sm"
onClick={async () => {
const ok = await confirm({
title: t("scheduler.purgeConfirmTitle"),
message: t("scheduler.purgeDiscoveryHint"),
confirmLabel: t("scheduler.purgeDiscovery"),
danger: true,
});
if (ok) purgeMut.mutate();
}}
disabled={purgeMut.isPending}
>
<Trash2 className="w-4 h-4" />
{t("scheduler.purgeDiscovery")}
</Button>
</Tooltip>
<Tooltip hint={t("scheduler.pauseHint")}>
<Button
variant="glass"
@@ -569,6 +597,13 @@ export default function Scheduler() {
{data.paused ? t("scheduler.resume") : t("scheduler.pause")}
</Button>
</Tooltip>
<MoreButton
label={t("scheduler.menu")}
variant="glass"
expanded={pageMenu.open}
menuProps={pageMenu.buttonProps("scheduler")}
/>
<ContextMenu menu={pageMenu} ariaLabel={t("scheduler.menu")} items={pageItems} />
</div>
{!data.running_here && (
+24 -19
View File
@@ -1,12 +1,10 @@
import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { CalendarClock, Copy, File, Link2, Scissors } from "lucide-react";
import { CalendarClock, File, Link2, Scissors } from "lucide-react";
import clsx from "clsx";
import { qk } from "../lib/queryKeys";
import { useLiveQuery } from "../lib/useLiveQuery";
import { api, type DownloadJob } from "../lib/api";
import { notify } from "../lib/notifications";
import { copyText } from "../lib/clipboard";
import {
formatBytes,
formatDateTime,
@@ -161,16 +159,14 @@ function displayUrl(url: string): string {
}
}
// "Downloaded from" reference: opens the original page in a new tab, or copies the link.
// 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 (E28 C1).
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();
const copy = async () => {
if (await copyText(url)) {
notify({ level: "success", message: t("downloads.source.copied") });
} else {
notify({ level: "error", message: t("downloads.source.copyFailed") });
}
};
return (
<div className="flex items-center gap-1 mt-1 text-sm text-muted min-w-0">
<Link2 className="w-3.5 h-3.5 shrink-0" />
@@ -180,12 +176,10 @@ function SourceRef({ url }: { url: string }) {
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>
<IconButton onClick={copy} label={t("downloads.source.copy")}>
<Copy />
</IconButton>
</div>
);
}
@@ -202,6 +196,7 @@ function ChannelLine({ job }: { job: DownloadJob }) {
rel="noopener noreferrer"
title={url}
className="text-muted hover:text-accent hover:underline"
{...nativeMenu}
>
{name}
</a>
@@ -215,12 +210,18 @@ 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 (E28 C1). */
more?: ReactNode;
children?: ReactNode;
}) {
const { t } = useTranslation();
@@ -228,7 +229,8 @@ export function DownloadRow({
return (
<div
{...(navIndex !== undefined && navItemAttrs(navIndex, jobTitle(job)))}
className={`${SURFACE.card} flex gap-3 p-2.5 glass-hover transition`}
{...rowProps}
className={`${SURFACE.card} group flex gap-3 p-2.5 glass-hover transition`}
>
<Thumb job={job} />
<div className="min-w-0 flex-1">
@@ -302,10 +304,13 @@ export function DownloadRow({
</div>
)}
</div>
{/* Row action buttons: the md rung here (the row stands alone), the sm rung in the modals, one
IconBtn for both. `shrink-0` + no wrap keeps them on the title's line; the text column
shrinks instead. */}
<div className="flex items-start gap-1 shrink-0">{children}</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. `shrink-0` + no
wrap keeps them on the title's line; the text column shrinks instead. */}
<div className="flex items-start gap-1 shrink-0">
{children}
{more}
</div>
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { type ActionMenuItem } from "./ActionMenu";
/** One menu from its groups, a rule between each two that are not empty: the first row of every
* group but the first carries `separatorBefore`. An empty group leaves no rule behind, so a row
* that a job's kind or state takes out cannot strand a divider at the top or doubled in the middle. */
export function menuGroups(...groups: readonly (readonly ActionMenuItem[])[]): ActionMenuItem[] {
return groups
.filter((g) => g.length > 0)
.flatMap((g, gi) =>
g.map((it, i) => (i === 0 && gi > 0 ? { ...it, separatorBefore: true } : it)),
);
}
@@ -0,0 +1,265 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
Ban,
Download,
Link2,
Pause,
Pencil,
Play,
PlayCircle,
RotateCcw,
Scissors,
Share2,
Trash2,
} from "lucide-react";
import { qk } from "../lib/queryKeys";
import { api, type DownloadJob } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { invalidateDownloads, quickShareLink } from "../lib/downloads";
import { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
import { useConfirm } from "./confirmContext";
import { useCopyItems } from "./copyItems";
import { jobTitle } from "./downloadTitle";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { menuGroups } from "./ui/menuGroups";
// A download's ONE action list (E28 C1): the row's "…", its right-click, long-press and Shift+F10,
// on the queue, the library and the shared tab. The row keeps its primary action (play; pause /
// resume / retry in the queue), the save link and its state (the error and its details) in sight;
// the rest — the editors, sharing, re-download, the copies and the destructive rows — lives here.
export type DownloadTab = "queue" | "done" | "shared";
export type JobOp = "pause" | "resume" | "cancel" | "delete" | "redownload";
export function useDownloadActions({
onPlay,
onEdit,
onEditDetails,
onManageShares,
}: {
onPlay: (job: DownloadJob) => void;
onEdit: (job: DownloadJob) => void;
onEditDetails: (job: DownloadJob) => void;
onManageShares: (job: DownloadJob) => void;
}) {
const qc = useQueryClient();
const { t } = useTranslation();
const confirm = useConfirm();
const copy = useCopyItems();
const act = useMutation({
mutationFn: ({ id, op }: { id: number; op: JobOp }) =>
op === "pause"
? api.pauseDownload(id)
: op === "resume"
? api.resumeDownload(id)
: op === "cancel"
? api.cancelDownload(id)
: op === "redownload"
? api.redownloadDownload(id)
: api.deleteDownload(id),
onSuccess: () => invalidateDownloads(qc),
// A 400/409/422/500 opens the global error dialog with the backend's own message (and is then
// skipped here); a job that is gone (404) or a lost connection is said here, naming the action.
onError: (e, v) => notifyActionError(e, t(`errors.writeFailed.job.${v.op}`)),
});
// Remove a shared-with-me item from your list (only your grant; the owner's file is untouched).
const removeShared = useMutation({
mutationFn: (id: number) => api.removeSharedDownload(id),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.downloadsShared() }),
meta: { failTitle: "errors.writeFailed.removeShared" },
});
// The confirm button names the ACTION ("Remove"), never the dialog's question: labelled with the title
// it read "Re-download this file?", and the cancel dialog's own "Cancel" would sit beside "Cancel".
const confirmThen = async (title: string, body: string, action: string, fn: () => void) => {
if (await confirm({ title, message: body, confirmLabel: action, danger: true })) fn();
};
// One-click share (E7 S3 · D2-A): ensure a downloadable link, copy it, toast with "open in new tab"
// (see quickShareLink). Re-choosing reuses the existing link; a per-job in-flight guard also stops a
// double pick from racing two POSTs into two links before the cache is primed. Advanced controls
// (password, expiry, revoke, share-with-a-user) live in the Share dialog, "Manage sharing".
const [sharingId, setSharingId] = useState<number | null>(null);
const quickShare = async (job: DownloadJob) => {
if (sharingId !== null) return;
setSharingId(job.id);
try {
await quickShareLink(qc, job, t);
} finally {
setSharingId(null);
}
};
const icon = (I: typeof Play) => <I className="w-4 h-4" />;
const play = (job: DownloadJob): ActionMenuItem => ({
id: "play",
label: t("downloads.actions.play"),
icon: icon(PlayCircle),
onSelect: () => onPlay(job),
});
// The same link the row's save button is, followed inside the click: the server answers it as an
// attachment, so the page stays where it is.
const save = (job: DownloadJob): ActionMenuItem => ({
id: "save",
label: t("downloads.actions.download"),
icon: icon(Download),
inGesture: true,
onSelect: () => {
const a = document.createElement("a");
a.href = api.downloadFileUrl(job.id);
a.click();
},
});
const edit = (job: DownloadJob): ActionMenuItem => ({
id: "edit",
label: t("downloads.actions.edit"),
icon: icon(Scissors),
onSelect: () => onEdit(job),
});
const copies = (job: DownloadJob) => copy(jobTitle(job), job.source_url);
function queueItems(job: DownloadJob): ActionMenuItem[] {
const running = job.status === "queued" || job.status === "running";
return menuGroups(
[
running
? {
id: "pause",
label: t("downloads.actions.pause"),
icon: icon(Pause),
onSelect: () => act.mutate({ id: job.id, op: "pause" }),
}
: {
id: "resume",
label: t(
job.status === "error" ? "downloads.actions.retry" : "downloads.actions.resume",
),
icon: icon(Play),
onSelect: () => act.mutate({ id: job.id, op: "resume" }),
},
],
copies(job),
[
{
id: "cancel",
label: t("downloads.actions.cancel"),
icon: icon(Ban),
danger: true,
onSelect: () =>
void confirmThen(
t("downloads.confirm.cancelTitle"),
t("downloads.confirm.cancelBody"),
t("downloads.confirm.cancelAction"),
() => act.mutate({ id: job.id, op: "cancel" }),
),
},
],
);
}
function libraryItems(job: DownloadJob): ActionMenuItem[] {
// Row actions gated on the stored media_kind + job_kind (see downloadActions).
const a = libraryRowActions(job);
return menuGroups(
[...(a.play ? [play(job)] : []), save(job)],
[
...(a.edit ? [edit(job)] : []),
...(a.editDetails
? [
{
id: "edit-details",
label: t("downloads.actions.editDetails"),
icon: icon(Pencil),
onSelect: () => onEditDetails(job),
},
]
: []),
...(a.redownload
? [
{
id: "redownload",
label: t("downloads.actions.redownload"),
icon: icon(RotateCcw),
onSelect: () =>
void confirmThen(
t("downloads.confirm.redownloadTitle"),
t("downloads.confirm.redownloadBody"),
t("downloads.confirm.redownloadAction"),
() => act.mutate({ id: job.id, op: "redownload" }),
),
},
]
: []),
],
[
{
id: "share",
label: t("downloads.actions.share"),
icon: icon(Share2),
disabled: sharingId !== null,
onSelect: () => void quickShare(job),
},
{
id: "share-manage",
label: t("downloads.actions.shareManage"),
icon: icon(Link2),
onSelect: () => onManageShares(job),
},
...copies(job),
],
[
{
id: "delete",
label: t("downloads.actions.delete"),
icon: icon(Trash2),
danger: true,
onSelect: () =>
void confirmThen(
t("downloads.confirm.deleteTitle"),
t("downloads.confirm.deleteBody"),
t("downloads.actions.delete"),
() => act.mutate({ id: job.id, op: "delete" }),
),
},
],
);
}
function sharedItems(job: DownloadJob): ActionMenuItem[] {
// Same media_kind + can_download gate as the library, via the shared helper (S5b).
const a = sharedRowActions(job);
return menuGroups(
[...(a.play ? [play(job)] : []), ...(a.save ? [save(job)] : [])],
a.edit ? [edit(job)] : [],
copies(job),
[
{
id: "remove-shared",
label: t("downloads.actions.removeShared"),
icon: icon(Trash2),
danger: true,
onSelect: () =>
void confirmThen(
t("downloads.confirm.removeSharedTitle"),
t("downloads.confirm.removeSharedBody"),
t("downloads.actions.removeShared"),
() => removeShared.mutate(job.id),
),
},
],
);
}
/** The job's menu rows on the tab it is listed on. */
const items = (job: DownloadJob, tab: DownloadTab): ActionMenuItem[] =>
tab === "queue" ? queueItems(job) : tab === "done" ? libraryItems(job) : sharedItems(job);
/** The queue's visible primary action and the error dialog's Retry. */
const run = (job: DownloadJob, op: JobOp) => act.mutate({ id: job.id, op });
return { items, run };
}
+2 -4
View File
@@ -92,10 +92,7 @@
"unlimited": "Unlimited"
},
"source": {
"open": "Open the original page in a new tab",
"copy": "Copy source link",
"copied": "Source link copied to clipboard",
"copyFailed": "Couldn't copy the link"
"open": "Open the original page in a new tab"
},
"edit": {
"title": "Edit details",
@@ -197,6 +194,7 @@
"noFootprint": "No downloads yet."
},
"clipBadge": "Clip",
"rowMenu": "Download actions",
"errors": {
"quota": {
"max_bytes": "You've used your download storage quota ({{size}} GB). Free up space first.",
+4
View File
@@ -3,8 +3,12 @@
"empty": "You're all caught up.",
"markAllRead": "Mark all read",
"clearAll": "Clear all",
"clearConfirmTitle": "Clear every notification?",
"clearConfirmBody": "Every notification here leaves the list, read or not. They don't come back.",
"markRead": "Mark read",
"dismiss": "Dismiss",
"rowMenu": "Notification actions",
"menu": "Notification center actions",
"sectionSystem": "System",
"sectionActivity": "Activity",
"andMore": "and {{count}} more",
@@ -1,5 +1,6 @@
{
"title": "Background scheduler",
"menu": "Scheduler actions",
"loading": "Loading scheduler…",
"running": "Running",
"paused": "Paused",
+2 -4
View File
@@ -92,10 +92,7 @@
"unlimited": "Korlátlan"
},
"source": {
"open": "Eredeti oldal megnyitása új lapon",
"copy": "Forráslink másolása",
"copied": "Forráslink a vágólapra másolva",
"copyFailed": "Nem sikerült a link másolása"
"open": "Eredeti oldal megnyitása új lapon"
},
"edit": {
"title": "Adatok szerkesztése",
@@ -197,6 +194,7 @@
"noFootprint": "Még nincs letöltés."
},
"clipBadge": "Klip",
"rowMenu": "Letöltés műveletei",
"errors": {
"quota": {
"max_bytes": "Elfogyott a letöltési tárhelykereted ({{size}} GB). Előbb szabadíts fel helyet.",
+4
View File
@@ -3,8 +3,12 @@
"empty": "Nincs új értesítés.",
"markAllRead": "Összes olvasott",
"clearAll": "Összes törlése",
"clearConfirmTitle": "Törlöd az összes értesítést?",
"clearConfirmBody": "Minden itteni értesítés eltűnik a listából, olvasott és olvasatlan is. Nem hozhatók vissza.",
"markRead": "Olvasottnak jelöl",
"dismiss": "Elvetés",
"rowMenu": "Értesítés műveletei",
"menu": "Értesítésközpont műveletei",
"sectionSystem": "Rendszer",
"sectionActivity": "Tevékenység",
"andMore": "és még {{count}}",
@@ -1,5 +1,6 @@
{
"title": "Háttér-ütemező",
"menu": "Ütemező műveletei",
"loading": "Ütemező betöltése…",
"running": "Fut",
"paused": "Szüneteltetve",