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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user