refactor(plex): dedupe show/season heroes into DetailHero + WatchedToggle
R12 S4b. PlexShowView and PlexSeasonView were copy-paste siblings — the same glass hero frame, play/resume poster button, customize-menu slot, and the mark-watched action button, duplicated verbatim. Two shared components now own that structure: - DetailHero: the hero shell (frame + poster button + customize slot + info column via children); the season page passes a smaller poster size. - WatchedToggle: the mark-whole-show/season watched action (icon + label flip); the differing labels are props. Single source of truth for the hero — a future hero change touches one place instead of two. Gate green (tsc / eslint 0 errors / prettier / knip / vitest 79) + self-E2E: the show detail and season detail pages both render their hero, metadata, actions and watched-toggle identically to before (screenshot-verified; the stale [hmr] PlexBrowse errors in the console buffer are from an intermediate edit — the fresh Vite server logs clean).
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type CSSProperties,
|
type CSSProperties,
|
||||||
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
@@ -985,6 +986,79 @@ function EpisodeCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The shared detail-page hero shell used by BOTH the show page and the season subpage (they were
|
||||||
|
* copy-paste siblings). Owns the glass frame, the play/resume poster button, the customize-menu slot,
|
||||||
|
* and the flex info column; the caller passes the customize menu + the title/metadata/actions as
|
||||||
|
* `children`. Poster is slightly larger on the show page (default) than the season page. */
|
||||||
|
function DetailHero({
|
||||||
|
thumb,
|
||||||
|
posterTitle,
|
||||||
|
posterDisabled,
|
||||||
|
showPlayOverlay,
|
||||||
|
onPosterClick,
|
||||||
|
posterSizeClass = "w-32 sm:w-44",
|
||||||
|
customizeMenu,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
thumb: string;
|
||||||
|
posterTitle: string;
|
||||||
|
posterDisabled: boolean;
|
||||||
|
showPlayOverlay: boolean;
|
||||||
|
onPosterClick: () => void;
|
||||||
|
posterSizeClass?: string;
|
||||||
|
customizeMenu: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="glass relative rounded-2xl p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5">
|
||||||
|
<div className="absolute right-3 top-3 z-base">{customizeMenu}</div>
|
||||||
|
<button
|
||||||
|
onClick={onPosterClick}
|
||||||
|
disabled={posterDisabled}
|
||||||
|
className={`group/poster relative ${posterSizeClass} shrink-0 self-start`}
|
||||||
|
title={posterTitle}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={thumb}
|
||||||
|
alt=""
|
||||||
|
className="w-full aspect-2/3 object-cover rounded-xl border border-border"
|
||||||
|
/>
|
||||||
|
{showPlayOverlay && (
|
||||||
|
<div className="absolute inset-0 rounded-xl bg-black/40 opacity-0 group-hover/poster:opacity-100 transition grid place-items-center">
|
||||||
|
<Play className="w-10 h-10 text-white" fill="currentColor" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div className="min-w-0 flex-1">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The "mark whole show/season watched" hero action (icon + label flip on state). Verbatim in both
|
||||||
|
* the show and season heroes; the labels differ, so the caller passes both. */
|
||||||
|
function WatchedToggle({
|
||||||
|
watched,
|
||||||
|
busy,
|
||||||
|
onToggle,
|
||||||
|
watchedLabel,
|
||||||
|
unwatchedLabel,
|
||||||
|
}: {
|
||||||
|
watched: boolean;
|
||||||
|
busy?: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
watchedLabel: string;
|
||||||
|
unwatchedLabel: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ActionBtn
|
||||||
|
onClick={onToggle}
|
||||||
|
disabled={busy}
|
||||||
|
icon={watched ? CheckCheck : Check}
|
||||||
|
label={watched ? watchedLabel : unwatchedLabel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function PlexShowView({
|
function PlexShowView({
|
||||||
showId,
|
showId,
|
||||||
onBack,
|
onBack,
|
||||||
@@ -1050,8 +1124,13 @@ function PlexShowView({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
<div className="glass relative rounded-2xl p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5">
|
<DetailHero
|
||||||
<div className="absolute right-3 top-3 z-base">
|
thumb={show.thumb}
|
||||||
|
posterTitle={show.status === "new" ? t("plex.play") : t("plex.resume")}
|
||||||
|
posterDisabled={!show.resume}
|
||||||
|
showPlayOverlay={!!show.resume}
|
||||||
|
onPosterClick={() => show.resume && onPlay(show.resume.id, allKeys)}
|
||||||
|
customizeMenu={
|
||||||
<DetailCustomizeMenu
|
<DetailCustomizeMenu
|
||||||
artBg={artBg}
|
artBg={artBg}
|
||||||
onToggleArtBg={toggleArtBg}
|
onToggleArtBg={toggleArtBg}
|
||||||
@@ -1062,129 +1141,108 @@ function PlexShowView({
|
|||||||
onToggleRelated={toggleRelated}
|
onToggleRelated={toggleRelated}
|
||||||
hasRelated={d.related.length > 0}
|
hasRelated={d.related.length > 0}
|
||||||
/>
|
/>
|
||||||
</div>
|
}
|
||||||
<button
|
>
|
||||||
onClick={() => show.resume && onPlay(show.resume.id, allKeys)}
|
<h1 className="text-2xl font-semibold leading-tight">{show.title}</h1>
|
||||||
disabled={!show.resume}
|
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted">
|
||||||
className="group/poster relative w-32 sm:w-44 shrink-0 self-start"
|
{show.year != null && (
|
||||||
title={show.status === "new" ? t("plex.play") : t("plex.resume")}
|
<Filterable
|
||||||
>
|
onClick={
|
||||||
<img
|
onFilter
|
||||||
src={show.thumb}
|
? () => onFilter({ yearMin: show.year, yearMax: show.year })
|
||||||
alt=""
|
: undefined
|
||||||
className="w-full aspect-2/3 object-cover rounded-xl border border-border"
|
|
||||||
/>
|
|
||||||
{show.resume && (
|
|
||||||
<div className="absolute inset-0 rounded-xl bg-black/40 opacity-0 group-hover/poster:opacity-100 transition grid place-items-center">
|
|
||||||
<Play className="w-10 h-10 text-white" fill="currentColor" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<h1 className="text-2xl font-semibold leading-tight">{show.title}</h1>
|
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted">
|
|
||||||
{show.year != null && (
|
|
||||||
<Filterable
|
|
||||||
onClick={
|
|
||||||
onFilter
|
|
||||||
? () => onFilter({ yearMin: show.year, yearMax: show.year })
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{show.year}
|
|
||||||
</Filterable>
|
|
||||||
)}
|
|
||||||
{show.content_rating && (
|
|
||||||
<Filterable
|
|
||||||
onClick={
|
|
||||||
onFilter
|
|
||||||
? () => onFilter({ contentRatings: [show.content_rating!] })
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
· {show.content_rating}
|
|
||||||
</Filterable>
|
|
||||||
)}
|
|
||||||
{show.season_count != null && (
|
|
||||||
<span>· {t("plex.seasons", { count: show.season_count })}</span>
|
|
||||||
)}
|
|
||||||
{show.imdb_rating != null && (
|
|
||||||
<button
|
|
||||||
onClick={
|
|
||||||
onFilter
|
|
||||||
? () => onFilter({ ratingMin: Math.floor(show.imdb_rating!) })
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
className={`inline-flex items-center gap-1 ${onFilter ? "cursor-pointer hover:text-accent" : "cursor-default"}`}
|
|
||||||
>
|
|
||||||
{/* decorative rating gold — intentionally NOT a status token */}
|
|
||||||
· <Star className="w-3.5 h-3.5 text-amber-400" fill="currentColor" />
|
|
||||||
{show.imdb_rating}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{show.genres.length > 0 && (
|
|
||||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
|
||||||
{show.genres.map((g) => (
|
|
||||||
<Filterable
|
|
||||||
key={g}
|
|
||||||
className="text-2xs rounded-full bg-surface px-2 py-0.5 text-muted"
|
|
||||||
onClick={onFilter ? () => onFilter({ genres: [g] }) : undefined}
|
|
||||||
>
|
|
||||||
{g}
|
|
||||||
</Filterable>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{show.summary && (
|
|
||||||
<p className="text-sm text-muted mt-3 leading-relaxed line-clamp-5">
|
|
||||||
{show.summary}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
|
||||||
{show.resume && (
|
|
||||||
<ActionBtn
|
|
||||||
primary
|
|
||||||
onClick={() => onPlay(show.resume!.id, allKeys)}
|
|
||||||
icon={Play}
|
|
||||||
label={show.status === "new" ? t("plex.play") : t("plex.resume")}
|
|
||||||
sub={epLabel(show.resume)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{show.first && show.status !== "new" && (
|
|
||||||
<ActionBtn
|
|
||||||
onClick={() => onPlay(show.first!.id, allKeys)}
|
|
||||||
icon={RotateCcw}
|
|
||||||
label={t("plex.series.playFromStart")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<ActionBtn
|
|
||||||
onClick={() => markAll(!watched)}
|
|
||||||
disabled={busy}
|
|
||||||
icon={watched ? CheckCheck : Check}
|
|
||||||
label={
|
|
||||||
watched ? t("plex.series.markShowUnwatched") : t("plex.series.markShowWatched")
|
|
||||||
}
|
}
|
||||||
/>
|
>
|
||||||
{allKeys.length > 0 && (
|
{show.year}
|
||||||
<ActionBtn
|
</Filterable>
|
||||||
onClick={() =>
|
)}
|
||||||
setAddTarget({ kind: "group", ratingKeys: allKeys, title: show.title })
|
{show.content_rating && (
|
||||||
}
|
<Filterable
|
||||||
icon={ListPlus}
|
onClick={
|
||||||
label={t("plex.playlist.addShow")}
|
onFilter
|
||||||
/>
|
? () => onFilter({ contentRatings: [show.content_rating!] })
|
||||||
)}
|
: undefined
|
||||||
{isAdmin && showLib && (
|
}
|
||||||
<ActionBtn
|
>
|
||||||
onClick={() => setCollOpen(true)}
|
· {show.content_rating}
|
||||||
icon={Layers}
|
</Filterable>
|
||||||
label={t("plex.series.addShowCollection")}
|
)}
|
||||||
/>
|
{show.season_count != null && (
|
||||||
)}
|
<span>· {t("plex.seasons", { count: show.season_count })}</span>
|
||||||
</div>
|
)}
|
||||||
|
{show.imdb_rating != null && (
|
||||||
|
<button
|
||||||
|
onClick={
|
||||||
|
onFilter
|
||||||
|
? () => onFilter({ ratingMin: Math.floor(show.imdb_rating!) })
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
className={`inline-flex items-center gap-1 ${onFilter ? "cursor-pointer hover:text-accent" : "cursor-default"}`}
|
||||||
|
>
|
||||||
|
{/* decorative rating gold — intentionally NOT a status token */}
|
||||||
|
· <Star className="w-3.5 h-3.5 text-amber-400" fill="currentColor" />
|
||||||
|
{show.imdb_rating}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{show.genres.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
{show.genres.map((g) => (
|
||||||
|
<Filterable
|
||||||
|
key={g}
|
||||||
|
className="text-2xs rounded-full bg-surface px-2 py-0.5 text-muted"
|
||||||
|
onClick={onFilter ? () => onFilter({ genres: [g] }) : undefined}
|
||||||
|
>
|
||||||
|
{g}
|
||||||
|
</Filterable>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{show.summary && (
|
||||||
|
<p className="text-sm text-muted mt-3 leading-relaxed line-clamp-5">{show.summary}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
{show.resume && (
|
||||||
|
<ActionBtn
|
||||||
|
primary
|
||||||
|
onClick={() => onPlay(show.resume!.id, allKeys)}
|
||||||
|
icon={Play}
|
||||||
|
label={show.status === "new" ? t("plex.play") : t("plex.resume")}
|
||||||
|
sub={epLabel(show.resume)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{show.first && show.status !== "new" && (
|
||||||
|
<ActionBtn
|
||||||
|
onClick={() => onPlay(show.first!.id, allKeys)}
|
||||||
|
icon={RotateCcw}
|
||||||
|
label={t("plex.series.playFromStart")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<WatchedToggle
|
||||||
|
watched={watched}
|
||||||
|
busy={busy}
|
||||||
|
onToggle={() => markAll(!watched)}
|
||||||
|
watchedLabel={t("plex.series.markShowUnwatched")}
|
||||||
|
unwatchedLabel={t("plex.series.markShowWatched")}
|
||||||
|
/>
|
||||||
|
{allKeys.length > 0 && (
|
||||||
|
<ActionBtn
|
||||||
|
onClick={() =>
|
||||||
|
setAddTarget({ kind: "group", ratingKeys: allKeys, title: show.title })
|
||||||
|
}
|
||||||
|
icon={ListPlus}
|
||||||
|
label={t("plex.playlist.addShow")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isAdmin && showLib && (
|
||||||
|
<ActionBtn
|
||||||
|
onClick={() => setCollOpen(true)}
|
||||||
|
icon={Layers}
|
||||||
|
label={t("plex.series.addShowCollection")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DetailHero>
|
||||||
|
|
||||||
{/* Seasons — in a glassy panel like the movie info-page strips. */}
|
{/* Seasons — in a glassy panel like the movie info-page strips. */}
|
||||||
<section
|
<section
|
||||||
@@ -1296,8 +1354,14 @@ function PlexSeasonView({
|
|||||||
<LoadingState label={t("plex.loading")} />
|
<LoadingState label={t("plex.loading")} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="glass relative rounded-2xl p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5">
|
<DetailHero
|
||||||
<div className="absolute right-3 top-3 z-base">
|
thumb={se.thumb}
|
||||||
|
posterTitle={se.status === "new" ? t("plex.play") : t("plex.resume")}
|
||||||
|
posterDisabled={!se.resume}
|
||||||
|
showPlayOverlay={!!se.resume}
|
||||||
|
onPosterClick={() => se.resume && onPlay(se.resume.id, allKeys)}
|
||||||
|
posterSizeClass="w-28 sm:w-40"
|
||||||
|
customizeMenu={
|
||||||
<DetailCustomizeMenu
|
<DetailCustomizeMenu
|
||||||
artBg={artBg}
|
artBg={artBg}
|
||||||
onToggleArtBg={toggleArtBg}
|
onToggleArtBg={toggleArtBg}
|
||||||
@@ -1305,78 +1369,57 @@ function PlexSeasonView({
|
|||||||
onToggleCast={toggleCast}
|
onToggleCast={toggleCast}
|
||||||
hasCast={false}
|
hasCast={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => se.resume && onPlay(se.resume.id, allKeys)}
|
onClick={onBack}
|
||||||
disabled={!se.resume}
|
className="text-sm text-muted hover:text-accent transition text-left"
|
||||||
className="group/poster relative w-28 sm:w-40 shrink-0 self-start"
|
|
||||||
title={se.status === "new" ? t("plex.play") : t("plex.resume")}
|
|
||||||
>
|
>
|
||||||
<img
|
{d.show.title}
|
||||||
src={se.thumb}
|
|
||||||
alt=""
|
|
||||||
className="w-full aspect-2/3 object-cover rounded-xl border border-border"
|
|
||||||
/>
|
|
||||||
{se.resume && (
|
|
||||||
<div className="absolute inset-0 rounded-xl bg-black/40 opacity-0 group-hover/poster:opacity-100 transition grid place-items-center">
|
|
||||||
<Play className="w-10 h-10 text-white" fill="currentColor" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
<div className="min-w-0 flex-1">
|
<h1 className="text-2xl font-semibold leading-tight">{se.title}</h1>
|
||||||
<button
|
<p className="text-sm text-muted mt-1">
|
||||||
onClick={onBack}
|
{t("plex.series.episodeCount", { count: se.episode_count })}
|
||||||
className="text-sm text-muted hover:text-accent transition text-left"
|
</p>
|
||||||
>
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
{d.show.title}
|
{se.resume && (
|
||||||
</button>
|
|
||||||
<h1 className="text-2xl font-semibold leading-tight">{se.title}</h1>
|
|
||||||
<p className="text-sm text-muted mt-1">
|
|
||||||
{t("plex.series.episodeCount", { count: se.episode_count })}
|
|
||||||
</p>
|
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
|
||||||
{se.resume && (
|
|
||||||
<ActionBtn
|
|
||||||
primary
|
|
||||||
onClick={() => onPlay(se.resume!.id, allKeys)}
|
|
||||||
icon={Play}
|
|
||||||
label={se.status === "new" ? t("plex.play") : t("plex.resume")}
|
|
||||||
sub={epLabel(se.resume)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{se.first && se.status !== "new" && (
|
|
||||||
<ActionBtn
|
|
||||||
onClick={() => onPlay(se.first!.id, allKeys)}
|
|
||||||
icon={RotateCcw}
|
|
||||||
label={t("plex.series.playFromStart")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<ActionBtn
|
<ActionBtn
|
||||||
onClick={() => markAll(!watched)}
|
primary
|
||||||
disabled={busy}
|
onClick={() => onPlay(se.resume!.id, allKeys)}
|
||||||
icon={watched ? CheckCheck : Check}
|
icon={Play}
|
||||||
label={
|
label={se.status === "new" ? t("plex.play") : t("plex.resume")}
|
||||||
watched
|
sub={epLabel(se.resume)}
|
||||||
? t("plex.series.markSeasonUnwatched")
|
|
||||||
: t("plex.series.markSeasonWatched")
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
{seasonKeys.length > 0 && (
|
)}
|
||||||
<ActionBtn
|
{se.first && se.status !== "new" && (
|
||||||
onClick={() =>
|
<ActionBtn
|
||||||
setAddTarget({
|
onClick={() => onPlay(se.first!.id, allKeys)}
|
||||||
kind: "group",
|
icon={RotateCcw}
|
||||||
ratingKeys: seasonKeys,
|
label={t("plex.series.playFromStart")}
|
||||||
title: `${d.show.title} — ${se.title}`,
|
/>
|
||||||
})
|
)}
|
||||||
}
|
<WatchedToggle
|
||||||
icon={ListPlus}
|
watched={watched}
|
||||||
label={t("plex.playlist.addSeason")}
|
busy={busy}
|
||||||
/>
|
onToggle={() => markAll(!watched)}
|
||||||
)}
|
watchedLabel={t("plex.series.markSeasonUnwatched")}
|
||||||
</div>
|
unwatchedLabel={t("plex.series.markSeasonWatched")}
|
||||||
|
/>
|
||||||
|
{seasonKeys.length > 0 && (
|
||||||
|
<ActionBtn
|
||||||
|
onClick={() =>
|
||||||
|
setAddTarget({
|
||||||
|
kind: "group",
|
||||||
|
ratingKeys: seasonKeys,
|
||||||
|
title: `${d.show.title} — ${se.title}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
icon={ListPlus}
|
||||||
|
label={t("plex.playlist.addSeason")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DetailHero>
|
||||||
|
|
||||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-x-3 gap-y-4">
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-x-3 gap-y-4">
|
||||||
{se.episodes.map((ep) => (
|
{se.episodes.map((ep) => (
|
||||||
|
|||||||
Reference in New Issue
Block a user