Files
siftlode/frontend/src/components/plexDetailAside.tsx
T
peter 4aaaaa7860 chore(comments): drop epic, sprint and review-round ids from the code
The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").

Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.

Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).

One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
2026-10-05 00:19:39 +02:00

241 lines
8.6 KiB
TypeScript

import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { PlexFilters } from "../lib/api";
import { formatMoney, formatRuntime } from "../lib/format";
import type { AsideSection, DetailAsideState, DetailFacts } from "../lib/plexDetail";
import { regionName, vocab } from "../lib/vocab";
import { Filterable, PrefToggle } from "./plexDetailUi";
import { Button } from "./ui/Button";
import { Pressable } from "./ui/Pressable";
// The detail pages' second column (the owner's pick "A"): the TMDB facts and key crew of a movie or
// a show beside its description, inside the hero — as the person page sets its known-for rail beside
// the bio — and its keywords under the text. They used to sit under the hero (movie) or
// nowhere at all (show), while the hero's text stopped at ~55% of the panel and left the rest empty. A
// narrow hero stacks the column under the text. Each section is a per-user toggle (default ON), the same
// three prefs on both pages (lib/plexDetail useDetailAside).
type OnFilter = (patch: Partial<PlexFilters>) => void;
// The customize menu's rows: one per section that has something (no toggle for an empty one).
export function DetailAsideToggles({ aside }: { aside: DetailAsideState }) {
const { t } = useTranslation();
const label: Record<AsideSection, string> = {
facts: t("plex.info.prefFacts"),
keywords: t("plex.info.prefKeywords"),
crew: t("plex.info.prefCrew"),
};
return (["facts", "keywords", "crew"] as const)
.filter((k) => aside.has[k])
.map((k) => (
<PrefToggle key={k} label={label[k]} on={aside.on[k]} onClick={() => aside.toggle(k)} />
));
}
// The hero's second column: the facts over the key crew — two lists, read down. Null when neither is
// shown. The keywords are chips that filter, as the genres are, so they sit under the text instead
// (DetailKeywords): all three in this column ran it to twice the text's height at 20 keywords and 12
// crew, a hole under the text — the measured worst case.
export function DetailAside({
facts,
shown,
onFilter,
onOpenPerson,
}: {
facts: DetailFacts;
shown: DetailAsideState["shown"];
onFilter?: OnFilter;
onOpenPerson?: (name: string) => void;
}) {
if (!shown.facts && !shown.crew) return null;
return (
<div data-testid="detail-aside" className="flex min-w-0 flex-col gap-5">
{shown.facts && <FactsSection facts={facts} onFilter={onFilter} />}
{shown.crew && <CrewSection crew={facts.tmdb?.crew ?? []} onOpenPerson={onOpenPerson} />}
</div>
);
}
// The structured facts: release / runtime / cert / budget / revenue / studios / countries, from
// the title's own fields + the persisted TMDB block. What filters the library is an edged chip, a plain
// fact stays flat text (edged = pressable, flat = just says).
function FactsSection({ facts, onFilter }: { facts: DetailFacts; onFilter?: OnFilter }) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const tmdb = facts.tmdb;
const studios = tmdb?.studios?.length ? tmdb.studios : facts.studio ? [facts.studio] : [];
const rows: { key: string; label: string; node: ReactNode }[] = [];
// The same values filter here as in the hero's meta line (year, certificate, studios).
const year = facts.year;
if (year != null)
rows.push({
key: "year",
label: t("plex.info.facts.release"),
node: (
<Filterable
tag
title={onFilter ? t("plex.info.filterYear", { year }) : undefined}
onClick={onFilter ? () => onFilter({ yearMin: year, yearMax: year }) : undefined}
>
{year}
</Filterable>
),
});
if (facts.duration_seconds)
rows.push({
key: "runtime",
label: t("plex.info.facts.runtime"),
node: formatRuntime(facts.duration_seconds, lang),
});
const cert = facts.content_rating;
if (cert)
rows.push({
key: "cert",
label: t("plex.info.facts.cert"),
node: (
<Filterable tag onClick={onFilter ? () => onFilter({ contentRatings: [cert] }) : undefined}>
{vocab(t, "certificate", cert)}
</Filterable>
),
});
if (tmdb?.budget)
rows.push({
key: "budget",
label: t("plex.info.facts.budget"),
node: formatMoney(tmdb.budget, lang),
});
if (tmdb?.revenue)
rows.push({
key: "revenue",
label: t("plex.info.facts.revenue"),
node: formatMoney(tmdb.revenue, lang),
});
if (studios.length)
rows.push({
key: "studios",
label: t("plex.info.facts.studios"),
node: (
<span className="flex flex-wrap gap-1.5">
{studios.map((s) => (
<Filterable
key={s}
tag
onClick={onFilter ? () => onFilter({ studios: [s] }) : undefined}
>
{s}
</Filterable>
))}
</span>
),
});
if (tmdb?.production_countries?.length)
rows.push({
key: "countries",
label: t("plex.info.facts.countries"),
node: tmdb.production_countries
.map((c) => regionName(t, c.code, i18n.language, c.name))
.join(", "),
});
if (rows.length === 0) return null;
return (
<section>
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.facts.title")}</h2>
<dl className="grid grid-cols-[auto_1fr] items-baseline gap-x-4 gap-y-2 text-sm">
{rows.map((r) => (
<div key={r.key} className="contents">
<dt className="text-muted">{r.label}</dt>
<dd className="min-w-0 font-medium">{r.node}</dd>
</div>
))}
</dl>
</section>
);
}
// TMDB keyword chips — tap one to filter the library by that keyword. Under the hero's text.
export function DetailKeywords({
keywords,
onFilter,
className,
}: {
keywords: string[];
onFilter?: OnFilter;
className?: string;
}) {
const { t } = useTranslation();
return (
<section data-testid="detail-keywords" className={className}>
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.keywords")}</h2>
<div className="flex flex-wrap gap-1.5">
{keywords.map((k) => (
<Filterable
key={k}
tag
onClick={onFilter ? () => onFilter({ keywords: [k] }) : undefined}
>
{k}
</Filterable>
))}
</div>
</section>
);
}
// The crew shows its first CREW_SHOWN names and a "Show N more" that opens the rest: the column beside the
// text is the hero's tallest part, and twelve names ran it ~170px past the text (the owner's call).
// A list only a little longer than that shows whole: a toggle that hides one or two names saves no room.
export const CREW_SHOWN = 6;
const CREW_FOLD_OVER = CREW_SHOWN + 2;
// The TMDB key crew — Director / Writer / Producer / …, each opening their person page. It
// complements the hero's Director line (which comes from Plex directly).
function CrewSection({
crew,
onOpenPerson,
}: {
crew: { name: string; job: string }[];
onOpenPerson?: (name: string) => void;
}) {
const { t } = useTranslation();
const [all, setAll] = useState(false);
const folds = crew.length > CREW_FOLD_OVER;
const shown = folds && !all ? crew.slice(0, CREW_SHOWN) : crew;
return (
<section>
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.crew")}</h2>
{/* As many name columns as the column holds: two in the hero's side column, one when narrow. */}
<div className="grid grid-cols-[repeat(auto-fill,minmax(10rem,1fr))] gap-x-3 gap-y-1.5">
{shown.map((c, i) => (
<div key={`${c.name}:${c.job}:${i}`} className="min-w-0">
{onOpenPerson ? (
<Pressable
selectable
onClick={() => onOpenPerson(c.name)}
title={t("plex.info.viewPerson", { name: c.name })}
className="max-w-full truncate text-sm leading-6 font-medium hover:text-accent pointer-coarse:leading-(--touch-target)"
>
{c.name}
</Pressable>
) : (
<div className="truncate text-sm font-medium">{c.name}</div>
)}
<div className="truncate text-2xs text-muted">{vocab(t, "crewJob", c.job)}</div>
</div>
))}
</div>
{folds && (
<Button
variant={all ? "link-muted" : "link"}
aria-expanded={all}
onClick={() => setAll((v) => !v)}
className="mt-2 text-sm"
>
{all
? t("plex.info.crewLess")
: t("plex.info.crewMore", { count: crew.length - CREW_SHOWN })}
</Button>
)}
</section>
);
}