Files
siftlode/frontend/src/components/plexDetailAside.tsx
T
peter 20010178b0 fix(ui): the Plex detail poster keeps its 2:3, and a long crew shows its first names
The hero's grid row is as tall as the crew column beside the text, and the poster, a flex child,
stretched to it: a 2:3 picture drawn ~175x590 and cropped. It now aligns to the top, and a wide hero
gives the movie and show poster a bigger rung (w-56). The crew, the hero's tallest part, shows its
first 6 names and a 'Show N more' toggle; a list of 8 or fewer shows whole.
2026-10-02 02:37:20 +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 (E31, 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 (E30 S6) — 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 (E31 S4c).
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 (E5 S5): 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 (E28 S3h, the S3d1 rule).
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 (E5 S5) — 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 (E31, 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 (E5 S5) — 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>
);
}