From c74f69babc5c07a8272317eec4aa9ecccc876560 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 11 Aug 2026 23:38:57 +0200 Subject: [PATCH] refactor(plex): dedupe show/season heroes into DetailHero + WatchedToggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- frontend/src/components/PlexBrowse.tsx | 425 ++++++++++++++----------- 1 file changed, 234 insertions(+), 191 deletions(-) diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 6aedf49d..cca253f1 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -6,6 +6,7 @@ import { useRef, useState, type CSSProperties, + type ReactNode, } from "react"; import { qk } from "../lib/queryKeys"; 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 ( +
+
{customizeMenu}
+ +
{children}
+
+ ); +} + +/** 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 ( + + ); +} + function PlexShowView({ showId, onBack, @@ -1050,8 +1124,13 @@ function PlexShowView({ ) : ( <> {/* Hero */} -
-
+ show.resume && onPlay(show.resume.id, allKeys)} + customizeMenu={ 0} /> -
- -
-

{show.title}

-
- {show.year != null && ( - onFilter({ yearMin: show.year, yearMax: show.year }) - : undefined - } - > - {show.year} - - )} - {show.content_rating && ( - onFilter({ contentRatings: [show.content_rating!] }) - : undefined - } - > - · {show.content_rating} - - )} - {show.season_count != null && ( - · {t("plex.seasons", { count: show.season_count })} - )} - {show.imdb_rating != null && ( - - )} -
- {show.genres.length > 0 && ( -
- {show.genres.map((g) => ( - onFilter({ genres: [g] }) : undefined} - > - {g} - - ))} -
- )} - {show.summary && ( -

- {show.summary} -

- )} -
- {show.resume && ( - 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" && ( - onPlay(show.first!.id, allKeys)} - icon={RotateCcw} - label={t("plex.series.playFromStart")} - /> - )} - markAll(!watched)} - disabled={busy} - icon={watched ? CheckCheck : Check} - label={ - watched ? t("plex.series.markShowUnwatched") : t("plex.series.markShowWatched") + } + > +

{show.title}

+
+ {show.year != null && ( + onFilter({ yearMin: show.year, yearMax: show.year }) + : undefined } - /> - {allKeys.length > 0 && ( - - setAddTarget({ kind: "group", ratingKeys: allKeys, title: show.title }) - } - icon={ListPlus} - label={t("plex.playlist.addShow")} - /> - )} - {isAdmin && showLib && ( - setCollOpen(true)} - icon={Layers} - label={t("plex.series.addShowCollection")} - /> - )} -
+ > + {show.year} + + )} + {show.content_rating && ( + onFilter({ contentRatings: [show.content_rating!] }) + : undefined + } + > + · {show.content_rating} + + )} + {show.season_count != null && ( + · {t("plex.seasons", { count: show.season_count })} + )} + {show.imdb_rating != null && ( + + )}
-
+ {show.genres.length > 0 && ( +
+ {show.genres.map((g) => ( + onFilter({ genres: [g] }) : undefined} + > + {g} + + ))} +
+ )} + {show.summary && ( +

{show.summary}

+ )} +
+ {show.resume && ( + 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" && ( + onPlay(show.first!.id, allKeys)} + icon={RotateCcw} + label={t("plex.series.playFromStart")} + /> + )} + markAll(!watched)} + watchedLabel={t("plex.series.markShowUnwatched")} + unwatchedLabel={t("plex.series.markShowWatched")} + /> + {allKeys.length > 0 && ( + + setAddTarget({ kind: "group", ratingKeys: allKeys, title: show.title }) + } + icon={ListPlus} + label={t("plex.playlist.addShow")} + /> + )} + {isAdmin && showLib && ( + setCollOpen(true)} + icon={Layers} + label={t("plex.series.addShowCollection")} + /> + )} +
+ {/* Seasons — in a glassy panel like the movie info-page strips. */}
) : ( <> -
-
+ se.resume && onPlay(se.resume.id, allKeys)} + posterSizeClass="w-28 sm:w-40" + customizeMenu={ -
+ } + > -
- -

{se.title}

-

- {t("plex.series.episodeCount", { count: se.episode_count })} -

-
- {se.resume && ( - 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" && ( - onPlay(se.first!.id, allKeys)} - icon={RotateCcw} - label={t("plex.series.playFromStart")} - /> - )} +

{se.title}

+

+ {t("plex.series.episodeCount", { count: se.episode_count })} +

+
+ {se.resume && ( markAll(!watched)} - disabled={busy} - icon={watched ? CheckCheck : Check} - label={ - watched - ? t("plex.series.markSeasonUnwatched") - : t("plex.series.markSeasonWatched") - } + primary + onClick={() => onPlay(se.resume!.id, allKeys)} + icon={Play} + label={se.status === "new" ? t("plex.play") : t("plex.resume")} + sub={epLabel(se.resume)} /> - {seasonKeys.length > 0 && ( - - setAddTarget({ - kind: "group", - ratingKeys: seasonKeys, - title: `${d.show.title} — ${se.title}`, - }) - } - icon={ListPlus} - label={t("plex.playlist.addSeason")} - /> - )} -
+ )} + {se.first && se.status !== "new" && ( + onPlay(se.first!.id, allKeys)} + icon={RotateCcw} + label={t("plex.series.playFromStart")} + /> + )} + markAll(!watched)} + watchedLabel={t("plex.series.markSeasonUnwatched")} + unwatchedLabel={t("plex.series.markSeasonWatched")} + /> + {seasonKeys.length > 0 && ( + + setAddTarget({ + kind: "group", + ratingKeys: seasonKeys, + title: `${d.show.title} — ${se.title}`, + }) + } + icon={ListPlus} + label={t("plex.playlist.addSeason")} + /> + )}
-
+
{se.episodes.map((ep) => (