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:
2026-08-11 23:38:57 +02:00
parent 69d34f7fa3
commit c74f69babc
+234 -191
View File
@@ -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) => (