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.
241 lines
8.6 KiB
TypeScript
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>
|
|
);
|
|
}
|