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) => 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 = { 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) => ( 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 (
{shown.facts && } {shown.crew && }
); } // 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: ( onFilter({ yearMin: year, yearMax: year }) : undefined} > {year} ), }); 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: ( onFilter({ contentRatings: [cert] }) : undefined}> {vocab(t, "certificate", cert)} ), }); 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: ( {studios.map((s) => ( onFilter({ studios: [s] }) : undefined} > {s} ))} ), }); 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 (

{t("plex.info.facts.title")}

{rows.map((r) => (
{r.label}
{r.node}
))}
); } // 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 (

{t("plex.info.keywords")}

{keywords.map((k) => ( onFilter({ keywords: [k] }) : undefined} > {k} ))}
); } // 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 (

{t("plex.info.crew")}

{/* As many name columns as the column holds: two in the hero's side column, one when narrow. */}
{shown.map((c, i) => (
{onOpenPerson ? ( 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} ) : (
{c.name}
)}
{vocab(t, "crewJob", c.job)}
))}
{folds && ( )}
); }