refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/
A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/ (ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows). Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the contract tests that scan sources now scan the whole app source, not only components/ — each glob was counted before and after so none went green over fewer files. The locale-format rule matched `/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton check; it now covers every folder that holds components, with a test per folder.
This commit is contained in:
@@ -0,0 +1,686 @@
|
||||
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, formatRuntime } from "../../lib/format";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi";
|
||||
import { DetailAside, DetailAsideToggles, DetailKeywords } from "./plexDetailAside";
|
||||
import {
|
||||
DETAIL_POSTER,
|
||||
episodeLine,
|
||||
episodeName,
|
||||
episodeNumbers,
|
||||
tmdbBackdropUrl,
|
||||
useCrossfadeBackdrop,
|
||||
useDetailAside,
|
||||
useDetailPrefs,
|
||||
} from "../../lib/plexDetail";
|
||||
import { Carousel } from "../../components/ui/Carousel";
|
||||
import PlexCollectionEditor from "./PlexCollectionEditor";
|
||||
import PlexPlaylistAdd from "../../features/plexCards/PlexPlaylistAdd";
|
||||
import { PLAYABLE_TINT, playableClass } from "./plexPlayable";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { IconButton } from "../../components/ui/IconButton";
|
||||
import { Pressable } from "../../components/ui/Pressable";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
import { Badge } from "../../components/ui/Badge";
|
||||
import { IconLink } from "../../components/ui/LinkButton";
|
||||
import { PageHeading } from "../../components/ui/Headings";
|
||||
import { CastRow, PlexPosterCard } from "../../features/plexCards/plexCards";
|
||||
import { PlexMenu } from "./PlexMenu";
|
||||
import { useContextMenu } from "../../components/ui/useContextMenu";
|
||||
import { usePlexCardActions } from "../../features/plexCards/usePlexCardActions";
|
||||
import { toggleWatchState } from "../../lib/plexWatch";
|
||||
import { vocab } from "../../lib/vocab";
|
||||
import RemoteImage from "../../components/ui/RemoteImage";
|
||||
|
||||
// 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
|
||||
// a card's "i" button (variant="page", with a big Play/Resume + watch controls), and a lean overlay
|
||||
// over the running player (variant="overlay", opened with "i"). Two GUI elements are user-toggleable
|
||||
// personal preferences, persisted in users.preferences: the faint art backdrop and the cast row.
|
||||
// The TMDB-enriched pieces (multi-source ratings row, facts, keyword chips, full crew) render
|
||||
// on the page variant only; the overlay stays lean.
|
||||
|
||||
// One rating chip's number: a filter button when this is the score the backend's ratingMin column
|
||||
// tracks, plain text otherwise. It lives at MODULE level, not inside RatingsRow, because a component
|
||||
// declared during render is a NEW component type on every render — React unmounts and remounts the
|
||||
// subtree rather than updating it, which throws away DOM state and, for this one, the keyboard focus
|
||||
// on the button while the info page re-renders around it.
|
||||
function Score({
|
||||
score,
|
||||
filterable,
|
||||
onFilterRating,
|
||||
}: {
|
||||
score: number;
|
||||
filterable: boolean;
|
||||
onFilterRating?: (score: number) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const shown = formatDecimal(score, i18n.language);
|
||||
return filterable && onFilterRating ? (
|
||||
<Pressable
|
||||
onClick={() => onFilterRating(Math.floor(score))}
|
||||
className="inline-flex min-h-6 items-center gap-1 hover:underline touch-target"
|
||||
title={t("plex.info.filterRating", { n: Math.floor(score) })}
|
||||
>
|
||||
<Star className="h-3.5 w-3.5 fill-current" />
|
||||
{shown}
|
||||
</Pressable>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Star className="h-3.5 w-3.5 fill-current" />
|
||||
{shown}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Multi-source ratings cluster: IMDb (links out) · TMDb (with vote count) · Plex audience —
|
||||
// whichever are present. The "primary" score (IMDb, else audience) mirrors the backend rating column
|
||||
// (coalesce(imdb, audience)), so only it carries the ratingMin filter; the others are informational.
|
||||
function RatingsRow({
|
||||
imdbRating,
|
||||
imdbUrl,
|
||||
audience,
|
||||
tmdbRating,
|
||||
tmdbVotes,
|
||||
onFilterRating,
|
||||
onDark,
|
||||
}: {
|
||||
imdbRating?: number | null;
|
||||
imdbUrl?: string | null;
|
||||
audience?: number | null;
|
||||
tmdbRating?: number | null;
|
||||
tmdbVotes?: number | null;
|
||||
onFilterRating?: (score: number) => void;
|
||||
/** Over the always-dark player: the page's `--rating` is tuned for the theme's page, so a light
|
||||
* theme's dark gold would sit on the player's black (2.6:1, measured). */
|
||||
onDark?: boolean;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const primary = imdbRating ?? audience ?? null; // the filterable score
|
||||
// The source label is the chip's own colour at full strength: dimmed to 70% it fell under 4.5:1.
|
||||
const chipCls = `inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 font-semibold ${
|
||||
onDark ? "bg-rating-media/15 text-rating-media" : "bg-rating/15 text-rating"
|
||||
}`;
|
||||
const has = (x?: number | null): x is number => x != null;
|
||||
if (!has(imdbRating) && !has(audience) && !has(tmdbRating)) return null;
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
{has(imdbRating) && (
|
||||
<span className={chipCls}>
|
||||
<Score
|
||||
score={imdbRating}
|
||||
filterable={imdbRating === primary}
|
||||
onFilterRating={onFilterRating}
|
||||
/>
|
||||
<span className="text-2xs font-normal">IMDb</span>
|
||||
</span>
|
||||
)}
|
||||
{/* Beside the score, not inside it: a link needs its own ≥ 24 box, and the score is a label. */}
|
||||
{has(imdbRating) && imdbUrl && (
|
||||
<IconLink
|
||||
href={imdbUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
label={t("plex.info.openImdb")}
|
||||
variant={onDark ? "overlay" : "ghost"}
|
||||
className="-ml-1.5"
|
||||
>
|
||||
<ExternalLink />
|
||||
</IconLink>
|
||||
)}
|
||||
{has(tmdbRating) && (
|
||||
<span className={chipCls}>
|
||||
<Score score={tmdbRating} filterable={false} />
|
||||
<span className="text-2xs font-normal">
|
||||
TMDb{has(tmdbVotes) ? ` · ${formatCompact(tmdbVotes, i18n.language)}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{has(audience) && imdbRating !== audience && (
|
||||
<span className={chipCls}>
|
||||
<Score score={audience} filterable={!has(imdbRating)} onFilterRating={onFilterRating} />
|
||||
<span className="text-2xs font-normal">{t("plex.info.audience")}</span>
|
||||
</span>
|
||||
)}
|
||||
</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.
|
||||
function PlaybackChip({ detail, overlay }: { detail: PlexItemDetail; overlay: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
if (!detail.playable) return null;
|
||||
const cls = playableClass(detail.playable, detail.transcode_hw);
|
||||
const sub = [
|
||||
detail.codec_video ? detail.codec_video.toUpperCase() : null,
|
||||
detail.video_bit_depth != null ? `${detail.video_bit_depth}-bit` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
return (
|
||||
<Badge onDark={overlay} title={t(`plex.playable.${cls}`)}>
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${PLAYABLE_TINT[cls] ?? "bg-muted"}`} />
|
||||
<span className="font-medium">{t(`plex.filter.playableOpt.${cls}`)}</span>
|
||||
{sub && <span className="opacity-70">{sub}</span>}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
detail: PlexItemDetail;
|
||||
variant: "page" | "overlay";
|
||||
onPlay?: () => void; // page: start/resume playback
|
||||
onClose?: () => void; // overlay: dismiss
|
||||
onStateChange?: () => void; // after a watch-state change, let the opener refresh
|
||||
// When provided, metadata (year/genre/director/cast/studio/rating) becomes clickable and sets
|
||||
// the corresponding Plex filter (then the opener navigates to the filtered grid).
|
||||
onFilter?: (patch: Partial<PlexFilters>) => void;
|
||||
// When provided, a cast member's photo/name opens their filmography page instead of filtering.
|
||||
onOpenPerson?: (name: string) => void;
|
||||
// Play a sibling title from a collection strip: the page opens its player, the overlay plays it
|
||||
// in the running one. Required, so no variant draws a collection card whose Play does nothing.
|
||||
onPlayItem: (id: string, fromStart?: boolean) => void;
|
||||
// The item's library key — enables the admin "Manage collections" editor (page variant, movies).
|
||||
library?: string;
|
||||
// Page variant, an episode: its show title opens the show page, its "S1·E25" the season page.
|
||||
onOpenShow?: (showId: string) => void;
|
||||
onOpenSeason?: (showId: string, seasonId: string) => void;
|
||||
// Page variant: a collection strip's title opens that title's info page (its picture plays it).
|
||||
onOpenInfo?: (id: string) => void;
|
||||
};
|
||||
|
||||
export default function PlexInfo({
|
||||
detail,
|
||||
variant,
|
||||
onPlay,
|
||||
onClose,
|
||||
onStateChange,
|
||||
onFilter,
|
||||
onOpenPerson,
|
||||
onPlayItem,
|
||||
library,
|
||||
onOpenShow,
|
||||
onOpenSeason,
|
||||
onOpenInfo,
|
||||
}: Props) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const qc = useQueryClient();
|
||||
|
||||
// Personal display prefs (default ON), shared with the series detail pages via useDetailPrefs:
|
||||
// artBg/showCast + their toggles and the raw savePref persister. The collection-strip source hiding
|
||||
// is PlexInfo-specific state, persisted through the same savePref.
|
||||
const { artBg, showCast, crossfade, toggleArtBg, toggleCast, toggleCrossfade, savePref } =
|
||||
useDetailPrefs();
|
||||
// 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
|
||||
// per type in the customize menu (Collections / IMDb / TMDb / …), only for sources present here.
|
||||
const [hiddenSources, setHiddenSources] = useState<string[]>(
|
||||
Array.isArray(prefs.plexHiddenStripSources) ? prefs.plexHiddenStripSources : [],
|
||||
);
|
||||
const toggleSource = (src: string) => {
|
||||
setHiddenSources((cur) => {
|
||||
const next = cur.includes(src) ? cur.filter((s) => s !== src) : [...cur, src];
|
||||
savePref({ plexHiddenStripSources: next });
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [playlistOpen, setPlaylistOpen] = useState(false);
|
||||
|
||||
// Faint art backdrop, HTPC-style (page variant only) — cross-dissolves through the item's own art,
|
||||
// its TMDB backdrops, and its collection siblings' art. Cleared in the overlay
|
||||
// variant / when disabled / on unmount.
|
||||
const backdropPool = [
|
||||
detail.art,
|
||||
...(detail.tmdb?.backdrops ?? []).map(tmdbBackdropUrl),
|
||||
...(detail.collections?.flatMap((c) => c.items.map((i) => i.art)) ?? []),
|
||||
];
|
||||
useCrossfadeBackdrop(backdropPool, variant !== "overlay" && artBg, crossfade);
|
||||
|
||||
const inProgress = (detail.position_seconds ?? 0) > 0 && detail.status !== "watched";
|
||||
const setState = async (status: "new" | "watched") => {
|
||||
try {
|
||||
await api.plexSetState(detail.id, status);
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.watchedFailed"));
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: qk.plexItem(detail.id) });
|
||||
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||
qc.invalidateQueries({ queryKey: qk.plexShow() });
|
||||
onStateChange?.();
|
||||
};
|
||||
|
||||
// A collection sibling is a Plex card like any other: the related strip's menu, its
|
||||
// watched toggle and its playlist add. The flip lands on this item's cached detail, whose strip it
|
||||
// is; the resync corrects it to the truth either way.
|
||||
const colMenu = useContextMenu<PlexCard>();
|
||||
const toggleColWatched = (card: PlexCard) =>
|
||||
toggleWatchState(card, t, {
|
||||
optimistic: (next) =>
|
||||
qc.setQueriesData<PlexItemDetail>({ queryKey: qk.plexItem(detail.id) }, (old) =>
|
||||
old
|
||||
? {
|
||||
...old,
|
||||
collections: old.collections?.map((col) => ({
|
||||
...col,
|
||||
items: col.items.map((m) => (m.id === card.id ? { ...m, status: next } : m)),
|
||||
})),
|
||||
}
|
||||
: old,
|
||||
),
|
||||
resync: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.plexItem(detail.id) });
|
||||
qc.invalidateQueries({ queryKey: qk.plexItem(card.id) });
|
||||
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||
},
|
||||
});
|
||||
// Details wherever the member's title opens its page (not in the overlay): the menu is that door
|
||||
// for the keyboard.
|
||||
const colActions = usePlexCardActions(
|
||||
{
|
||||
onOpen: (c) => onPlayItem(c.id),
|
||||
onRestart: (c) => onPlayItem(c.id, true),
|
||||
onInfo: variant !== "overlay" && onOpenInfo ? (c) => onOpenInfo(c.id) : undefined,
|
||||
onToggleWatched: toggleColWatched,
|
||||
},
|
||||
colMenu.open,
|
||||
);
|
||||
|
||||
const cast = showCast ? detail.cast : [];
|
||||
const overlay = variant === "overlay";
|
||||
const mutedCls = overlay ? "text-white/70" : "text-muted";
|
||||
|
||||
// Distinct strip-source buckets present on THIS item, in strip order — one toggle each.
|
||||
const presentSources = (detail.collections ?? []).reduce<string[]>((acc, c) => {
|
||||
if (!acc.includes(c.source)) acc.push(c.source);
|
||||
return acc;
|
||||
}, []);
|
||||
const sourceLabel = (src: string) =>
|
||||
t(`plex.info.stripSource.${src}`, { defaultValue: src.toUpperCase() });
|
||||
|
||||
const asideShown = !overlay && aside.column;
|
||||
const infoTitle =
|
||||
detail.kind === "episode" && detail.show_title ? detail.show_title : detail.title;
|
||||
// Up from an episode: the show's title and the episode's season are links on the page (not over
|
||||
// the running player, where leaving would stop it).
|
||||
const showId = detail.kind === "episode" && !overlay ? detail.show_id : null;
|
||||
const seasonId = detail.season_id;
|
||||
const numbers = episodeNumbers(detail);
|
||||
const upLink = "hover:text-accent hover:underline";
|
||||
|
||||
return (
|
||||
<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} @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">
|
||||
{/* The page's own h1; over the running player it is the info dialog's title. */}
|
||||
{overlay ? (
|
||||
<h2 className="text-lg font-bold text-white">{infoTitle}</h2>
|
||||
) : (
|
||||
<PageHeading className="text-2xl font-bold">
|
||||
{showId && onOpenShow && detail.show_title ? (
|
||||
<Pressable selectable className={upLink} onClick={() => onOpenShow(showId)}>
|
||||
{infoTitle}
|
||||
</Pressable>
|
||||
) : (
|
||||
infoTitle
|
||||
)}
|
||||
</PageHeading>
|
||||
)}
|
||||
{detail.kind === "episode" && (
|
||||
<p className={`text-sm ${overlay ? "text-white/70" : "text-muted"}`}>
|
||||
{showId && seasonId && onOpenSeason && numbers ? (
|
||||
<>
|
||||
<Pressable
|
||||
selectable
|
||||
className={upLink}
|
||||
onClick={() => onOpenSeason(showId, seasonId)}
|
||||
>
|
||||
{episodeName(detail)}
|
||||
</Pressable>
|
||||
{` — ${detail.title}`}
|
||||
</>
|
||||
) : (
|
||||
episodeLine(detail)
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DetailCustomizeMenu
|
||||
overlay={overlay}
|
||||
artBg={artBg}
|
||||
onToggleArtBg={toggleArtBg}
|
||||
crossfade={crossfade}
|
||||
onToggleCrossfade={toggleCrossfade}
|
||||
showCast={showCast}
|
||||
onToggleCast={toggleCast}
|
||||
hasCast={detail.cast.length > 0}
|
||||
extra={
|
||||
<>
|
||||
{!overlay && <DetailAsideToggles aside={aside} />}
|
||||
{presentSources.map((src) => (
|
||||
<PrefToggle
|
||||
key={src}
|
||||
label={sourceLabel(src)}
|
||||
on={!hiddenSources.includes(src)}
|
||||
onClick={() => toggleSource(src)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{overlay && onClose && (
|
||||
<IconButton
|
||||
variant="overlay"
|
||||
size="md"
|
||||
onClick={onClose}
|
||||
label={t("plex.player.close")}
|
||||
>
|
||||
<X />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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 */}
|
||||
<RemoteImage
|
||||
src={detail.thumb}
|
||||
alt=""
|
||||
className={`shrink-0 self-start rounded-xl border border-border object-cover ${overlay ? "w-24 sm:w-28" : DETAIL_POSTER} aspect-2/3`}
|
||||
// The poster's own box, empty, so the text beside it does not jump into its place.
|
||||
fallback={
|
||||
<div
|
||||
className={`shrink-0 self-start rounded-xl border border-border bg-surface ${overlay ? "w-24 sm:w-28" : DETAIL_POSTER} 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: 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 whitespace-pre-line 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: 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 moved up into the hero's second column. The overlay (over
|
||||
the running player) is the same stack, lean. */}
|
||||
{(() => {
|
||||
const collections = (detail.collections ?? []).filter(
|
||||
(col) => !hiddenSources.includes(col.source),
|
||||
);
|
||||
const strips = collections.map((col) => (
|
||||
<div key={col.id} className={overlay ? "mt-6" : `${SURFACE.panel} p-4 sm:p-5`}>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<h2 className={`text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
|
||||
{col.title}
|
||||
</h2>
|
||||
{onFilter && (
|
||||
<Button
|
||||
variant="link-muted"
|
||||
className="text-xs"
|
||||
onClick={() =>
|
||||
onFilter({
|
||||
collections: [col.id],
|
||||
collectionTitles: { [col.id]: col.title },
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("plex.info.browseCollection")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Carousel ariaLabel={col.title} gap="gap-3" keyboardNav>
|
||||
{col.items.map((m, i) => (
|
||||
// The one media card, as on every other strip. A grid cell, so the
|
||||
// card fills the slot the carousel stretches to its tallest card.
|
||||
<div key={m.id} className="grid w-37.5 shrink-0 grid-cols-1">
|
||||
<PlexPosterCard
|
||||
card={{ ...m, type: "movie" }}
|
||||
shelf
|
||||
navIndex={i}
|
||||
onOpen={() => onPlayItem(m.id)}
|
||||
onPage={!overlay && onOpenInfo ? (c) => onOpenInfo(c.id) : undefined}
|
||||
onRestart={() => onPlayItem(m.id, true)}
|
||||
onToggleWatched={toggleColWatched}
|
||||
onAdd={colActions.add}
|
||||
menuProps={colMenu.triggerProps}
|
||||
menuButtonProps={colMenu.buttonProps}
|
||||
menuOpen={colMenu.open && colMenu.target?.id === m.id}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Carousel>
|
||||
</div>
|
||||
));
|
||||
const castRow = cast.length > 0 && (
|
||||
<div className={overlay ? "mt-5" : `${SURFACE.panel} p-4 sm:p-5`}>
|
||||
<h2 className={`mb-2 text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
|
||||
{t("plex.info.cast")}
|
||||
</h2>
|
||||
<CastRow
|
||||
cast={cast}
|
||||
tone={overlay ? "overlay" : "panel"}
|
||||
onFilter={onFilter}
|
||||
onOpenPerson={onOpenPerson}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
if (overlay)
|
||||
return (
|
||||
<>
|
||||
{strips}
|
||||
{castRow}
|
||||
</>
|
||||
);
|
||||
if (strips.length === 0 && !castRow) return null;
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
{strips}
|
||||
{castRow}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{editorOpen && library && (
|
||||
<PlexCollectionEditor
|
||||
item={{ id: detail.id, title: detail.title }}
|
||||
library={library}
|
||||
memberOf={(detail.collections ?? []).map((c) => c.id)}
|
||||
onClose={() => setEditorOpen(false)}
|
||||
onChanged={() => onStateChange?.()}
|
||||
/>
|
||||
)}
|
||||
{playlistOpen && (
|
||||
<PlexPlaylistAdd
|
||||
target={{ kind: "single", ratingKey: detail.id, title: detail.title }}
|
||||
onClose={() => setPlaylistOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<PlexMenu menu={colMenu} actions={colActions} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user