diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js
index 414099d7..4500b12e 100644
--- a/frontend/eslint.config.js
+++ b/frontend/eslint.config.js
@@ -428,6 +428,12 @@ const BOX_PX =
"/(^|[\\s:])(min-|max-)?(w|h|size|left|right|top|bottom|inset|gap|p[xytrbl]?|m[xytrbl]?)-\\[[\\d.]+px\\]/";
const BOX_PX_MESSAGE =
"px-frozen box size doesn't scale with --font-scale: use the spacing scale (w-37.5, max-w-60), not w-[Npx].";
+// …and a grid's auto-fill column MINIMUM in px (`minmax(150px,1fr)`): the cards' text grows with the
+// slider while the column floor stays, so the cards get cramped at a large text size (E30 review). A
+// fixed track (`grid-cols-[1fr_240px]`) is left alone, as is VirtualGrid's measured `minCol` (a number).
+const GRID_MIN_PX = "/minmax\\([\\d.]+px,/";
+const GRID_MIN_PX_MESSAGE =
+ "px-frozen box size doesn't scale with --font-scale: give a grid column's minimum in rem (minmax(9.375rem,1fr)), not minmax(Npx,…).";
// A stacking order outside the layer scale (DESIGN.md Tokens: `base < menu < chrome < … < tooltip` in
// index.css). A numeric `z-10` equals a level today but doesn't follow it when the scale moves; name it.
const Z_RAW = "/(^|[\\s:])-?z-([1-9]\\d*|\\[[^\\]]+\\])(?![\\w-])/";
@@ -587,6 +593,8 @@ export default tseslint.config(
// 150px at the default size) — 32 of these were swept in the 2026-09 debt burn.
{ selector: `Literal[value=${BOX_PX}]`, message: BOX_PX_MESSAGE },
{ selector: `TemplateElement[value.raw=${BOX_PX}]`, message: BOX_PX_MESSAGE },
+ { selector: `Literal[value=${GRID_MIN_PX}]`, message: GRID_MIN_PX_MESSAGE },
+ { selector: `TemplateElement[value.raw=${GRID_MIN_PX}]`, message: GRID_MIN_PX_MESSAGE },
{ selector: `Literal[value=${Z_RAW}]`, message: Z_RAW_MESSAGE },
{ selector: `TemplateElement[value.raw=${Z_RAW}]`, message: Z_RAW_MESSAGE },
{ selector: `Literal[value=${HOVER_SWAP}]`, message: HOVER_SWAP_MESSAGE },
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 71ff6d69..deac0a4c 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,5 +1,6 @@
import { lazy, Suspense, useEffect, useRef, useState } from "react";
import { qk } from "./lib/queryKeys";
+import { saveLanguage } from "./lib/saveLanguage";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api, setUnauthorizedHandler, type Me } from "./lib/api";
@@ -333,9 +334,9 @@ export default function App() {
setLanguage(code);
// Also from the signed-out login page, where the save 401s — `req` marks that as handled, so
// the reporter stays quiet there.
- api
- .savePrefs({ language: code })
- .catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.prefs")));
+ saveLanguage(queryClient, code).catch((e: unknown) =>
+ notifyActionError(e, t("errors.writeFailed.prefs")),
+ );
}
// First-run: a fresh instance is in setup mode (all other routes are locked) → show the wizard.
diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx
index dc72735f..13d9e541 100644
--- a/frontend/src/components/AdminUsers.tsx
+++ b/frontend/src/components/AdminUsers.tsx
@@ -629,7 +629,8 @@ function AdminInvites() {
// is also where the "you already wrote to them" warning belongs, next to the button that would
// do it a second time, rather than only in a tooltip the admin may never hover.
const onInvite = async (inv: Invite) => {
- let lang: LangCode = LANGUAGES.some((l) => l.code === inv.lang)
+ // The language the INVITE is written in — not the admin's `lang`, which dates the sentence below.
+ let mailLang: LangCode = LANGUAGES.some((l) => l.code === inv.lang)
? (inv.lang as LangCode)
: ((i18n.language.split("-")[0] === "hu" ? "hu" : "en") as LangCode);
const ok = await confirm({
@@ -647,12 +648,12 @@ function AdminInvites() {
},
)}
- (lang = v)} />
+ (mailLang = v)} />
>
),
confirmLabel: t("settings.invites.inviteSend"),
});
- if (ok) invite.mutate({ inv, lang });
+ if (ok) invite.mutate({ inv, lang: mailLang });
};
const add = useMutation({
diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx
index 356af484..f632172e 100644
--- a/frontend/src/components/Channels.tsx
+++ b/frontend/src/components/Channels.tsx
@@ -171,7 +171,10 @@ export default function Channels() {
const listRef = useRef(null);
const pickPriority = (c: ManagedChannel, next: number) => {
setPriority(c.id, next);
- qc.setQueryData(qk.channels(), (old) => old && placeByPriority(old, c.id));
+ qc.setQueryData(
+ qk.channels(),
+ (old) => old && placeByPriority(old, c.id, lang),
+ );
setFollow((f) => ({ key: c.id, seq: (f?.seq ?? 0) + 1 }));
};
diff --git a/frontend/src/components/NotificationsPanel.tsx b/frontend/src/components/NotificationsPanel.tsx
index 9249e979..99912cd7 100644
--- a/frontend/src/components/NotificationsPanel.tsx
+++ b/frontend/src/components/NotificationsPanel.tsx
@@ -18,8 +18,8 @@ import {
UserPlus,
X,
} from "lucide-react";
-import { api, type AppNotification, type JobResult } from "../lib/api";
-import { jobResultText, parseSummary } from "../lib/jobResult";
+import { api, type AppNotification } from "../lib/api";
+import { jobRunText } from "../lib/jobResult";
import { notifyActionError } from "../lib/actionErrors";
import { useLiveQuery } from "../lib/useLiveQuery";
import { focusAccessRequestsTab } from "../lib/adminUsersTab";
@@ -530,13 +530,9 @@ function NotificationRow({
} else if (isScheduler) {
// " finished/failed", with the result as the body — named in the reader's language
// when it rode along as data (E30), else the English summary of an older notice.
- const d = n.data as { job_id: string; status?: string; summary?: string; result?: JobResult };
- const job = t(`scheduler.jobs.${d.job_id}`, d.job_id);
- title = t(d.status === "error" ? "inbox.jobDone.titleError" : "inbox.jobDone.titleOk", {
- job,
- });
- const result = d.result ?? parseSummary(d.summary);
- body = result ? jobResultText(t, result, lang) : d.summary || n.body;
+ const run = jobRunText(t, n.data as { job_id: string }, lang);
+ title = run.title;
+ body = run.body || n.body;
} else if (isEmailUndelivered) {
// Email is fail-soft: a drop leaves no error anywhere else, so this notice IS the trace. Name
// the message that was lost, since that is what tells the operator what their users didn't get.
diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx
index d71ff5e7..4bd12039 100644
--- a/frontend/src/components/PlexBrowse.tsx
+++ b/frontend/src/components/PlexBrowse.tsx
@@ -699,7 +699,7 @@ export default function PlexBrowse() {
{episodes.length > 0 && (
{t("plex.unified.titles")}
)}
-
+
{items.map((c, i) => (
0 && (
{t("plex.unified.episodes")}
-
+
{episodes.map((ep, i) => (
{
}
});
});
+
+describe("PlexCollectionView — the summary (E30 review)", () => {
+ it("offers no More under a summary that fits", async () => {
+ vi.spyOn(api, "plexCollection").mockResolvedValue({
+ ...detail([card("m1", "movie")]),
+ summary: "One short line.",
+ } as PlexCollectionDetail);
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ render(
+
+ {}}
+ onPlay={vi.fn()}
+ onOpenCard={() => {}}
+ onRestartCard={() => {}}
+ onInfoCard={() => {}}
+ />
+ ,
+ );
+ await screen.findByText("One short line.");
+ expect(screen.queryByText("plex.collection.more")).toBeNull(); // jsdom: nothing is cut
+ });
+});
diff --git a/frontend/src/components/PlexCollectionView.tsx b/frontend/src/components/PlexCollectionView.tsx
index f17891e6..b903f883 100644
--- a/frontend/src/components/PlexCollectionView.tsx
+++ b/frontend/src/components/PlexCollectionView.tsx
@@ -24,6 +24,7 @@ import { toggleWatchState } from "../lib/plexWatch";
import { LoadingState, StateMessage } from "./ui/QueryState";
import NavGrid from "./ui/NavGrid";
import { Button } from "./ui/Button";
+import { ClampedText } from "./ui/ClampedText";
import { IconButton } from "./ui/IconButton";
import { Input } from "./ui/Input";
import { useReturnFocus } from "./ui/useReturnFocus";
@@ -98,7 +99,6 @@ export default function PlexCollectionView({
});
const d = q.data;
- const [expanded, setExpanded] = useState(false);
const [renaming, setRenaming] = useState(false);
const renameFocus = useReturnFocus(renaming);
const [name, setName] = useState("");
@@ -333,21 +333,13 @@ export default function PlexCollectionView({
)}
{d.summary && (
-
-
- {d.summary}
-
-
-
+
)}
diff --git a/frontend/src/components/PlexPersonView.dom.test.tsx b/frontend/src/components/PlexPersonView.dom.test.tsx
index 11b5630e..483d589f 100644
--- a/frontend/src/components/PlexPersonView.dom.test.tsx
+++ b/frontend/src/components/PlexPersonView.dom.test.tsx
@@ -7,7 +7,7 @@
// and the api is mocked so the query resolves synchronously in the test.
import { setLanguage } from "../i18n";
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
-import { render, screen, fireEvent, cleanup } from "@testing-library/react";
+import { act, render, screen, fireEvent, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
beforeAll(() => {
@@ -187,6 +187,8 @@ describe("PlexPersonView — the reader's language (E30)", () => {
});
});
+const BIO_POLL = 1600; // just past plexViews' BIO_POLL_MS
+
describe("PlexPersonView — a bio on its way (E30 S6)", () => {
const view = (
{
expect(screen.queryByRole("status")).toBeNull();
});
+ it("stops asking once the wait is over, and takes the placeholder away", async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ try {
+ plexPerson.mockReset();
+ plexPerson.mockResolvedValue(person({ biography: null, enriching: true })); // TMDB never answers
+ wrap(view);
+ await screen.findByText("Movie One");
+ await act(() => vi.advanceTimersByTimeAsync(21_000));
+ const asked = plexPerson.mock.calls.length;
+ expect(asked).toBeGreaterThan(2);
+ await act(() => vi.advanceTimersByTimeAsync(10_000));
+ expect(plexPerson).toHaveBeenCalledTimes(asked);
+ expect(screen.queryByRole("status")).toBeNull();
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("stops asking when the page's own request fails", async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ try {
+ plexPerson.mockReset();
+ plexPerson
+ .mockResolvedValueOnce(person({ biography: null, enriching: true }))
+ .mockRejectedValue(new Error("server down"));
+ wrap(view);
+ await screen.findByText("Movie One");
+ await act(() => vi.advanceTimersByTimeAsync(BIO_POLL));
+ expect(plexPerson).toHaveBeenCalledTimes(2);
+ await act(() => vi.advanceTimersByTimeAsync(10_000));
+ expect(plexPerson).toHaveBeenCalledTimes(2); // not every 1.5 s for as long as it fails
+ expect(screen.queryByRole("status")).toBeNull();
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
it("offers More only when the bio is cut, and Less once open", async () => {
const tall = vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(400);
const box = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(160);
diff --git a/frontend/src/components/PlexPlaylistView.menu.dom.test.tsx b/frontend/src/components/PlexPlaylistView.menu.dom.test.tsx
index c6e22b46..5f94722f 100644
--- a/frontend/src/components/PlexPlaylistView.menu.dom.test.tsx
+++ b/frontend/src/components/PlexPlaylistView.menu.dom.test.tsx
@@ -206,7 +206,7 @@ describe("PlexPlaylistView · a row's menu", () => {
it("removes one season from its label's menu", async () => {
mount([ep("e1", 1, 1), ep("e2", 2, 1), ep("e3", 2, 2)]);
fireEvent.click(await screen.findByText("The Show")); // open the group
- const seasonLabel = await screen.findByText(/plex\.playlist\.season · 2/);
+ const seasonLabel = await screen.findByText(/plex\.seasonTitle · 2/);
const more = seasonLabel.parentElement!.querySelector(
'button[aria-label="plex.playlist.rowMenu"]',
)!;
@@ -363,3 +363,12 @@ describe("PlexPlaylistView · the reader's language (E30)", () => {
}
});
});
+
+describe("PlexPlaylistView · a season's name (E30 review)", () => {
+ it("names season 0 the Specials, as the show page does", async () => {
+ mount([ep("e0", 0, 1), ep("e1", 1, 1)]);
+ fireEvent.click(await screen.findByText("The Show")); // open the show's seasons
+ expect(await screen.findByText(/^plex\.specials · 1$/)).toBeTruthy();
+ expect(screen.getByText(/^plex\.seasonTitle · 1$/)).toBeTruthy();
+ });
+});
diff --git a/frontend/src/components/PlexPlaylistView.tsx b/frontend/src/components/PlexPlaylistView.tsx
index 13ec004e..0285992d 100644
--- a/frontend/src/components/PlexPlaylistView.tsx
+++ b/frontend/src/components/PlexPlaylistView.tsx
@@ -54,6 +54,7 @@ import { useReturnFocus } from "./ui/useReturnFocus";
import { SURFACE } from "./ui/surfaces";
import { PageHeading } from "./ui/Headings";
import { MEDIA_BADGE } from "./ui/mediaCardStyles";
+import { seasonTitle } from "../lib/plexDetail";
// A playlist's items, grouped for readability: movies stay standalone, while a run of episodes from the
// same show collapses into a season/show group so a whole series doesn't sprawl into an endless flat
@@ -259,13 +260,11 @@ function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) {
);
}
-/** A season's name, as its label and in the toast that says it went. */
+/** A season's name, as its label and in the toast that says it went — named as the show page names it
+ * (season 0 is the Specials). */
function useSeasonLabel() {
const { t } = useTranslation();
- return (s: Season) =>
- s.number != null
- ? t("plex.playlist.season", { n: s.number })
- : t("plex.playlist.seasonUnknown");
+ return (s: Season) => seasonTitle({ season_number: s.number }, t);
}
function ShowGroup({ block, ctx }: { block: ShowBlock; ctx: Ctx }) {
diff --git a/frontend/src/components/adminInvites.dom.test.tsx b/frontend/src/components/adminInvites.dom.test.tsx
index 3cf6dc21..536b98cb 100644
--- a/frontend/src/components/adminInvites.dom.test.tsx
+++ b/frontend/src/components/adminInvites.dom.test.tsx
@@ -10,6 +10,7 @@
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { formatDateTime } from "../lib/format";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal();
@@ -18,7 +19,8 @@ vi.mock("react-i18next", async (importOriginal) => {
useTranslation: () => ({
// Echo the interpolation too, so a message that forgets its {{email}} is visible in the test.
t: (key: string, vars?: Record) =>
- vars?.email ? `${key}:${String(vars.email)}` : key,
+ (vars?.email ? `${key}:${String(vars.email)}` : key) +
+ (vars?.time ? `@${String(vars.time)}` : ""),
i18n: { language: "en" },
}),
};
@@ -315,6 +317,19 @@ describe("inviting someone the admin whitelisted", () => {
expect(dialog.container.textContent).toContain("settings.invites.inviteConfirmAgain");
});
+ it("dates the earlier invite in the admin's language, not the invitee's (E30 review)", async () => {
+ listMock.mockResolvedValue([
+ invite({ status: "approved", invited_at: "2026-09-05T09:00:00Z", lang: "hu" }),
+ ]);
+ await open();
+ fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
+ await waitFor(() => expect(confirmMock).toHaveBeenCalled());
+ const dialog = render(<>{confirmMock.mock.calls[0]![0].message}>);
+ const at = "2026-09-05T09:00:00Z";
+ expect(dialog.container.textContent).toContain(`@${formatDateTime(at, "en")}`);
+ expect(formatDateTime(at, "en")).not.toBe(formatDateTime(at, "hu"));
+ });
+
it("offers none on a denied row, which would contradict the decision", async () => {
listMock.mockResolvedValue([invite({ status: "denied" })]);
await open();
diff --git a/frontend/src/components/auditColumns.dom.test.tsx b/frontend/src/components/auditColumns.dom.test.tsx
index e9d4bde6..cc299a5c 100644
--- a/frontend/src/components/auditColumns.dom.test.tsx
+++ b/frontend/src/components/auditColumns.dom.test.tsx
@@ -82,6 +82,68 @@ describe("an audit row's details", () => {
expect(details(secret)).toMatch(/ \| titok frissítve/);
});
+ it("name a nested result and a skipped run from the row's data (E30 review)", () => {
+ const nested = row({
+ target_type: "job",
+ target_id: "people_enrich",
+ after: {
+ status: "ok",
+ summary: "bios={'enriched': 12}, portraits={'cached': 40}",
+ result: { bios: { enriched: 12 }, portraits: { cached: 40 } },
+ },
+ });
+ expect(details(nested)).not.toContain("{");
+ expect(details(nested)).toMatch(/^Személyadatok és portrék \(Plex\) kész \| /);
+ const skipped = row({
+ target_type: "job",
+ target_id: "rss_poll",
+ after: { status: "skipped", summary: "sync paused" },
+ });
+ expect(details(skipped)).toBe(
+ "RSS-lekérdezés (új feltöltések) kihagyva | kihagyva: a szinkron szünetel",
+ );
+ });
+
+ it("tell a secret's reset from its update, and name a choice", () => {
+ const reset = row({
+ action: "config_reset",
+ target_type: "config",
+ target_id: "tmdb_api_key",
+ summary: "tmdb_api_key → default",
+ });
+ expect(details(reset)).toMatch(/ \| titok visszaállítva/);
+ const choice = row({
+ action: "config_set",
+ target_type: "config",
+ target_id: "plex_transcode_hw",
+ before: { value: "auto" },
+ after: { value: "on" },
+ });
+ expect(details(choice)).toMatch(/ \| Automatikus → GPU$/);
+ });
+
+ it("name an interval, a batch and a count in the reader's language", () => {
+ expect(
+ details(
+ row({
+ action: "scheduler_interval",
+ target_type: "job",
+ target_id: "rss_poll",
+ summary: "rss_poll → 15 min/run",
+ after: { interval_minutes: 15 },
+ }),
+ ),
+ ).toBe("RSS-lekérdezés (új feltöltések) | 15 percenként");
+ expect(
+ details(row({ action: "maintenance_tune", after: { revalidate_batch: 50000 } })),
+ ).toMatch(/^Futásonként újraellenőrzött videók \| 50\s000$/);
+ expect(
+ details(
+ row({ action: "audit_clear", summary: "Cleared 12 audit entries", after: { cleared: 12 } }),
+ ),
+ ).not.toContain("Cleared");
+ });
+
it("add nothing to a pause, whose action already says it", () => {
expect(details(row({ action: "sync_pause", summary: "Background sync paused" }))).toBe("");
});
diff --git a/frontend/src/components/auditColumns.tsx b/frontend/src/components/auditColumns.tsx
index 8fc643e9..d9181cb6 100644
--- a/frontend/src/components/auditColumns.tsx
+++ b/frontend/src/components/auditColumns.tsx
@@ -1,7 +1,8 @@
import type { TFunction } from "i18next";
import type { AuditRow } from "../lib/api";
import { formatNumber } from "../lib/format";
-import { jobResultText, parseSummary } from "../lib/jobResult";
+import type { JobResult } from "../lib/api";
+import { jobResultText, jobRunText } from "../lib/jobResult";
import type { Column } from "./ui/DataTable";
import { RelativeTime } from "./ui/RelativeTime";
@@ -14,17 +15,27 @@ function fmt(v: unknown): string {
return v == null || v === "" ? "—" : String(v);
}
-/** A logged config value in the reader's language: a switch reads on/off, a count is grouped. */
-function configValue(t: TFunction, v: unknown, lang: string): string {
+/** A logged config value in the reader's language: a switch reads on/off, a count is grouped, a
+ * choice is named as the Configuration page names it. */
+function configValue(t: TFunction, key: string, v: unknown, lang: string): string {
if (typeof v === "boolean") return t(v ? "audit.on" : "audit.off");
if (typeof v === "number") return formatNumber(v, lang);
+ if (typeof v === "string" && v) return t(`config.choices.${key}.${v}`, { defaultValue: v });
return fmt(v);
}
+/** A row's `after` as a job-style result (counts), or null when it holds none. */
+function counts(after: Record): JobResult | null {
+ const out: JobResult = {};
+ for (const [k, v] of Object.entries(after)) if (typeof v === "number") out[k] = v;
+ return Object.keys(out).length ? out : null;
+}
+
/** The details of the rows whose server `summary` is a fixed English line (E30), rebuilt from the row's
* data in the reader's language: a job run names the job and its result (as the inbox does), a config
- * change names the field and its values, a pause/resume says nothing the action has not. `undefined` =
- * not one of these; the generic summary + change rendering applies. */
+ * change names the field and its values, an interval or batch change its new value, a count-bearing
+ * action its counts; a pause/resume says nothing the action has not. `undefined` = not one of these (or
+ * an older row without the data); the generic summary + change rendering applies. */
function localDetails(
t: TFunction,
row: AuditRow,
@@ -33,23 +44,40 @@ function localDetails(
const after = row.after ?? {};
const before = row.before ?? {};
if (row.action === "job_run" && row.target_type === "job" && row.target_id) {
- const job = t(`scheduler.jobs.${row.target_id}`, row.target_id);
- const failed = after.status === "error";
- const summary = typeof after.summary === "string" ? after.summary : null;
- // A nested phase (a Python dict repr) or an error text does not parse, and shows as it was.
- const result = failed ? null : parseSummary(summary);
- return {
- head: t(failed ? "inbox.jobDone.titleError" : "inbox.jobDone.titleOk", { job }),
- sub: result ? jobResultText(t, result, lang) : summary,
- };
+ const run = jobRunText(t, { job_id: row.target_id, ...after }, lang);
+ return { head: run.title, sub: run.body };
}
if ((row.action === "config_set" || row.action === "config_reset") && row.target_id) {
- const head = t(`config.fields.${row.target_id}.label`, row.target_id);
+ const key = row.target_id;
+ const head = t(`config.fields.${key}.label`, key);
+ const reset = row.action === "config_reset";
// A secret's value is never logged: there is no before/after to show.
- if (!row.before && !row.after) return { head, sub: t("audit.secretUpdated") };
- const to =
- row.action === "config_reset" ? t("audit.default") : configValue(t, after.value, lang);
- return { head, sub: `${configValue(t, before.value, lang)} → ${to}` };
+ if (!row.before && !row.after)
+ return { head, sub: t(reset ? "audit.secretReset" : "audit.secretUpdated") };
+ const to = reset ? t("audit.default") : configValue(t, key, after.value, lang);
+ return { head, sub: `${configValue(t, key, before.value, lang)} → ${to}` };
+ }
+ if (
+ row.action === "scheduler_interval" &&
+ row.target_id &&
+ typeof after.interval_minutes === "number"
+ )
+ return {
+ head: t(`scheduler.jobs.${row.target_id}`, row.target_id),
+ sub: t("scheduler.everyMin", { count: after.interval_minutes }),
+ };
+ if (row.action === "maintenance_tune" && typeof after.revalidate_batch === "number")
+ return {
+ head: t("scheduler.maintenance.batchLabel"),
+ sub: formatNumber(after.revalidate_batch, lang),
+ };
+ if (
+ row.action === "audit_clear" ||
+ row.action === "demo_reset" ||
+ row.action === "discovery_purge"
+ ) {
+ const result = counts(after);
+ return result ? { head: null, sub: jobResultText(t, result, lang) } : undefined;
}
if (row.action === "sync_pause" || row.action === "sync_resume") return { head: null, sub: null };
return undefined;
diff --git a/frontend/src/components/plexViews.tsx b/frontend/src/components/plexViews.tsx
index 47eb2755..e31979fd 100644
--- a/frontend/src/components/plexViews.tsx
+++ b/frontend/src/components/plexViews.tsx
@@ -1,4 +1,4 @@
-import { lazy, Suspense, useLayoutEffect, useRef, useState } from "react";
+import { lazy, Suspense, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
@@ -48,6 +48,7 @@ import { useCopyItems } from "./copyItems";
import { ContextMenu } from "./ui/ContextMenu";
import { useContextMenu } from "./ui/useContextMenu";
import { Button } from "./ui/Button";
+import { ClampedText } from "./ui/ClampedText";
import { Carousel } from "./ui/Carousel";
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
import PlexCollectionEditor from "./PlexCollectionEditor";
@@ -128,45 +129,6 @@ const BIO_POLL_MS = 1500;
const KNOWN_FOR_HALF = 4;
const BIO_WAIT_MS = 20_000;
-/** A person's biography, clamped to a few lines with a More / Less toggle — offered only when the
- * text is actually cut (measured, so a short bio carries no dead button). */
-function PersonBio({ text }: { text: string }) {
- const { t } = useTranslation();
- const [expanded, setExpanded] = useState(false);
- const [clipped, setClipped] = useState(false);
- const ref = useRef(null);
- useLayoutEffect(() => {
- const el = ref.current;
- if (!el || expanded) return;
- // Re-measured on a resize: the text-size slider and the window both change where the cut falls.
- const measure = () => setClipped(el.scrollHeight > el.clientHeight + 1);
- measure();
- const ro = new ResizeObserver(measure);
- ro.observe(el);
- return () => ro.disconnect();
- }, [text, expanded]);
- return (
-
-
- {text}
-
- {(clipped || expanded) && (
-
- )}
-
- );
-}
-
// --- Actor detail: a cast/crew member's filmography (movies + shows in one place) ---------------
// The inverse of the cast-name → library-filter link: opened from a cast row (info/show page), it
// shows the person's headshot + name, a "filter the library by them" action, and their titles as two
@@ -196,16 +158,23 @@ export function PlexPersonView({
const personKey = qk.plexPerson(name, lang);
// A person the people_enrich job hasn't reached yet gets their bio fetched when the page opens (E30
// S6); the answer says so (`enriching`) and the page asks again until it lands — for a while, not
- // forever: past BIO_WAIT_MS a TMDB that is down stops being waited on (the next open tries again).
- const [openedAt] = useState(() => Date.now());
+ // forever: BIO_WAIT_MS after the page opened (by the clock, not by the last answer — a failing ask
+ // leaves that where it was) a TMDB that is down stops being waited on, and a failing ask ends it at
+ // once (the next open tries again). The page is keyed on the person, so the wait starts per person.
+ const [waitOver, setWaitOver] = useState(false);
+ useEffect(() => {
+ const timer = setTimeout(() => setWaitOver(true), BIO_WAIT_MS);
+ return () => clearTimeout(timer);
+ }, []);
const q = useQuery({
queryKey: personKey,
queryFn: () => api.plexPerson(name, lang),
refetchInterval: (query) =>
- query.state.data?.enriching && query.state.dataUpdatedAt - openedAt < BIO_WAIT_MS
+ query.state.data?.enriching && !waitOver && query.state.status !== "error"
? BIO_POLL_MS
: false,
});
+ const bioOnItsWay = !!q.data?.enriching && !waitOver && !q.isError;
// E5 S8: cross-dissolve the backdrop through the person's films' art (the touch the roadmap calls
// out). Reuses the shared art-backdrop / crossfade prefs the detail pages own — no per-page control.
const { artBg, crossfade } = useDetailPrefs();
@@ -256,7 +225,7 @@ export function PlexPersonView({
cards.length > 0 ? (
-
+
{d.seasons.map((se, i) => (
-
+
{se.episodes.map((ep, i) => (
{
+ class Stub {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ takeRecords() {
+ return [];
+ }
+ }
+ globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
+});
+afterEach(cleanup);
+
+const view = (text: string) => (
+
+);
+
+describe("ClampedText", () => {
+ it("offers no toggle for a text that fits", () => {
+ render(view("Short."));
+ expect(screen.queryByText("More")).toBeNull(); // jsdom: nothing is cut
+ });
+
+ it("offers More for a cut text, and Less once open", () => {
+ const tall = vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(400);
+ const box = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(60);
+ try {
+ render(view("A long text.\n\nOn and on."));
+ const p = screen.getByText(/A long text/);
+ expect(p.className).toContain("line-clamp-3");
+ fireEvent.click(screen.getByText("More"));
+ expect(screen.getByText("Less").getAttribute("aria-expanded")).toBe("true");
+ expect(p.className).not.toContain("line-clamp-3");
+ } finally {
+ tall.mockRestore();
+ box.mockRestore();
+ }
+ });
+});
diff --git a/frontend/src/components/ui/ClampedText.tsx b/frontend/src/components/ui/ClampedText.tsx
new file mode 100644
index 00000000..0160295f
--- /dev/null
+++ b/frontend/src/components/ui/ClampedText.tsx
@@ -0,0 +1,55 @@
+import { useLayoutEffect, useRef, useState } from "react";
+import { Button } from "./Button";
+
+// Static class names, so Tailwind sees them.
+const CLAMP = { 3: "line-clamp-3", 8: "line-clamp-8" } as const;
+
+/** A long text (a biography, a summary) clamped to a few lines, with a More / Less toggle offered only
+ * when the text is actually cut — measured, so a short text carries no dead button. Re-measured on a
+ * resize: the window and the text-size slider both move where the cut falls. */
+export function ClampedText({
+ text,
+ lines,
+ moreLabel,
+ lessLabel,
+ className,
+}: {
+ text: string;
+ lines: keyof typeof CLAMP;
+ moreLabel: string;
+ lessLabel: string;
+ className?: string;
+}) {
+ const [expanded, setExpanded] = useState(false);
+ const [clipped, setClipped] = useState(false);
+ const ref = useRef(null);
+ useLayoutEffect(() => {
+ const el = ref.current;
+ if (!el || expanded) return;
+ const measure = () => setClipped(el.scrollHeight > el.clientHeight + 1);
+ measure();
+ const ro = new ResizeObserver(measure);
+ ro.observe(el);
+ return () => ro.disconnect();
+ }, [text, expanded]);
+ return (
+
+
+ {text}
+
+ {(clipped || expanded) && (
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/ui/README.md b/frontend/src/components/ui/README.md
index c6cb00a7..7c85e7d9 100644
--- a/frontend/src/components/ui/README.md
+++ b/frontend/src/components/ui/README.md
@@ -40,7 +40,7 @@ domain-free.
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `