feat(ui): E31 S4c — the channel header folds, the Plex pages fill their hero, the player gets a side panel
- Channel page: the banner and identity block fold away once the grid scrolls (ui/scrollFold: folds past 32px only if the page can stay scrolled, unfolds at the very top) and the name rides in the tab row. One Back button in both states keeps its focus; folded at a tablet width the actions wrap instead of pushing the page sideways. - Plex movie and show pages: the TMDB facts over the key crew are the hero's second column (plexDetailAside), the keywords sit under the text; the show page shows them for the first time, with the same three toggles. A collection's hero is sized to its text. - YouTube player, 1280px and wider: the title, channel, actions, description and queue sit in a side panel beside the video, which takes the window's height (1438x809 at 1920x911, was ~1008 wide). The wheel scrolls that panel while it can and turns the volume elsewhere. - DESIGN.md decision log: the keywords' place under the text.
This commit is contained in:
@@ -1,14 +1,19 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react";
|
||||
import { api, type PlexCard, type PlexFilters, type PlexItemDetail } from "../lib/api";
|
||||
import { formatCompact, formatDecimal, formatMoney, formatRuntime } from "../lib/format";
|
||||
import { formatCompact, formatDecimal, formatRuntime } from "../lib/format";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi";
|
||||
import { tmdbBackdropUrl, useCrossfadeBackdrop, useDetailPrefs } from "../lib/plexDetail";
|
||||
import { usePref } from "../lib/usePref";
|
||||
import { DetailAside, DetailAsideToggles, DetailKeywords } from "./plexDetailAside";
|
||||
import {
|
||||
tmdbBackdropUrl,
|
||||
useCrossfadeBackdrop,
|
||||
useDetailAside,
|
||||
useDetailPrefs,
|
||||
} from "../lib/plexDetail";
|
||||
import { Carousel } from "./ui/Carousel";
|
||||
import PlexCollectionEditor from "./PlexCollectionEditor";
|
||||
import PlexPlaylistAdd from "./PlexPlaylistAdd";
|
||||
@@ -21,13 +26,12 @@ import { SURFACE } from "./ui/surfaces";
|
||||
import { Badge } from "./ui/Badge";
|
||||
import { IconLink } from "./ui/LinkButton";
|
||||
import { PageHeading } from "./ui/Headings";
|
||||
import clsx from "clsx";
|
||||
import { PlexPosterCard } from "./plexCards";
|
||||
import { PlexMenu } from "./PlexMenu";
|
||||
import { useContextMenu } from "./ui/useContextMenu";
|
||||
import { usePlexCardActions } from "./usePlexCardActions";
|
||||
import { toggleWatchState } from "../lib/plexWatch";
|
||||
import { regionName, vocab } from "../lib/vocab";
|
||||
import { vocab } from "../lib/vocab";
|
||||
|
||||
// The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating,
|
||||
// genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from
|
||||
@@ -143,174 +147,6 @@ function RatingsRow({
|
||||
);
|
||||
}
|
||||
|
||||
// A structured facts panel (E5 S5): release / runtime / cert / budget / revenue / studios / countries,
|
||||
// pulling from the item's own fields + the persisted TMDB block. Rendered only when it has something.
|
||||
// What filters the library is an edged chip, a plain fact stays flat text (E28 S3h, the S3d1 rule).
|
||||
function FactsPanel({
|
||||
detail,
|
||||
onFilter,
|
||||
}: {
|
||||
detail: PlexItemDetail;
|
||||
onFilter?: (patch: Partial<PlexFilters>) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const tmdb = detail.tmdb;
|
||||
const studios = tmdb?.studios?.length ? tmdb.studios : detail.studio ? [detail.studio] : [];
|
||||
const rows: { key: string; label: string; node: ReactNode }[] = [];
|
||||
// The same values filter here as in the header above (year, certificate, studios).
|
||||
const year = detail.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 (detail.duration_seconds)
|
||||
rows.push({
|
||||
key: "runtime",
|
||||
label: t("plex.info.facts.runtime"),
|
||||
node: formatRuntime(detail.duration_seconds, lang),
|
||||
});
|
||||
const cert = detail.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 (
|
||||
<div className={`${SURFACE.panel} p-4 sm:p-5`}>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// TMDB keyword chips (E5 S5) — tap one to filter the library by that keyword (via applyFilter).
|
||||
function KeywordsPanel({
|
||||
keywords,
|
||||
onFilter,
|
||||
}: {
|
||||
keywords: string[];
|
||||
onFilter?: (patch: Partial<PlexFilters>) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (keywords.length === 0) return null;
|
||||
return (
|
||||
<div className={`${SURFACE.panel} p-4 sm:p-5`}>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Full crew (E5 S5) — the TMDB key-crew (Director / Writer / Producer / …), each clickable to open
|
||||
// their person page. Complements the hero's Director line (which comes from Plex directly).
|
||||
function CrewPanel({
|
||||
crew,
|
||||
onOpenPerson,
|
||||
}: {
|
||||
crew: { name: string; job: string }[];
|
||||
onOpenPerson?: (name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (crew.length === 0) return null;
|
||||
return (
|
||||
<div className={`${SURFACE.panel} p-4 sm:p-5`}>
|
||||
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.crew")}</h2>
|
||||
{/* In the main column (layout B): as many name columns as its width holds. */}
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-x-3 gap-y-1.5">
|
||||
{crew.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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The playback-class chip (dot + Plex-native label), with a muted codec · bit-depth subtitle when
|
||||
// known. Class + colour are derived through the SAME shared helper the browse-card corner dot uses,
|
||||
// so the two surfaces can't drift. `overlay` tints the label for the dark player background.
|
||||
@@ -372,10 +208,8 @@ export default function PlexInfo({
|
||||
// is PlexInfo-specific state, persisted through the same savePref.
|
||||
const { artBg, showCast, crossfade, toggleArtBg, toggleCast, toggleCrossfade, savePref } =
|
||||
useDetailPrefs();
|
||||
// The three TMDB-enriched side panels are each independently hideable (default ON), like the cast row.
|
||||
const [showFacts, setShowFacts] = usePref<boolean>("plexInfoFacts", true);
|
||||
const [showKeywords, setShowKeywords] = usePref<boolean>("plexInfoKeywords", true);
|
||||
const [showCrew, setShowCrew] = usePref<boolean>("plexInfoCrew", true);
|
||||
// The TMDB facts / keywords / crew: the hero's second column, each section a per-user toggle.
|
||||
const aside = useDetailAside(detail);
|
||||
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(qk.me())?.preferences ??
|
||||
{}) as { plexHiddenStripSources?: string[] };
|
||||
// Which collection-strip source buckets the user has HIDDEN (default: none → all shown). Toggled
|
||||
@@ -462,20 +296,7 @@ export default function PlexInfo({
|
||||
const sourceLabel = (src: string) =>
|
||||
t(`plex.info.stripSource.${src}`, { defaultValue: src.toUpperCase() });
|
||||
|
||||
// Whether each enriched side panel has anything to show — drives both its render and its toggle row
|
||||
// (no toggle for a panel that would be empty anyway).
|
||||
const tmdb = detail.tmdb;
|
||||
const hasFacts =
|
||||
detail.year != null ||
|
||||
!!detail.duration_seconds ||
|
||||
!!detail.content_rating ||
|
||||
!!tmdb?.budget ||
|
||||
!!tmdb?.revenue ||
|
||||
!!tmdb?.studios?.length ||
|
||||
!!detail.studio ||
|
||||
!!tmdb?.production_countries?.length;
|
||||
const hasKeywords = (tmdb?.keywords?.length ?? 0) > 0;
|
||||
const hasCrew = (tmdb?.crew?.length ?? 0) > 0;
|
||||
const asideShown = !overlay && aside.column;
|
||||
const infoTitle =
|
||||
detail.kind === "episode" && detail.show_title ? detail.show_title : detail.title;
|
||||
|
||||
@@ -483,7 +304,7 @@ export default function PlexInfo({
|
||||
<div className={overlay ? "relative w-full max-w-3xl" : "relative glass-media"}>
|
||||
<div className={overlay ? "p-5" : "flex flex-col gap-3 p-3 sm:p-4"}>
|
||||
{/* Hero block: floats as a frosted glass panel over the fixed art backdrop (page variant). */}
|
||||
<div className={overlay ? "" : `${SURFACE.panel} p-4 sm:p-6`}>
|
||||
<div className={overlay ? "" : `${SURFACE.panel} @container p-4 sm:p-6`}>
|
||||
{/* Header: close (overlay) / customize */}
|
||||
<div className="mb-4 flex items-start gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -510,27 +331,7 @@ export default function PlexInfo({
|
||||
hasCast={detail.cast.length > 0}
|
||||
extra={
|
||||
<>
|
||||
{hasFacts && (
|
||||
<PrefToggle
|
||||
label={t("plex.info.prefFacts")}
|
||||
on={showFacts}
|
||||
onClick={() => setShowFacts(!showFacts)}
|
||||
/>
|
||||
)}
|
||||
{hasKeywords && (
|
||||
<PrefToggle
|
||||
label={t("plex.info.prefKeywords")}
|
||||
on={showKeywords}
|
||||
onClick={() => setShowKeywords(!showKeywords)}
|
||||
/>
|
||||
)}
|
||||
{hasCrew && (
|
||||
<PrefToggle
|
||||
label={t("plex.info.prefCrew")}
|
||||
on={showCrew}
|
||||
onClick={() => setShowCrew(!showCrew)}
|
||||
/>
|
||||
)}
|
||||
{!overlay && <DetailAsideToggles aside={aside} />}
|
||||
{presentSources.map((src) => (
|
||||
<PrefToggle
|
||||
key={src}
|
||||
@@ -554,171 +355,192 @@ export default function PlexInfo({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 sm:gap-6">
|
||||
{/* Poster */}
|
||||
<img
|
||||
src={detail.thumb}
|
||||
alt=""
|
||||
className={`shrink-0 rounded-xl border border-border object-cover ${overlay ? "w-24 sm:w-28" : "w-32 sm:w-44"} aspect-2/3`}
|
||||
/>
|
||||
{/* The poster + text, and beside them (page variant, a wide hero) the facts / keywords / crew. */}
|
||||
<div
|
||||
className={
|
||||
asideShown ? "grid gap-6 @5xl:grid-cols-[minmax(0,1fr)_minmax(0,24rem)]" : undefined
|
||||
}
|
||||
>
|
||||
<div className="flex gap-4 sm:gap-6">
|
||||
{/* Poster */}
|
||||
<img
|
||||
src={detail.thumb}
|
||||
alt=""
|
||||
className={`shrink-0 rounded-xl border border-border object-cover ${overlay ? "w-24 sm:w-28" : "w-32 sm:w-44"} aspect-2/3`}
|
||||
/>
|
||||
|
||||
<div className={`min-w-0 flex-1 ${overlay ? "text-white/90" : ""}`}>
|
||||
{/* Meta line + IMDb rating/link (each value can set the matching filter). */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
||||
{detail.year != null && (
|
||||
<Filterable
|
||||
tag
|
||||
onDark={overlay}
|
||||
title={onFilter ? t("plex.info.filterYear", { year: detail.year }) : undefined}
|
||||
onClick={
|
||||
onFilter
|
||||
? () => onFilter({ yearMin: detail.year, yearMax: detail.year })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{detail.year}
|
||||
</Filterable>
|
||||
)}
|
||||
{detail.content_rating && (
|
||||
<Filterable
|
||||
tag
|
||||
onDark={overlay}
|
||||
onClick={
|
||||
onFilter
|
||||
? () => onFilter({ contentRatings: [detail.content_rating!] })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{vocab(t, "certificate", detail.content_rating)}
|
||||
</Filterable>
|
||||
)}
|
||||
{detail.duration_seconds ? (
|
||||
<span className={mutedCls}>{formatRuntime(detail.duration_seconds, lang)}</span>
|
||||
) : null}
|
||||
{detail.studio && (
|
||||
<Filterable
|
||||
tag
|
||||
onDark={overlay}
|
||||
onClick={onFilter ? () => onFilter({ studios: [detail.studio!] }) : undefined}
|
||||
>
|
||||
{detail.studio}
|
||||
</Filterable>
|
||||
)}
|
||||
<PlaybackChip detail={detail} overlay={overlay} />
|
||||
</div>
|
||||
|
||||
{/* Multi-source ratings row (E5 S5): IMDb / TMDb / audience, whichever are present. */}
|
||||
<div className="mt-2">
|
||||
<RatingsRow
|
||||
imdbRating={detail.imdb_rating}
|
||||
imdbUrl={detail.imdb_url}
|
||||
audience={detail.rating}
|
||||
tmdbRating={detail.tmdb?.rating}
|
||||
tmdbVotes={detail.tmdb?.vote_count}
|
||||
onFilterRating={onFilter ? (n) => onFilter({ ratingMin: n }) : undefined}
|
||||
onDark={overlay}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{detail.tagline && (
|
||||
<p className={`mt-2 text-sm italic ${overlay ? "text-white/60" : "text-muted"}`}>
|
||||
{detail.tagline}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{detail.genres.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{detail.genres.map((g) => (
|
||||
<div className={`min-w-0 flex-1 ${overlay ? "text-white/90" : ""}`}>
|
||||
{/* Meta line + IMDb rating/link (each value can set the matching filter). */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
||||
{detail.year != null && (
|
||||
<Filterable
|
||||
key={g}
|
||||
tag
|
||||
onDark={overlay}
|
||||
onClick={onFilter ? () => onFilter({ genres: [g] }) : undefined}
|
||||
title={
|
||||
onFilter ? t("plex.info.filterYear", { year: detail.year }) : undefined
|
||||
}
|
||||
onClick={
|
||||
onFilter
|
||||
? () => onFilter({ yearMin: detail.year, yearMax: detail.year })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{g}
|
||||
{detail.year}
|
||||
</Filterable>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.directors.length > 0 && (
|
||||
<p className={`mt-2 flex flex-wrap items-center gap-1.5 text-sm ${mutedCls}`}>
|
||||
<span>{t("plex.info.director")}:</span>
|
||||
{detail.directors.map((d) => (
|
||||
)}
|
||||
{detail.content_rating && (
|
||||
<Filterable
|
||||
key={d}
|
||||
tag
|
||||
onDark={overlay}
|
||||
onClick={onFilter ? () => onFilter({ directors: [d] }) : undefined}
|
||||
onClick={
|
||||
onFilter
|
||||
? () => onFilter({ contentRatings: [detail.content_rating!] })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{d}
|
||||
{vocab(t, "certificate", detail.content_rating)}
|
||||
</Filterable>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{detail.summary && (
|
||||
<p
|
||||
className={`measure mt-3 text-sm leading-relaxed ${overlay ? "text-white/85 line-clamp-5" : ""}`}
|
||||
>
|
||||
{detail.summary}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Page actions: Play/Resume + watch controls */}
|
||||
{!overlay && (
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
{onPlay && (
|
||||
<Button onClick={onPlay} icon={<Play className="fill-current" />}>
|
||||
{inProgress ? t("plex.resume") : t("plex.play")}
|
||||
</Button>
|
||||
)}
|
||||
{detail.status === "watched" ? (
|
||||
<Button variant="glass" onClick={() => setState("new")} icon={<RotateCcw />}>
|
||||
{t("plex.info.markUnwatched")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="glass" onClick={() => setState("watched")} icon={<Check />}>
|
||||
{t("plex.info.markWatched")}
|
||||
</Button>
|
||||
)}
|
||||
{inProgress && (
|
||||
<Button variant="glass" onClick={() => setState("new")} icon={<RotateCcw />}>
|
||||
{t("plex.info.clearResume")}
|
||||
</Button>
|
||||
)}
|
||||
{detail.kind === "movie" && (
|
||||
<Button
|
||||
variant="glass"
|
||||
onClick={() => setPlaylistOpen(true)}
|
||||
icon={<ListPlus />}
|
||||
{detail.duration_seconds ? (
|
||||
<span className={mutedCls}>{formatRuntime(detail.duration_seconds, lang)}</span>
|
||||
) : null}
|
||||
{detail.studio && (
|
||||
<Filterable
|
||||
tag
|
||||
onDark={overlay}
|
||||
onClick={onFilter ? () => onFilter({ studios: [detail.studio!] }) : undefined}
|
||||
>
|
||||
{t("plex.playlistAdd.manage")}
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && detail.kind === "movie" && library && (
|
||||
<Button
|
||||
variant="glass"
|
||||
onClick={() => setEditorOpen(true)}
|
||||
icon={<FolderPlus />}
|
||||
>
|
||||
{t("plex.collEditor.manage")}
|
||||
</Button>
|
||||
{detail.studio}
|
||||
</Filterable>
|
||||
)}
|
||||
<PlaybackChip detail={detail} overlay={overlay} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Multi-source ratings row (E5 S5): IMDb / TMDb / audience, whichever are present. */}
|
||||
<div className="mt-2">
|
||||
<RatingsRow
|
||||
imdbRating={detail.imdb_rating}
|
||||
imdbUrl={detail.imdb_url}
|
||||
audience={detail.rating}
|
||||
tmdbRating={detail.tmdb?.rating}
|
||||
tmdbVotes={detail.tmdb?.vote_count}
|
||||
onFilterRating={onFilter ? (n) => onFilter({ ratingMin: n }) : undefined}
|
||||
onDark={overlay}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{detail.tagline && (
|
||||
<p className={`mt-2 text-sm italic ${overlay ? "text-white/60" : "text-muted"}`}>
|
||||
{detail.tagline}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{detail.genres.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{detail.genres.map((g) => (
|
||||
<Filterable
|
||||
key={g}
|
||||
tag
|
||||
onDark={overlay}
|
||||
onClick={onFilter ? () => onFilter({ genres: [g] }) : undefined}
|
||||
>
|
||||
{g}
|
||||
</Filterable>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.directors.length > 0 && (
|
||||
<p className={`mt-2 flex flex-wrap items-center gap-1.5 text-sm ${mutedCls}`}>
|
||||
<span>{t("plex.info.director")}:</span>
|
||||
{detail.directors.map((d) => (
|
||||
<Filterable
|
||||
key={d}
|
||||
tag
|
||||
onDark={overlay}
|
||||
onClick={onFilter ? () => onFilter({ directors: [d] }) : undefined}
|
||||
>
|
||||
{d}
|
||||
</Filterable>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{detail.summary && (
|
||||
<p
|
||||
className={`measure mt-3 text-sm leading-relaxed ${overlay ? "text-white/85 line-clamp-5" : ""}`}
|
||||
>
|
||||
{detail.summary}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Page actions: Play/Resume + watch controls */}
|
||||
{!overlay && (
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
{onPlay && (
|
||||
<Button onClick={onPlay} icon={<Play className="fill-current" />}>
|
||||
{inProgress ? t("plex.resume") : t("plex.play")}
|
||||
</Button>
|
||||
)}
|
||||
{detail.status === "watched" ? (
|
||||
<Button variant="glass" onClick={() => setState("new")} icon={<RotateCcw />}>
|
||||
{t("plex.info.markUnwatched")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="glass" onClick={() => setState("watched")} icon={<Check />}>
|
||||
{t("plex.info.markWatched")}
|
||||
</Button>
|
||||
)}
|
||||
{inProgress && (
|
||||
<Button variant="glass" onClick={() => setState("new")} icon={<RotateCcw />}>
|
||||
{t("plex.info.clearResume")}
|
||||
</Button>
|
||||
)}
|
||||
{detail.kind === "movie" && (
|
||||
<Button
|
||||
variant="glass"
|
||||
onClick={() => setPlaylistOpen(true)}
|
||||
icon={<ListPlus />}
|
||||
>
|
||||
{t("plex.playlistAdd.manage")}
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && detail.kind === "movie" && library && (
|
||||
<Button
|
||||
variant="glass"
|
||||
onClick={() => setEditorOpen(true)}
|
||||
icon={<FolderPlus />}
|
||||
>
|
||||
{t("plex.collEditor.manage")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!overlay && aside.shown.keywords && (
|
||||
<DetailKeywords
|
||||
keywords={detail.tmdb?.keywords ?? []}
|
||||
onFilter={onFilter}
|
||||
className="mt-5"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{asideShown && (
|
||||
<DetailAside
|
||||
facts={detail}
|
||||
shown={aside.shown}
|
||||
onFilter={onFilter}
|
||||
onOpenPerson={onOpenPerson}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* End hero panel. */}
|
||||
|
||||
{/* Below the hero (E28 S3h, the owner's layout B, 2026-09-26): a main column — the collection
|
||||
strips first (the owner's most used), then the crew, then the cast — beside a narrow
|
||||
sidebar of facts (details, keywords), the TMDB/IMDb shape. Every section is per-user
|
||||
toggleable, so the layout is built from what is ON: a column with nothing in it is not
|
||||
rendered and the other takes the full width; inside a column the sections stack, so a
|
||||
hidden one leaves no hole. Narrow windows stack the sidebar under the main column. The
|
||||
overlay (over the running player) stays lean: its strips and its cast, stacked. */}
|
||||
{/* Below the hero: the collection strips first (the owner's most used), then the cast, each
|
||||
per-user toggleable, stacked so a hidden one leaves no hole. The facts / keywords / crew that
|
||||
sat beside them (E28 S3h) moved up into the hero's second column (E31). The overlay (over
|
||||
the running player) is the same stack, lean. */}
|
||||
{(() => {
|
||||
const collections = (detail.collections ?? []).filter(
|
||||
(col) => !hiddenSources.includes(col.source),
|
||||
@@ -845,38 +667,11 @@ export default function PlexInfo({
|
||||
{castRow}
|
||||
</>
|
||||
);
|
||||
const crewBox = showCrew && hasCrew && (
|
||||
<CrewPanel crew={tmdb?.crew ?? []} onOpenPerson={onOpenPerson} />
|
||||
);
|
||||
const factsBox = showFacts && hasFacts && (
|
||||
<FactsPanel detail={detail} onFilter={onFilter} />
|
||||
);
|
||||
const keywordsBox = showKeywords && hasKeywords && (
|
||||
<KeywordsPanel keywords={tmdb?.keywords ?? []} onFilter={onFilter} />
|
||||
);
|
||||
const hasMain = strips.length > 0 || !!crewBox || !!castRow;
|
||||
const hasSide = !!factsBox || !!keywordsBox;
|
||||
if (!hasMain && !hasSide) return null;
|
||||
if (strips.length === 0 && !castRow) return null;
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"grid items-start gap-3",
|
||||
hasMain && hasSide && "lg:grid-cols-[minmax(0,1fr)_20rem]",
|
||||
)}
|
||||
>
|
||||
{hasMain && (
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
{strips}
|
||||
{crewBox}
|
||||
{castRow}
|
||||
</div>
|
||||
)}
|
||||
{hasSide && (
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
{factsBox}
|
||||
{keywordsBox}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
{strips}
|
||||
{castRow}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
Reference in New Issue
Block a user