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 ? (

{t(titleKey, { count: cards.length })}

- + {cards.map((c, i) => (
{d.biography ? ( - + ) : ( - d.enriching && - q.dataUpdatedAt - openedAt < BIO_WAIT_MS && ( + bioOnItsWay && (

{t("plex.series.seasons")}

- + {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 `