refactor(plex): split PlexBrowse god-file into cards + views + shared UI (E4 S4)

Pure behaviour-preserving code-motion — PlexBrowse 1751 -> 538 lines. The ~11 inlined
components are self-contained (every view is invoked with shell callbacks as props; no
view reaches back into shell state), so they just move to sibling files.

New files:
- lib/plexWatch.ts: toggleWatchState (shared by the grid + person page)
- components/plexCards.tsx: PlexPosterCard, SeasonCard, CastStrip, RelatedStrip, EpisodeCard
- components/plexViews.tsx: PlexInfoView, PlexPersonView, PlexShowView, PlexSeasonView
- lib/plexDetailUi.tsx (extended): BackBtn, ActionBtn, WatchedToggle, DetailHero, epLabel
  (the file's own remit — "shared detail-page UI")

PlexBrowse keeps the Sub router + PLEX_SUB_URL + PAGE + PlexBrowse() shell. Repointed
the only external importer (PlexPersonView.dom.test) to plexCards/plexViews.

Self-E2E (localdev): Plex grid renders (poster cards + episodes); The Expanse show-drill
renders the hero + action buttons + season cards; ?v=show URL-mirroring intact; 0 console
errors. Gate green: typecheck, knip, eslint (0 err), prettier, 247 vitest.
This commit is contained in:
2026-08-19 06:10:48 +02:00
parent 0b592345e6
commit 180ab56379
6 changed files with 1252 additions and 1228 deletions
+463
View File
@@ -0,0 +1,463 @@
import { useTranslation } from "react-i18next";
import { CheckCircle2, Film, Info, ListPlus, Play, Tv2 } from "lucide-react";
import { type CSSProperties } from "react";
import { formatRuntime } from "../lib/format";
import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api";
// Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared
// state). Rendered by the browse grid, the person page, and the series show/season views.
// Playback cost, escalating good→heavy: direct (green) → remux, a light repackage
// (amber) → transcode, a full re-encode (red). Reuses the status trio as a gradient;
// all three stay visually distinct.
const PLAYABLE_TINT: Record<string, string> = {
direct: "bg-success",
remux: "bg-warn",
transcode: "bg-danger",
};
export function PlexPosterCard({
card,
onOpen,
onInfo,
onToggleWatched,
}: {
card: PlexCard;
onOpen: () => void;
onInfo: () => void;
onToggleWatched: (c: PlexCard) => void;
}) {
const { t } = useTranslation();
const inProgress = (card.position_seconds ?? 0) > 0 && card.status !== "watched";
const isPlayable = card.type !== "show"; // movies/episodes play; shows open the drill-down
const pct =
inProgress && card.duration_seconds
? Math.min(100, Math.round(((card.position_seconds ?? 0) / card.duration_seconds) * 100))
: 0;
return (
<div
className="group text-left"
// content-visibility keeps off-screen posters cheap to render (smoother long-list scroll).
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 300px" } as CSSProperties}
>
<div
onClick={onOpen}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpen();
}
}}
className="glass-card glass-hover relative aspect-2/3 rounded-xl overflow-hidden transition-all duration-200 cursor-pointer"
>
<img
src={card.thumb}
alt=""
loading="lazy"
decoding="async"
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
{/* Hover affordance: a play overlay on every card (movies/episodes play; a show opens). */}
<div className="absolute inset-0 bg-black/45 opacity-0 group-hover:opacity-100 transition grid place-items-center">
<div className="flex flex-col items-center gap-1 text-white">
<Play className="w-10 h-10" fill="currentColor" />
<span className="text-xs font-medium">
{card.type === "show"
? t("plex.openShow")
: inProgress
? t("plex.resume")
: t("plex.play")}
</span>
</div>
</div>
{/* Quick watched toggle (movies/episodes), on hover. */}
{isPlayable && (
<button
onClick={(e) => {
e.stopPropagation();
onToggleWatched(card);
}}
title={card.status === "watched" ? t("plex.markUnwatched") : t("plex.markWatched")}
className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 opacity-0 group-hover:opacity-100 hover:bg-black/80 transition"
>
<CheckCircle2
className={`w-4 h-4 ${card.status === "watched" ? "text-success" : "text-white"}`}
/>
</button>
)}
{/* Media info page (movies/episodes). */}
{isPlayable && (
<button
onClick={(e) => {
e.stopPropagation();
onInfo();
}}
title={t("plex.info.title")}
className="absolute bottom-1.5 left-1.5 p-1 rounded-full bg-black/60 opacity-0 group-hover:opacity-100 hover:bg-black/80 transition"
>
<Info className="w-4 h-4 text-white" />
</button>
)}
{/* Watched badge when not hovering (the toggle replaces it on hover). */}
{card.status === "watched" && (
<span className="pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm group-hover:opacity-0">
{t("plex.watched")}
</span>
)}
{card.type === "show" && card.season_count != null && (
<span className="absolute bottom-1.5 left-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm">
{t("plex.seasons", { count: card.season_count })}
</span>
)}
{/* Aggregate in-progress badge for a partially-watched show. */}
{card.type === "show" && card.status === "in_progress" && (
<span className="pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/75 text-accent px-1.5 py-0.5 rounded-sm group-hover:opacity-0">
{t("plex.inProgress")}
</span>
)}
{card.playable && (
<span
className={`absolute top-1.5 left-1.5 w-2 h-2 rounded-full ${PLAYABLE_TINT[card.playable] ?? "bg-gray-400"}`}
title={t(`plex.playable.${card.playable}`)}
/>
)}
{/* Type tag — tell a movie card from a show card at a glance (unified feed). */}
<span
className="absolute bottom-1.5 right-1.5 rounded-sm bg-black/65 p-0.5 text-white/90"
title={t(
card.type === "show" ? "plex.filter.scopeOpt.show" : "plex.filter.scopeOpt.movie",
)}
>
{card.type === "show" ? <Tv2 className="w-3 h-3" /> : <Film className="w-3 h-3" />}
</span>
{pct > 0 && (
<div className="absolute bottom-0 inset-x-0 h-1 bg-black/40">
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
</div>
)}
</div>
<div
onClick={onOpen}
className="mt-1.5 rounded-lg px-2 py-1.5 cursor-pointer"
style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
>
<div className="text-sm font-medium line-clamp-2 leading-tight min-h-[2lh] hover:text-accent">
{card.title}
</div>
<div className="text-xs text-muted">
{[card.year, formatRuntime(card.duration_seconds)].filter(Boolean).join(" · ")}
</div>
</div>
</div>
);
}
export function SeasonCard({
se,
onOpen,
onToggleWatched,
onAdd,
}: {
se: PlexSeasonDetail;
onOpen: () => void;
onToggleWatched?: () => void;
onAdd?: () => void;
}) {
const { t } = useTranslation();
const watched = se.status === "watched";
return (
<div className="group text-left">
<div
onClick={onOpen}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpen();
}
}}
className="glass-card glass-hover relative aspect-2/3 rounded-xl overflow-hidden transition-all duration-200 cursor-pointer"
>
<img
src={se.thumb}
alt=""
loading="lazy"
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
<div className="absolute inset-0 bg-black/45 opacity-0 group-hover:opacity-100 transition grid place-items-center">
<div className="flex flex-col items-center gap-1 text-white">
<Play className="w-9 h-9" fill="currentColor" />
<span className="text-xs font-medium">{t("plex.openShow")}</span>
</div>
</div>
{/* Quick watched toggle (whole season), on hover. */}
{onToggleWatched && (
<button
onClick={(e) => {
e.stopPropagation();
onToggleWatched();
}}
title={
watched ? t("plex.series.markSeasonUnwatched") : t("plex.series.markSeasonWatched")
}
className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 opacity-0 group-hover:opacity-100 hover:bg-black/80 transition"
>
<CheckCircle2 className={`w-4 h-4 ${watched ? "text-success" : "text-white"}`} />
</button>
)}
{/* Add whole season to a playlist, on hover. */}
{onAdd && (
<button
onClick={(e) => {
e.stopPropagation();
onAdd();
}}
title={t("plex.playlist.addSeason")}
className="absolute bottom-1.5 right-1.5 p-1 rounded-full bg-black/60 opacity-0 group-hover:opacity-100 hover:bg-black/80 transition"
>
<ListPlus className="w-4 h-4 text-white" />
</button>
)}
{watched && (
<span className="pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm group-hover:opacity-0">
{t("plex.watched")}
</span>
)}
{se.status === "in_progress" && (
<span className="pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/75 text-accent px-1.5 py-0.5 rounded-sm group-hover:opacity-0">
{t("plex.inProgress")}
</span>
)}
<span className="absolute bottom-1.5 left-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm">
{t("plex.series.episodeCount", { count: se.episode_count })}
</span>
</div>
<div
onClick={onOpen}
className="mt-1.5 rounded-lg px-2 py-1.5 text-sm font-medium line-clamp-2 leading-tight cursor-pointer hover:text-accent"
style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
>
{se.title}
</div>
</div>
);
}
export function CastStrip({
cast,
onFilter,
onOpenPerson,
}: {
cast: PlexCastMember[];
onFilter?: (patch: Partial<PlexFilters>) => void;
onOpenPerson?: (name: string) => void;
}) {
const { t } = useTranslation();
const clickable = !!(onOpenPerson || onFilter);
return (
<section
className="glass-card rounded-2xl p-4 sm:p-5 mb-6"
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
>
<h2 className="text-sm font-semibold mb-3">{t("plex.info.cast")}</h2>
<div className="flex gap-4 overflow-x-auto pb-2">
{cast.map((c, i) => {
const inner = (
<>
<div
className={`mx-auto h-20 w-20 overflow-hidden rounded-full border bg-surface ${clickable ? "border-border group-hover/cast:border-accent" : "border-border"}`}
>
{c.thumb ? (
<img src={c.thumb} alt="" loading="lazy" className="h-full w-full object-cover" />
) : (
<div className="grid h-full w-full place-items-center opacity-40">
{c.name.charAt(0)}
</div>
)}
</div>
<div
className={`mt-1.5 text-xs font-medium line-clamp-2 leading-tight ${clickable ? "group-hover/cast:text-accent" : ""}`}
>
{c.name}
</div>
{c.role && <div className="text-2xs text-muted line-clamp-1">{c.role}</div>}
</>
);
return clickable ? (
<button
key={i}
onClick={() =>
onOpenPerson ? onOpenPerson(c.name) : onFilter?.({ actors: [c.name] })
}
title={
onOpenPerson
? t("plex.info.viewPerson", { name: c.name })
: t("plex.info.filterActor", { name: c.name })
}
className="group/cast w-20 shrink-0 text-center"
>
{inner}
</button>
) : (
<div key={i} className="w-20 shrink-0 text-center">
{inner}
</div>
);
})}
</div>
</section>
);
}
export function RelatedStrip({
related,
onOpen,
}: {
related: PlexCard[];
onOpen: (id: string) => void;
}) {
const { t } = useTranslation();
return (
<section
className="glass-card rounded-2xl p-4 sm:p-5 mb-6"
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
>
<h2 className="text-sm font-semibold mb-3">{t("plex.series.related")}</h2>
<div className="flex gap-3 overflow-x-auto pb-2">
{related.map((r) => (
<button
key={r.id}
onClick={() => onOpen(r.id)}
className="group w-[130px] shrink-0 text-left"
>
<div className="glass-card glass-hover relative aspect-2/3 rounded-xl overflow-hidden transition-all duration-200">
<img
src={r.thumb}
alt=""
loading="lazy"
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
{r.status === "watched" && (
<span className="absolute top-1.5 right-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm">
{t("plex.watched")}
</span>
)}
</div>
<div className="mt-1.5 text-sm font-medium line-clamp-2 leading-tight min-h-[2lh]">
{r.title}
</div>
</button>
))}
</div>
</section>
);
}
export function EpisodeCard({
ep,
onPlay,
onAdd,
onToggleWatched,
withShowTitle,
}: {
ep: PlexCard;
onPlay: () => void;
onAdd?: () => void;
onToggleWatched?: () => void;
withShowTitle?: boolean;
}) {
const { t } = useTranslation();
const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched";
const pct =
inProgress && ep.duration_seconds
? Math.min(100, Math.round(((ep.position_seconds ?? 0) / ep.duration_seconds) * 100))
: 0;
return (
<div className="group">
<div
onClick={onPlay}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onPlay();
}
}}
className="relative block w-full aspect-video rounded-xl overflow-hidden bg-card border border-border text-left cursor-pointer"
>
<img
src={ep.thumb}
alt=""
loading="lazy"
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition grid place-items-center">
<Play className="w-9 h-9 text-white" fill="currentColor" />
</div>
{/* Quick watched toggle (single episode), on hover. */}
{onToggleWatched && (
<button
onClick={(e) => {
e.stopPropagation();
onToggleWatched();
}}
title={ep.status === "watched" ? t("plex.markUnwatched") : t("plex.markWatched")}
className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 opacity-0 group-hover:opacity-100 hover:bg-black/80 transition"
>
<CheckCircle2
className={`w-4 h-4 ${ep.status === "watched" ? "text-success" : "text-white"}`}
/>
</button>
)}
{ep.status === "watched" && (
<span
className={`pointer-events-none absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 ${
onToggleWatched ? "group-hover:opacity-0" : ""
}`}
>
<CheckCircle2 className="w-4 h-4 text-success" />
</span>
)}
{pct > 0 && (
<div className="absolute bottom-0 inset-x-0 h-1 bg-black/40">
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
</div>
)}
</div>
<div
className="mt-1.5 flex items-start gap-1.5 rounded-lg px-2 py-1.5"
style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
>
<div className="min-w-0 flex-1">
{withShowTitle && ep.show_title && (
<div className="text-2xs text-muted truncate">
{ep.show_title}
{ep.season_number != null && ep.episode_number != null
? ` · S${ep.season_number}·E${ep.episode_number}`
: ""}
</div>
)}
<div className="text-sm font-medium leading-tight">
{!withShowTitle && <span className="text-muted mr-1">{ep.episode_number}.</span>}
{ep.title}
</div>
<div className="text-2xs text-muted mt-0.5">{formatRuntime(ep.duration_seconds)}</div>
</div>
{onAdd && (
<button
onClick={onAdd}
title={t("plex.playlist.addEpisode")}
aria-label={t("plex.playlist.addEpisode")}
className="shrink-0 rounded-sm p-1 text-muted transition hover:text-accent"
>
<ListPlus className="w-4 h-4" />
</button>
)}
</div>
</div>
);
}