diff --git a/frontend/src/components/ShareDialog.tsx b/frontend/src/components/ShareDialog.tsx
index 1f7e1be9..77068885 100644
--- a/frontend/src/components/ShareDialog.tsx
+++ b/frontend/src/components/ShareDialog.tsx
@@ -160,7 +160,7 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
- {t("downloads.share.views", { n: link.view_count })}
+ {t("downloads.share.views", { count: link.view_count })}
{badges.map((b) => (
{d === null
? t("downloads.share.expiryNever")
- : t("downloads.share.days", { n: d })}
+ : t("downloads.share.days", { count: d })}
))}
diff --git a/frontend/src/components/useVideoActions.dom.test.tsx b/frontend/src/components/useVideoActions.dom.test.tsx
new file mode 100644
index 00000000..e6a63d9b
--- /dev/null
+++ b/frontend/src/components/useVideoActions.dom.test.tsx
@@ -0,0 +1,56 @@
+// @vitest-environment jsdom
+//
+// E28 review J4: the video menu's download row was labelled with the download's STATE ("Downloaded",
+// "In queue") — a row that reads as a status yet opens the download dialog. It is "Download…" now,
+// the state riding along as the row's hint.
+import type { ReactNode } from "react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { cleanup, renderHook, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+vi.mock("react-i18next", async (importOriginal) => ({
+ ...(await importOriginal()),
+ useTranslation: () => ({ t: (k: string) => k }),
+}));
+vi.mock("./navigationContext", () => ({ useNavigationActions: () => ({ openChannel: vi.fn() }) }));
+vi.mock("./comfortPin", () => ({
+ pinVideo: vi.fn(),
+ useComfortPin: () => ({ item: () => [] }),
+}));
+
+import { api, type Video } from "../lib/api";
+import { useVideoActions } from "./useVideoActions";
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+const video = { id: "v1", title: "A video", status: "new", position_seconds: 0 } as Video;
+
+function downloadRow(state: string | undefined) {
+ vi.spyOn(api, "downloadIndex").mockResolvedValue(
+ (state ? { v1: state } : {}) as Awaited>,
+ );
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ const wrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+ );
+ const { result } = renderHook(
+ () => useVideoActions({ onState: vi.fn(), onToggleSave: vi.fn() }, true),
+ { wrapper },
+ );
+ return () => result.current.items(video, null).find((i) => i.id === "download")!;
+}
+
+describe("the video menu's download row", () => {
+ it("says what it does, whatever the state, and carries the state as its hint", async () => {
+ const done = downloadRow("done");
+ await waitFor(() => expect(done().title).toBe("downloads.button.downloaded"));
+ expect(done().label).toBe("downloads.menu.download");
+ cleanup();
+ const none = downloadRow(undefined);
+ await waitFor(() => expect(none().label).toBe("downloads.menu.download"));
+ expect(none().title).toBeUndefined();
+ });
+});
diff --git a/frontend/src/components/useVideoActions.tsx b/frontend/src/components/useVideoActions.tsx
index 8f90026b..bcd1fb2f 100644
--- a/frontend/src/components/useVideoActions.tsx
+++ b/frontend/src/components/useVideoActions.tsx
@@ -74,12 +74,14 @@ export function useVideoActions(handlers: VideoActionHandlers, open: boolean) {
const hidden = v.status === "hidden";
const inProgress = v.position_seconds > 0 && !watched;
const dl = index.data?.[v.id];
- const dlLabel =
+ // The row opens the download dialog, so it says so; the download's state is its hint, not its
+ // label — "Downloaded" read as a status, not as a row that opens something (E28 review J4).
+ const dlState =
dl === "done"
? t("downloads.button.downloaded")
: dl === "queued" || dl === "running" || dl === "paused"
? t("downloads.button.queued")
- : t("downloads.button.label");
+ : undefined;
// The two panels open beside the object; a menu opened with no element to anchor to (none of
// the triggers does that today) leaves them out rather than opening them at the corner.
const at = (kind: "playlist" | "details") =>
@@ -140,7 +142,8 @@ export function useVideoActions(handlers: VideoActionHandlers, open: boolean) {
? [
{
id: "download",
- label: dlLabel,
+ label: t("downloads.menu.download"),
+ title: dlState,
icon: ,
onSelect: () => setFollowUp({ kind: "download", video: v }),
},
diff --git a/frontend/src/i18n/locales/en/downloads.json b/frontend/src/i18n/locales/en/downloads.json
index 59e51318..a385c9ce 100644
--- a/frontend/src/i18n/locales/en/downloads.json
+++ b/frontend/src/i18n/locales/en/downloads.json
@@ -1,5 +1,8 @@
{
"navLabel": "Downloads",
+ "menu": {
+ "download": "Download…"
+ },
"button": {
"label": "Download",
"queued": "In queue",
@@ -124,13 +127,15 @@
"allowDownload": "Allow download",
"expiry": "Expires",
"expiryNever": "Never",
- "days": "{{n}} days",
+ "days_one": "{{count}} day",
+ "days_other": "{{count}} days",
"password": "Password (optional)",
"copy": "Copy link",
"copyFailed": "Couldn’t copy",
"revoke": "Revoke",
"revokeConfirm": "This link will stop working immediately.",
- "views": "{{n}} views",
+ "views_one": "{{count}} view",
+ "views_other": "{{count}} views",
"expiresOn": "expires {{date}}",
"hasPassword": "password",
"downloadable": "downloadable",
diff --git a/frontend/src/i18n/locales/hu/downloads.json b/frontend/src/i18n/locales/hu/downloads.json
index 9871919a..0e9c92e9 100644
--- a/frontend/src/i18n/locales/hu/downloads.json
+++ b/frontend/src/i18n/locales/hu/downloads.json
@@ -1,5 +1,8 @@
{
"navLabel": "Letöltések",
+ "menu": {
+ "download": "Letöltés…"
+ },
"button": {
"label": "Letöltés",
"queued": "Sorban",
@@ -124,13 +127,15 @@
"allowDownload": "Letöltés engedélyezése",
"expiry": "Lejárat",
"expiryNever": "Soha",
- "days": "{{n}} nap",
+ "days_one": "{{count}} nap",
+ "days_other": "{{count}} nap",
"password": "Jelszó (opcionális)",
"copy": "Link másolása",
"copyFailed": "Nem sikerült másolni",
"revoke": "Visszavonás",
"revokeConfirm": "A link azonnal használhatatlanná válik.",
- "views": "{{n}} megtekintés",
+ "views_one": "{{count}} megtekintés",
+ "views_other": "{{count}} megtekintés",
"expiresOn": "lejár: {{date}}",
"hasPassword": "jelszó",
"downloadable": "letölthető",
diff --git a/frontend/src/i18n/plurals.test.ts b/frontend/src/i18n/plurals.test.ts
new file mode 100644
index 00000000..24c3adc2
--- /dev/null
+++ b/frontend/src/i18n/plurals.test.ts
@@ -0,0 +1,24 @@
+// @vitest-environment jsdom
+//
+// E28 review J5: a count in copy goes through i18next's plural forms (`count`), never a bare `n` —
+// the share dialog's expiry read "1 days".
+import { describe, expect, it } from "vitest";
+
+import i18n from "./index";
+
+describe("counted copy", () => {
+ it("the share link's expiry is singular for one day", async () => {
+ await i18n.changeLanguage("en");
+ expect(i18n.t("downloads.share.days", { count: 1 })).toBe("1 day");
+ expect(i18n.t("downloads.share.days", { count: 7 })).toBe("7 days");
+ await i18n.changeLanguage("hu");
+ expect(i18n.t("downloads.share.days", { count: 1 })).toBe("1 nap");
+ await i18n.changeLanguage("en");
+ });
+
+ it("a link's view count is singular for one view", async () => {
+ await i18n.changeLanguage("en");
+ expect(i18n.t("downloads.share.views", { count: 1 })).toBe("1 view");
+ expect(i18n.t("downloads.share.views", { count: 0 })).toBe("0 views");
+ });
+});