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:
@@ -77,6 +77,24 @@ afterEach(() => {
|
|||||||
mocks.confirm.mockClear();
|
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", () => {
|
describe("DownloadCenter confirm dialogs", () => {
|
||||||
it("label the confirm button with the action, not the dialog's question", async () => {
|
it("label the confirm button with the action, not the dialog's question", async () => {
|
||||||
mocks.jobs = [job(1, "running"), job(2, "done")];
|
mocks.jobs = [job(1, "running"), job(2, "done")];
|
||||||
@@ -86,12 +104,15 @@ describe("DownloadCenter confirm dialogs", () => {
|
|||||||
</QueryClientProvider>,
|
</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));
|
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
|
||||||
// The tab's label shares its button with a count badge.
|
// The tab's label shares its button with a count badge.
|
||||||
fireEvent.click(screen.getByRole("button", { name: /downloads\.tabs\.done/ }));
|
fireEvent.click(screen.getByRole("button", { name: /downloads\.tabs\.done/ }));
|
||||||
fireEvent.click(await screen.findByTitle("downloads.actions.redownload"));
|
await screen.findByText("Clip 2");
|
||||||
fireEvent.click(screen.getByTitle("downloads.actions.delete"));
|
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));
|
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(3));
|
||||||
|
|
||||||
expect(mocks.confirm.mock.calls.map(([o]) => [o.title, o.confirmLabel])).toEqual([
|
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],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,22 +1,15 @@
|
|||||||
import { lazy, Suspense, useState } from "react";
|
import { lazy, Suspense, 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 { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Ban,
|
|
||||||
ClipboardPaste,
|
ClipboardPaste,
|
||||||
Download,
|
Download,
|
||||||
Link2,
|
|
||||||
Pause,
|
Pause,
|
||||||
Play,
|
Play,
|
||||||
PlayCircle,
|
PlayCircle,
|
||||||
Plus,
|
Plus,
|
||||||
RotateCcw,
|
|
||||||
Scissors,
|
|
||||||
Settings2,
|
Settings2,
|
||||||
Share2,
|
|
||||||
Pencil,
|
|
||||||
Trash2,
|
|
||||||
UploadCloud,
|
UploadCloud,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Tabs from "./ui/Tabs";
|
import Tabs from "./ui/Tabs";
|
||||||
@@ -30,12 +23,9 @@ const ProfileEditor = lazy(() => import("./ProfileEditor"));
|
|||||||
const VideoEditor = lazy(() => import("./VideoEditor"));
|
const VideoEditor = lazy(() => import("./VideoEditor"));
|
||||||
const ShareDialog = lazy(() => import("./ShareDialog"));
|
const ShareDialog = lazy(() => import("./ShareDialog"));
|
||||||
const LibraryPlayer = lazy(() => import("./LibraryPlayer"));
|
const LibraryPlayer = lazy(() => import("./LibraryPlayer"));
|
||||||
import { useConfirm } from "./confirmContext";
|
|
||||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||||
import { useMe } from "../lib/useMe";
|
import { useMe } from "../lib/useMe";
|
||||||
import { api, type DownloadJob } from "../lib/api";
|
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 { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
import { IconButton } from "./ui/IconButton";
|
import { IconButton } from "./ui/IconButton";
|
||||||
@@ -45,12 +35,16 @@ import { AdminSystem } from "./downloadAdmin";
|
|||||||
import NavGrid from "./ui/NavGrid";
|
import NavGrid from "./ui/NavGrid";
|
||||||
import { Input } from "./ui/Input";
|
import { Input } from "./ui/Input";
|
||||||
import { IconLink } from "./ui/LinkButton";
|
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() {
|
export default function DownloadCenter() {
|
||||||
const me = useMe();
|
const me = useMe();
|
||||||
const { t } = useTranslation();
|
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
|
// 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.
|
// any failures are, so it's the useful default every visit.
|
||||||
const [tab, setTab] = useState("queue");
|
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 queue = jobs.filter((j) => ["queued", "running", "paused", "error"].includes(j.status));
|
||||||
const library = jobs.filter((j) => j.status === "done");
|
const library = jobs.filter((j) => j.status === "done");
|
||||||
|
|
||||||
const invalidate = () => invalidateDownloads(qc);
|
// --- The rows' ONE menu (E28 C1) -------------------------------------------------------------
|
||||||
const act = useMutation({
|
// Keyed by the job's id: the queue polls every 2 s while a job runs, replacing the object under an
|
||||||
mutationFn: ({
|
// open menu, and its "…" must still close it. The tab is the one on screen.
|
||||||
id,
|
const rowMenu = useContextMenu<DownloadJob>({ keyOf: jobKey });
|
||||||
op,
|
const actions = useDownloadActions({
|
||||||
}: {
|
onPlay: setPlayJob,
|
||||||
id: number;
|
onEdit: setEditJob,
|
||||||
op: "pause" | "resume" | "cancel" | "delete" | "redownload";
|
onEditDetails: setMetaJob,
|
||||||
}) =>
|
onManageShares: setShareJob,
|
||||||
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}`)),
|
|
||||||
});
|
});
|
||||||
|
const menuTarget = rowMenu.target;
|
||||||
// Remove a shared-with-me item from your list (only your grant; the owner's file is untouched).
|
const listTab: DownloadTab | null =
|
||||||
const removeShared = useMutation({
|
tab === "queue" || tab === "done" || tab === "shared" ? tab : null;
|
||||||
mutationFn: (id: number) => api.removeSharedDownload(id),
|
const menuItems = menuTarget && listTab ? actions.items(menuTarget, listTab) : [];
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.downloadsShared() }),
|
// The row's menu triggers and its "…".
|
||||||
meta: { failTitle: "errors.writeFailed.removeShared" },
|
const rowMenuProps = (job: DownloadJob) => {
|
||||||
});
|
const mine = rowMenu.open && rowMenu.target?.id === job.id;
|
||||||
|
return {
|
||||||
// The confirm button names the ACTION ("Remove"), never the dialog's question: labelled with the title
|
rowProps: rowMenu.triggerProps(job),
|
||||||
// it read "Re-download this file?", and the cancel dialog's own "Cancel" would sit beside "Cancel".
|
more: (
|
||||||
const confirmThen = async (title: string, body: string, action: string, fn: () => void) => {
|
<MoreButton
|
||||||
if (await confirm({ title, message: body, confirmLabel: action, danger: true })) fn();
|
label={t("downloads.rowMenu")}
|
||||||
};
|
size="md"
|
||||||
|
expanded={mine}
|
||||||
// One-click share (E7 S3 · D2-A): ensure a downloadable link, copy it, toast with "open in new tab"
|
menuProps={rowMenu.buttonProps(job)}
|
||||||
// (see quickShareLink). Re-clicking reuses the existing link; a per-job in-flight guard also stops a
|
className={mine ? undefined : "reveal"}
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Pull a link off the clipboard into the add-URL box. Clipboard reads need a secure context +
|
// 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>
|
</div>
|
||||||
<NavGrid className="space-y-2">
|
<NavGrid className="space-y-2">
|
||||||
{queue.map((job, i) => (
|
{queue.map((job, i) => (
|
||||||
<DownloadRow key={job.id} job={job} navIndex={i} onShowError={setErrorJob}>
|
<DownloadRow
|
||||||
{(job.status === "queued" || job.status === "running") && (
|
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
|
<IconBtn
|
||||||
onClick={() => act.mutate({ id: job.id, op: "pause" })}
|
onClick={() => actions.run(job, "pause")}
|
||||||
title={t("downloads.actions.pause")}
|
title={t("downloads.actions.pause")}
|
||||||
>
|
>
|
||||||
<Pause className="w-4 h-4" />
|
<Pause className="w-4 h-4" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
)}
|
) : (
|
||||||
{(job.status === "paused" || job.status === "error") && (
|
|
||||||
<IconBtn
|
<IconBtn
|
||||||
onClick={() => act.mutate({ id: job.id, op: "resume" })}
|
onClick={() => actions.run(job, "resume")}
|
||||||
title={t(
|
title={t(
|
||||||
job.status === "error"
|
job.status === "error"
|
||||||
? "downloads.actions.retry"
|
? "downloads.actions.retry"
|
||||||
@@ -247,20 +226,6 @@ export default function DownloadCenter() {
|
|||||||
<Play className="w-4 h-4" />
|
<Play className="w-4 h-4" />
|
||||||
</IconBtn>
|
</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>
|
</DownloadRow>
|
||||||
))}
|
))}
|
||||||
{queue.length === 0 &&
|
{queue.length === 0 &&
|
||||||
@@ -283,75 +248,20 @@ export default function DownloadCenter() {
|
|||||||
<>
|
<>
|
||||||
<UsageBar />
|
<UsageBar />
|
||||||
<NavGrid className="space-y-2">
|
<NavGrid className="space-y-2">
|
||||||
{library.map((job, i) => {
|
{library.map((job, i) => (
|
||||||
// Row actions gated on the stored media_kind + job_kind (see downloadActions).
|
<DownloadRow key={job.id} job={job} navIndex={i} {...rowMenuProps(job)}>
|
||||||
const a = libraryRowActions(job);
|
{libraryRowActions(job).play && (
|
||||||
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>
|
|
||||||
<IconBtn
|
<IconBtn
|
||||||
onClick={() => setShareJob(job)}
|
open
|
||||||
title={t("downloads.actions.shareManage")}
|
onClick={() => setPlayJob(job)}
|
||||||
|
title={t("downloads.actions.play")}
|
||||||
>
|
>
|
||||||
<Link2 className="w-4 h-4" />
|
<PlayCircle className="w-4 h-4" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
{a.redownload && (
|
)}
|
||||||
<IconBtn
|
{saveBtn(job)}
|
||||||
onClick={() =>
|
</DownloadRow>
|
||||||
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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{library.length === 0 &&
|
{library.length === 0 &&
|
||||||
(jobsQ.isError && !jobsQ.data ? (
|
(jobsQ.isError && !jobsQ.data ? (
|
||||||
<StateMessage tone="error" onRetry={() => jobsQ.refetch()}>
|
<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).
|
// Same media_kind + can_download gate as the library, via the shared helper (S5b).
|
||||||
const a = sharedRowActions(job);
|
const a = sharedRowActions(job);
|
||||||
return (
|
return (
|
||||||
<DownloadRow key={job.id} job={job} navIndex={i}>
|
<DownloadRow key={job.id} job={job} navIndex={i} {...rowMenuProps(job)}>
|
||||||
{a.play && (
|
{a.play && (
|
||||||
<IconBtn
|
<IconBtn
|
||||||
open
|
open
|
||||||
@@ -385,25 +295,6 @@ export default function DownloadCenter() {
|
|||||||
</IconBtn>
|
</IconBtn>
|
||||||
)}
|
)}
|
||||||
{a.save && saveBtn(job)}
|
{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>
|
</DownloadRow>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -424,6 +315,8 @@ export default function DownloadCenter() {
|
|||||||
|
|
||||||
{tab === "system" && me.role === "admin" && <AdminSystem />}
|
{tab === "system" && me.role === "admin" && <AdminSystem />}
|
||||||
|
|
||||||
|
<ContextMenu menu={rowMenu} ariaLabel={t("downloads.rowMenu")} items={menuItems} />
|
||||||
|
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
{addSource && (
|
{addSource && (
|
||||||
<DownloadDialog
|
<DownloadDialog
|
||||||
@@ -443,7 +336,7 @@ export default function DownloadCenter() {
|
|||||||
{errorJob && (
|
{errorJob && (
|
||||||
<ErrorModal
|
<ErrorModal
|
||||||
job={errorJob}
|
job={errorJob}
|
||||||
onRetry={() => act.mutate({ id: errorJob.id, op: "resume" })}
|
onRetry={() => actions.run(errorJob, "resume")}
|
||||||
onClose={() => setErrorJob(null)}
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useSyncExternalStore } from "react";
|
import { useEffect, useSyncExternalStore, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
@@ -47,6 +47,15 @@ import { useChannelsActions } from "./channelsContext";
|
|||||||
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
||||||
import { SURFACE } from "./ui/surfaces";
|
import { SURFACE } from "./ui/surfaces";
|
||||||
import { SectionLabel } from "./ui/Headings";
|
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
|
// The single notification center. "System" = durable, server-backed notifications (inbox
|
||||||
// phase 1). "Activity" = the client-side transient events (action confirmations, errors, with
|
// 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.
|
// there's one indicator and one place to look. Transient toasts still pop via the Toaster.
|
||||||
const POLL_MS = 8000;
|
const POLL_MS = 8000;
|
||||||
|
|
||||||
|
const byId = (n: { id: number }) => n.id;
|
||||||
|
|
||||||
export default function NotificationsPanel() {
|
export default function NotificationsPanel() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { setPage } = useNavigationActions();
|
const { setPage } = useNavigationActions();
|
||||||
@@ -63,6 +74,7 @@ export default function NotificationsPanel() {
|
|||||||
// ChannelsProvider — read it here and pass it down to the notification rows.
|
// ChannelsProvider — read it here and pass it down to the notification rows.
|
||||||
const { focusChannel: onFocusChannel } = useChannelsActions();
|
const { focusChannel: onFocusChannel } = useChannelsActions();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const confirm = useConfirm();
|
||||||
const q = useLiveQuery<{ items: AppNotification[]; total: number }>(
|
const q = useLiveQuery<{ items: AppNotification[]; total: number }>(
|
||||||
qk.notifications(),
|
qk.notifications(),
|
||||||
() => api.notifications(),
|
() => api.notifications(),
|
||||||
@@ -112,16 +124,31 @@ export default function NotificationsPanel() {
|
|||||||
if (serverUnread) readAllMut.mutate();
|
if (serverUnread) readAllMut.mutate();
|
||||||
else refresh();
|
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();
|
clearClient();
|
||||||
clearMut.mutate();
|
clearMut.mutate();
|
||||||
}
|
}
|
||||||
|
|
||||||
// "Find in feed" / undo, ported from the old bell. Works for both hidden and watched
|
// "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.
|
// 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) {
|
function locate(meta: VideoHiddenMeta | VideoWatchedMeta) {
|
||||||
setFilters({
|
setFilters({
|
||||||
...filters,
|
...filters,
|
||||||
|
tags: [],
|
||||||
|
untagged: false,
|
||||||
|
q: "",
|
||||||
|
maxAgeDays: undefined,
|
||||||
show: meta.kind === "video-hidden" ? "hidden" : "watched",
|
show: meta.kind === "video-hidden" ? "hidden" : "watched",
|
||||||
channelIds: meta.channelId ? [meta.channelId] : [],
|
channelIds: meta.channelId ? [meta.channelId] : [],
|
||||||
});
|
});
|
||||||
@@ -139,13 +166,165 @@ export default function NotificationsPanel() {
|
|||||||
.catch((e) => notifyActionError(e, t("inbox.revertFailed")));
|
.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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* The inbox band (B4): its two actions, fixed while the list scrolls. No title card: the nav
|
{/* The inbox band (B4): Mark all read in sight, Clear everything in its "…" (E28 C1), fixed
|
||||||
rail and the tab title name the page, and the shell's hidden h1 names it to a screen reader. */}
|
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 && (
|
{hasAny && (
|
||||||
<PageToolbar>
|
<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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -155,15 +334,11 @@ export default function NotificationsPanel() {
|
|||||||
>
|
>
|
||||||
{t("inbox.markAllRead")}
|
{t("inbox.markAllRead")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<MoreButton
|
||||||
variant="ghost"
|
label={t("inbox.menu")}
|
||||||
size="sm"
|
expanded={headerMenu.open}
|
||||||
onClick={clearEverything}
|
menuProps={headerMenu.buttonProps("inbox")}
|
||||||
disabled={clearMut.isPending}
|
/>
|
||||||
icon={<Trash2 />}
|
|
||||||
>
|
|
||||||
{t("inbox.clearAll")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</PageToolbar>
|
</PageToolbar>
|
||||||
)}
|
)}
|
||||||
@@ -185,53 +360,112 @@ export default function NotificationsPanel() {
|
|||||||
{serverItems.length > 0 && (
|
{serverItems.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<SectionLabel className="mb-2">{t("inbox.sectionSystem")}</SectionLabel>
|
<SectionLabel className="mb-2">{t("inbox.sectionSystem")}</SectionLabel>
|
||||||
<div className="flex flex-col gap-2">
|
<NavGrid className="flex flex-col gap-2">
|
||||||
{serverItems.map((n) => (
|
{serverItems.map((n, i) => {
|
||||||
<NotificationRow
|
return (
|
||||||
key={n.id}
|
<NotificationRow
|
||||||
n={n}
|
key={n.id}
|
||||||
t={t}
|
n={n}
|
||||||
onRead={() => readMut.mutate(n.id)}
|
t={t}
|
||||||
onDismiss={() => dismissMut.mutate(n.id)}
|
navIndex={i}
|
||||||
onOpenScheduler={() => setPage("scheduler")}
|
rowProps={serverMenu.triggerProps(n)}
|
||||||
onOpenDownloads={() => setPage("downloads")}
|
more={more(serverMenu, n)}
|
||||||
/>
|
onRead={() => readMut.mutate(n.id)}
|
||||||
))}
|
onDismiss={() => dismissMut.mutate(n.id)}
|
||||||
</div>
|
onOpenScheduler={openScheduler}
|
||||||
|
onOpenDownloads={openDownloads}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</NavGrid>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{clientItems.length > 0 && (
|
{clientItems.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<SectionLabel className="mb-2">{t("inbox.sectionActivity")}</SectionLabel>
|
<SectionLabel className="mb-2">{t("inbox.sectionActivity")}</SectionLabel>
|
||||||
<div className="flex flex-col gap-2">
|
<NavGrid className="flex flex-col gap-2">
|
||||||
{clientItems.map((n) => (
|
{clientItems.map((n, i) => {
|
||||||
<ClientActivityRow
|
return (
|
||||||
key={n.id}
|
<ClientActivityRow
|
||||||
n={n}
|
key={n.id}
|
||||||
t={t}
|
n={n}
|
||||||
onFind={locate}
|
t={t}
|
||||||
onRevert={revertState}
|
navIndex={i}
|
||||||
onFocusChannel={onFocusChannel}
|
rowProps={clientMenu.triggerProps(n)}
|
||||||
onReviewAccess={() => {
|
more={more(clientMenu, n)}
|
||||||
focusAccessRequestsTab();
|
onFocusChannel={onFocusChannel}
|
||||||
setPage("users");
|
onReviewAccess={reviewAccess}
|
||||||
}}
|
onRemove={() => removeClient(n.id)}
|
||||||
onRemove={() => removeClient(n.id)}
|
/>
|
||||||
/>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</NavGrid>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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({
|
function NotificationRow({
|
||||||
n,
|
n,
|
||||||
t,
|
t,
|
||||||
|
navIndex,
|
||||||
|
rowProps,
|
||||||
|
more,
|
||||||
onRead,
|
onRead,
|
||||||
onDismiss,
|
onDismiss,
|
||||||
onOpenScheduler,
|
onOpenScheduler,
|
||||||
@@ -239,6 +473,9 @@ function NotificationRow({
|
|||||||
}: {
|
}: {
|
||||||
n: AppNotification;
|
n: AppNotification;
|
||||||
t: TFunction;
|
t: TFunction;
|
||||||
|
navIndex: number;
|
||||||
|
rowProps: Record<string, unknown>;
|
||||||
|
more: ReactNode;
|
||||||
onRead: () => void;
|
onRead: () => void;
|
||||||
onDismiss: () => void;
|
onDismiss: () => void;
|
||||||
onOpenScheduler: () => void;
|
onOpenScheduler: () => void;
|
||||||
@@ -251,8 +488,8 @@ function NotificationRow({
|
|||||||
// Known notification types are rendered from i18n (so they're trilingual) using the typed
|
// 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.
|
// 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 isMaintenance = n.type === "maintenance" && typeof n.data?.count === "number";
|
||||||
const isScheduler = n.type === "scheduler" && typeof n.data?.job_id === "string";
|
const isScheduler = serverKind(n) === "scheduler";
|
||||||
const isDownloadDone = n.type === "download_done";
|
const isDownloadDone = serverKind(n) === "download";
|
||||||
const isEmailUndelivered = n.type === "email_undelivered" && typeof n.data?.reason === "string";
|
const isEmailUndelivered = n.type === "email_undelivered" && typeof n.data?.reason === "string";
|
||||||
let title = n.title;
|
let title = n.title;
|
||||||
let body = n.body;
|
let body = n.body;
|
||||||
@@ -289,7 +526,9 @@ function NotificationRow({
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div
|
<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" : ""
|
n.read ? "opacity-70" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -318,6 +557,7 @@ function NotificationRow({
|
|||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
className="text-sm mt-1.5"
|
className="text-sm mt-1.5"
|
||||||
|
data-nav-open=""
|
||||||
onClick={onOpenScheduler}
|
onClick={onOpenScheduler}
|
||||||
icon={<Activity className="w-3.5 h-3.5" />}
|
icon={<Activity className="w-3.5 h-3.5" />}
|
||||||
>
|
>
|
||||||
@@ -328,6 +568,7 @@ function NotificationRow({
|
|||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
className="text-sm mt-1.5"
|
className="text-sm mt-1.5"
|
||||||
|
data-nav-open=""
|
||||||
onClick={onOpenDownloads}
|
onClick={onOpenDownloads}
|
||||||
icon={<Download className="w-3.5 h-3.5" />}
|
icon={<Download className="w-3.5 h-3.5" />}
|
||||||
>
|
>
|
||||||
@@ -335,16 +576,7 @@ function NotificationRow({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<RowEnd onRead={n.read ? undefined : onRead} onDismiss={onDismiss} more={more} />
|
||||||
{!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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -352,30 +584,34 @@ function NotificationRow({
|
|||||||
function ClientActivityRow({
|
function ClientActivityRow({
|
||||||
n,
|
n,
|
||||||
t,
|
t,
|
||||||
onFind,
|
navIndex,
|
||||||
onRevert,
|
rowProps,
|
||||||
|
more,
|
||||||
onFocusChannel,
|
onFocusChannel,
|
||||||
onReviewAccess,
|
onReviewAccess,
|
||||||
onRemove,
|
onRemove,
|
||||||
}: {
|
}: {
|
||||||
n: ClientNotif;
|
n: ClientNotif;
|
||||||
t: TFunction;
|
t: TFunction;
|
||||||
onFind: (meta: VideoHiddenMeta | VideoWatchedMeta) => void;
|
navIndex: number;
|
||||||
onRevert: (meta: VideoHiddenMeta | VideoWatchedMeta) => void;
|
rowProps: Record<string, unknown>;
|
||||||
|
more: ReactNode;
|
||||||
onFocusChannel: (name: string) => void;
|
onFocusChannel: (name: string) => void;
|
||||||
onReviewAccess: () => void;
|
onReviewAccess: () => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { icon: Icon, color } = LEVEL_STYLE[n.level];
|
const { icon: Icon, color } = LEVEL_STYLE[n.level];
|
||||||
const needsAction = n.requiresInteraction && !n.dismissed;
|
const needsAction = n.requiresInteraction && !n.dismissed;
|
||||||
const hidden = n.meta?.kind === "video-hidden" ? n.meta : null;
|
// A hidden / watched notice's Find in feed and Undo are in its menu (E28 C1): the row reads.
|
||||||
const watched = n.meta?.kind === "video-watched" ? n.meta : null;
|
const quiet = n.meta?.kind === "video-hidden" || n.meta?.kind === "video-watched";
|
||||||
const subscribed: ChannelSubscribedMeta | null =
|
const subscribed: ChannelSubscribedMeta | null =
|
||||||
n.meta?.kind === "channel-subscribed" ? n.meta : null;
|
n.meta?.kind === "channel-subscribed" ? n.meta : null;
|
||||||
const access = n.meta?.kind === "access-requests" ? n.meta : null;
|
const access = n.meta?.kind === "access-requests" ? n.meta : null;
|
||||||
return (
|
return (
|
||||||
<div
|
<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}`} />
|
<Icon className={`w-4 h-4 shrink-0 mt-0.5 ${color}`} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -396,49 +632,12 @@ function ClientActivityRow({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{hidden ? (
|
{quiet ? null : subscribed ? (
|
||||||
<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 ? (
|
|
||||||
<div className="flex items-center gap-3 mt-1">
|
<div className="flex items-center gap-3 mt-1">
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
className="text-sm"
|
className="text-sm"
|
||||||
|
data-nav-open=""
|
||||||
onClick={() => onFocusChannel(subscribed.channelName)}
|
onClick={() => onFocusChannel(subscribed.channelName)}
|
||||||
icon={<Tv className="w-3.5 h-3.5" />}
|
icon={<Tv className="w-3.5 h-3.5" />}
|
||||||
>
|
>
|
||||||
@@ -449,6 +648,8 @@ function ClientActivityRow({
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
|
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" />
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
{t("notifications.openOnYouTube")}
|
{t("notifications.openOnYouTube")}
|
||||||
@@ -458,6 +659,7 @@ function ClientActivityRow({
|
|||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
className="text-sm mt-1"
|
className="text-sm mt-1"
|
||||||
|
data-nav-open=""
|
||||||
onClick={() => onReviewAccess()}
|
onClick={() => onReviewAccess()}
|
||||||
icon={<UserPlus className="w-3.5 h-3.5" />}
|
icon={<UserPlus className="w-3.5 h-3.5" />}
|
||||||
>
|
>
|
||||||
@@ -469,6 +671,7 @@ function ClientActivityRow({
|
|||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
className="mt-1 text-sm"
|
className="mt-1 text-sm"
|
||||||
|
data-nav-open=""
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
n.action!.onClick();
|
n.action!.onClick();
|
||||||
dismissClient(n.id);
|
dismissClient(n.id);
|
||||||
@@ -479,9 +682,7 @@ function ClientActivityRow({
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<IconButton label={t("inbox.dismiss")} size="sm" variant="ghost" onClick={onRemove}>
|
<RowEnd onDismiss={onRemove} more={more} />
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
</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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -31,6 +31,10 @@ import { Pressable } from "./ui/Pressable";
|
|||||||
import { Input } from "./ui/Input";
|
import { Input } from "./ui/Input";
|
||||||
import { SURFACE } from "./ui/surfaces";
|
import { SURFACE } from "./ui/surfaces";
|
||||||
import { SectionLabel } from "./ui/Headings";
|
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;
|
const POLL_MS = 4000;
|
||||||
// While any job is running, poll faster so short scheduled runs aren't missed between ticks.
|
// 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)
|
if (q.isError && !data)
|
||||||
return (
|
return (
|
||||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
<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 (q.isLoading && !data) return <LoadingState label={t("scheduler.loading")} />;
|
||||||
if (!data) return <div className="p-8 text-muted">{t("common.somethingWrong")}</div>;
|
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
|
const quotaPct = data.quota.daily_budget
|
||||||
? Math.min(100, Math.round((data.quota.used_today / data.quota.daily_budget) * 100))
|
? Math.min(100, Math.round((data.quota.used_today / data.quota.daily_budget) * 100))
|
||||||
: 0;
|
: 0;
|
||||||
@@ -539,25 +586,6 @@ export default function Scheduler() {
|
|||||||
{t("scheduler.runAll")}
|
{t("scheduler.runAll")}
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</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")}>
|
<Tooltip hint={t("scheduler.pauseHint")}>
|
||||||
<Button
|
<Button
|
||||||
variant="glass"
|
variant="glass"
|
||||||
@@ -569,6 +597,13 @@ export default function Scheduler() {
|
|||||||
{data.paused ? t("scheduler.resume") : t("scheduler.pause")}
|
{data.paused ? t("scheduler.resume") : t("scheduler.pause")}
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</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>
|
</div>
|
||||||
|
|
||||||
{!data.running_here && (
|
{!data.running_here && (
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { useState, type ReactNode } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 clsx from "clsx";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||||
import { api, type DownloadJob } from "../lib/api";
|
import { api, type DownloadJob } from "../lib/api";
|
||||||
import { notify } from "../lib/notifications";
|
|
||||||
import { copyText } from "../lib/clipboard";
|
|
||||||
import {
|
import {
|
||||||
formatBytes,
|
formatBytes,
|
||||||
formatDateTime,
|
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 }) {
|
function SourceRef({ url }: { url: string }) {
|
||||||
const { t } = useTranslation();
|
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 (
|
return (
|
||||||
<div className="flex items-center gap-1 mt-1 text-sm text-muted min-w-0">
|
<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" />
|
<Link2 className="w-3.5 h-3.5 shrink-0" />
|
||||||
@@ -180,12 +176,10 @@ function SourceRef({ url }: { url: string }) {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
title={`${t("downloads.source.open")} — ${url}`}
|
title={`${t("downloads.source.open")} — ${url}`}
|
||||||
className="truncate leading-6 hover:text-accent hover:underline pointer-coarse:leading-(--touch-target)"
|
className="truncate leading-6 hover:text-accent hover:underline pointer-coarse:leading-(--touch-target)"
|
||||||
|
{...nativeMenu}
|
||||||
>
|
>
|
||||||
{displayUrl(url)}
|
{displayUrl(url)}
|
||||||
</a>
|
</a>
|
||||||
<IconButton onClick={copy} label={t("downloads.source.copy")}>
|
|
||||||
<Copy />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -202,6 +196,7 @@ function ChannelLine({ job }: { job: DownloadJob }) {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
title={url}
|
title={url}
|
||||||
className="text-muted hover:text-accent hover:underline"
|
className="text-muted hover:text-accent hover:underline"
|
||||||
|
{...nativeMenu}
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</a>
|
</a>
|
||||||
@@ -215,12 +210,18 @@ export function DownloadRow({
|
|||||||
job,
|
job,
|
||||||
onShowError,
|
onShowError,
|
||||||
navIndex,
|
navIndex,
|
||||||
|
rowProps,
|
||||||
|
more,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
job: DownloadJob;
|
job: DownloadJob;
|
||||||
onShowError?: (job: DownloadJob) => void;
|
onShowError?: (job: DownloadJob) => void;
|
||||||
/** Its position in a keyboard-walkable list (lib/listNav). */
|
/** Its position in a keyboard-walkable list (lib/listNav). */
|
||||||
navIndex?: number;
|
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;
|
children?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -228,7 +229,8 @@ export function DownloadRow({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...(navIndex !== undefined && navItemAttrs(navIndex, jobTitle(job)))}
|
{...(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} />
|
<Thumb job={job} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -302,10 +304,13 @@ export function DownloadRow({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Row action buttons: the md rung here (the row stands alone), the sm rung in the modals, one
|
{/* The row's visible actions — its primary one and the save link — then its "…": the md rung
|
||||||
IconBtn for both. `shrink-0` + no wrap keeps them on the title's line; the text column
|
here (the row stands alone), the sm rung in the modals, one IconBtn for both. `shrink-0` + no
|
||||||
shrinks instead. */}
|
wrap keeps them on the title's line; the text column shrinks instead. */}
|
||||||
<div className="flex items-start gap-1 shrink-0">{children}</div>
|
<div className="flex items-start gap-1 shrink-0">
|
||||||
|
{children}
|
||||||
|
{more}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -92,10 +92,7 @@
|
|||||||
"unlimited": "Unlimited"
|
"unlimited": "Unlimited"
|
||||||
},
|
},
|
||||||
"source": {
|
"source": {
|
||||||
"open": "Open the original page in a new tab",
|
"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"
|
|
||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"title": "Edit details",
|
"title": "Edit details",
|
||||||
@@ -197,6 +194,7 @@
|
|||||||
"noFootprint": "No downloads yet."
|
"noFootprint": "No downloads yet."
|
||||||
},
|
},
|
||||||
"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.",
|
||||||
|
|||||||
@@ -3,8 +3,12 @@
|
|||||||
"empty": "You're all caught up.",
|
"empty": "You're all caught up.",
|
||||||
"markAllRead": "Mark all read",
|
"markAllRead": "Mark all read",
|
||||||
"clearAll": "Clear all",
|
"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",
|
"markRead": "Mark read",
|
||||||
"dismiss": "Dismiss",
|
"dismiss": "Dismiss",
|
||||||
|
"rowMenu": "Notification actions",
|
||||||
|
"menu": "Notification center actions",
|
||||||
"sectionSystem": "System",
|
"sectionSystem": "System",
|
||||||
"sectionActivity": "Activity",
|
"sectionActivity": "Activity",
|
||||||
"andMore": "and {{count}} more",
|
"andMore": "and {{count}} more",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"title": "Background scheduler",
|
"title": "Background scheduler",
|
||||||
|
"menu": "Scheduler actions",
|
||||||
"loading": "Loading scheduler…",
|
"loading": "Loading scheduler…",
|
||||||
"running": "Running",
|
"running": "Running",
|
||||||
"paused": "Paused",
|
"paused": "Paused",
|
||||||
|
|||||||
@@ -92,10 +92,7 @@
|
|||||||
"unlimited": "Korlátlan"
|
"unlimited": "Korlátlan"
|
||||||
},
|
},
|
||||||
"source": {
|
"source": {
|
||||||
"open": "Eredeti oldal megnyitása új lapon",
|
"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"
|
|
||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"title": "Adatok szerkesztése",
|
"title": "Adatok szerkesztése",
|
||||||
@@ -197,6 +194,7 @@
|
|||||||
"noFootprint": "Még nincs letöltés."
|
"noFootprint": "Még nincs letöltés."
|
||||||
},
|
},
|
||||||
"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.",
|
||||||
|
|||||||
@@ -3,8 +3,12 @@
|
|||||||
"empty": "Nincs új értesítés.",
|
"empty": "Nincs új értesítés.",
|
||||||
"markAllRead": "Összes olvasott",
|
"markAllRead": "Összes olvasott",
|
||||||
"clearAll": "Összes törlése",
|
"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",
|
"markRead": "Olvasottnak jelöl",
|
||||||
"dismiss": "Elvetés",
|
"dismiss": "Elvetés",
|
||||||
|
"rowMenu": "Értesítés műveletei",
|
||||||
|
"menu": "Értesítésközpont műveletei",
|
||||||
"sectionSystem": "Rendszer",
|
"sectionSystem": "Rendszer",
|
||||||
"sectionActivity": "Tevékenység",
|
"sectionActivity": "Tevékenység",
|
||||||
"andMore": "és még {{count}}",
|
"andMore": "és még {{count}}",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"title": "Háttér-ütemező",
|
"title": "Háttér-ütemező",
|
||||||
|
"menu": "Ütemező műveletei",
|
||||||
"loading": "Ütemező betöltése…",
|
"loading": "Ütemező betöltése…",
|
||||||
"running": "Fut",
|
"running": "Fut",
|
||||||
"paused": "Szüneteltetve",
|
"paused": "Szüneteltetve",
|
||||||
|
|||||||
Reference in New Issue
Block a user