fix(ui,i18n): E30 review round 1 — audit rows in the reader's language, one HU name per thing
- audit log: a job run named from its result data (nested phases too), a skipped run says so, a secret's reset reads as a reset, choices named as Configuration names them, interval / batch / counts localized; the inbox shares the same jobRunText - HU: remux is "Közvetlen közvetítés" (it shared "Közvetlen lejátszás" with direct play); backfill is behúzás, enrich is dúsítás, collection is kollekció, the allow-list is fehérlista; the last "jobnak" / "transzkódolás"; a test holds the options of one choice to distinct names - contract: every skipped / reason word a job returns is named in both languages - person page: the bio wait is timed from the open and a failing ask stops the polling; ClampedText (ui) shared with the collection page, whose short summary lost its dead More - a playlist names season 0 the Specials; the channel re-slot compares in the reader's alphabet; an invite's date is in the admin's language; the language-ordered lists are asked again once a language switch is saved (saveLanguage) - poster grids' column minimum in rem; lint refuses minmax(Npx,…)
This commit is contained in:
@@ -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() {
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
<InviteLangPicker initial={lang} onPick={(v) => (lang = v)} />
|
||||
<InviteLangPicker initial={mailLang} onPick={(v) => (mailLang = v)} />
|
||||
</>
|
||||
),
|
||||
confirmLabel: t("settings.invites.inviteSend"),
|
||||
});
|
||||
if (ok) invite.mutate({ inv, lang });
|
||||
if (ok) invite.mutate({ inv, lang: mailLang });
|
||||
};
|
||||
|
||||
const add = useMutation({
|
||||
|
||||
@@ -171,7 +171,10 @@ export default function Channels() {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const pickPriority = (c: ManagedChannel, next: number) => {
|
||||
setPriority(c.id, next);
|
||||
qc.setQueryData<ManagedChannel[]>(qk.channels(), (old) => old && placeByPriority(old, c.id));
|
||||
qc.setQueryData<ManagedChannel[]>(
|
||||
qk.channels(),
|
||||
(old) => old && placeByPriority(old, c.id, lang),
|
||||
);
|
||||
setFollow((f) => ({ key: c.id, seq: (f?.seq ?? 0) + 1 }));
|
||||
};
|
||||
|
||||
|
||||
@@ -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) {
|
||||
// "<Job label> 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.
|
||||
|
||||
@@ -699,7 +699,7 @@ export default function PlexBrowse() {
|
||||
{episodes.length > 0 && (
|
||||
<h2 className="text-sm font-semibold mb-2">{t("plex.unified.titles")}</h2>
|
||||
)}
|
||||
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
|
||||
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(9.375rem,1fr))] gap-3">
|
||||
{items.map((c, i) => (
|
||||
<PlexPosterCard
|
||||
key={c.id}
|
||||
@@ -721,7 +721,7 @@ export default function PlexBrowse() {
|
||||
{episodes.length > 0 && (
|
||||
<section className="mt-8">
|
||||
<h2 className="text-sm font-semibold mb-3">{t("plex.unified.episodes")}</h2>
|
||||
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-x-3 gap-y-4">
|
||||
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(14.375rem,1fr))] gap-x-3 gap-y-4">
|
||||
{episodes.map((ep, i) => (
|
||||
<EpisodeCard
|
||||
key={ep.id}
|
||||
|
||||
@@ -227,3 +227,27 @@ describe("PlexCollectionView — the reader's language (E30)", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PlexCollectionView
|
||||
collectionId="col1"
|
||||
onBack={() => {}}
|
||||
onPlay={vi.fn()}
|
||||
onOpenCard={() => {}}
|
||||
onRestartCard={() => {}}
|
||||
onInfoCard={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await screen.findByText("One short line.");
|
||||
expect(screen.queryByText("plex.collection.more")).toBeNull(); // jsdom: nothing is cut
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 && (
|
||||
<div className="mt-3">
|
||||
<p
|
||||
className={`measure text-sm leading-relaxed text-muted ${expanded ? "" : "line-clamp-3"}`}
|
||||
>
|
||||
{d.summary}
|
||||
</p>
|
||||
<Button
|
||||
variant="link"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="mt-1 text-xs"
|
||||
>
|
||||
{expanded ? t("plex.collection.less") : t("plex.collection.more")}
|
||||
</Button>
|
||||
</div>
|
||||
<ClampedText
|
||||
text={d.summary}
|
||||
lines={3}
|
||||
moreLabel={t("plex.collection.more")}
|
||||
lessLabel={t("plex.collection.less")}
|
||||
className="mt-3"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
|
||||
@@ -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 = (
|
||||
<PlexPersonView
|
||||
@@ -231,6 +233,43 @@ describe("PlexPersonView — a bio on its way (E30 S6)", () => {
|
||||
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);
|
||||
|
||||
@@ -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<HTMLElement>(
|
||||
'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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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<typeof import("react-i18next")>();
|
||||
@@ -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<string, unknown>) =>
|
||||
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();
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>): 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;
|
||||
|
||||
@@ -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<HTMLParagraphElement>(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 (
|
||||
<div className="mt-4">
|
||||
<p
|
||||
ref={ref}
|
||||
className={`measure whitespace-pre-line text-sm leading-relaxed text-muted ${expanded ? "" : "line-clamp-8"}`}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
{(clipped || expanded) && (
|
||||
<Button
|
||||
variant="link"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="mt-1 text-xs"
|
||||
>
|
||||
{expanded ? t("plex.person.less") : t("plex.person.more")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- 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 ? (
|
||||
<section className="mb-5">
|
||||
<h2 className="text-sm font-semibold mb-3">{t(titleKey, { count: cards.length })}</h2>
|
||||
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
|
||||
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(9.375rem,1fr))] gap-3">
|
||||
{cards.map((c, i) => (
|
||||
<PlexPosterCard
|
||||
key={c.id}
|
||||
@@ -353,10 +322,15 @@ export function PlexPersonView({
|
||||
</div>
|
||||
</div>
|
||||
{d.biography ? (
|
||||
<PersonBio text={d.biography} />
|
||||
<ClampedText
|
||||
text={d.biography}
|
||||
lines={8}
|
||||
moreLabel={t("plex.person.more")}
|
||||
lessLabel={t("plex.person.less")}
|
||||
className="mt-4"
|
||||
/>
|
||||
) : (
|
||||
d.enriching &&
|
||||
q.dataUpdatedAt - openedAt < BIO_WAIT_MS && (
|
||||
bioOnItsWay && (
|
||||
<LoadingState
|
||||
shape="text"
|
||||
label={t("plex.person.bioLoading")}
|
||||
@@ -694,7 +668,7 @@ export function PlexShowView({
|
||||
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
|
||||
>
|
||||
<h2 className="text-sm font-semibold mb-3">{t("plex.series.seasons")}</h2>
|
||||
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3">
|
||||
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(8.75rem,1fr))] gap-3">
|
||||
{d.seasons.map((se, i) => (
|
||||
<SeasonCard
|
||||
key={se.id}
|
||||
@@ -894,7 +868,7 @@ export function PlexSeasonView({
|
||||
</div>
|
||||
</DetailHero>
|
||||
|
||||
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-x-3 gap-y-4">
|
||||
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(14.375rem,1fr))] gap-x-3 gap-y-4">
|
||||
{se.episodes.map((ep, i) => (
|
||||
<EpisodeCard
|
||||
key={ep.id}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A clamped text offers More only when it is actually cut (E30 S6 on the person page, shared with the
|
||||
// collection page in the E30 review, where a one-line summary carried a More that did nothing).
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { ClampedText } from "./ClampedText";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
const view = (text: string) => (
|
||||
<ClampedText text={text} lines={3} moreLabel="More" lessLabel="Less" />
|
||||
);
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLParagraphElement>(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 (
|
||||
<div className={className}>
|
||||
<p
|
||||
ref={ref}
|
||||
className={`measure whitespace-pre-line text-sm leading-relaxed text-muted ${expanded ? "" : CLAMP[lines]}`}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
{(clipped || expanded) && (
|
||||
<Button
|
||||
variant="link"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="mt-1 text-xs"
|
||||
>
|
||||
{expanded ? lessLabel : moreLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
|
||||
| data display | `DataTable`, `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy") |
|
||||
| data display | `DataTable`, `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut) |
|
||||
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
|
||||
| state & messaging | `QueryState`, `Banner`, `InlineNotice` |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user