From c73761ae39d0fb0ed9318a6f1cfa4a721dbb42c1 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 3 Oct 2026 19:12:17 +0200 Subject: [PATCH] refactor(plex): one cast row for a show's page and a title's info CastStrip (a show's page) and PlexInfo (a title's page and the player's overlay) each drew the cast carousel by hand, and the copies had drifted: the gap, lazy pictures, the name's tooltip, the initial's size. Both now render CastRow, which takes the overlay's white-on-art palette as a tone; each caller keeps its own frame and heading. A DOM test holds the palette per variant (seen red with the tone wired to "panel"). jscpd over the shipped code: 1 clone >= 10 lines -> 0. --- frontend/src/components/PlexInfo.dom.test.tsx | 11 ++ frontend/src/components/PlexInfo.tsx | 74 +--------- frontend/src/components/plexCards.tsx | 136 +++++++++++------- 3 files changed, 100 insertions(+), 121 deletions(-) diff --git a/frontend/src/components/PlexInfo.dom.test.tsx b/frontend/src/components/PlexInfo.dom.test.tsx index 737e7f4d..70a55c88 100644 --- a/frontend/src/components/PlexInfo.dom.test.tsx +++ b/frontend/src/components/PlexInfo.dom.test.tsx @@ -300,6 +300,17 @@ describe("PlexInfo — the hero's second column (page)", () => { expect(screen.getByTestId("detail-aside").textContent).toContain(`Person ${CREW_SHOWN + 1}`); expect(screen.queryByRole("button", { name: "plex.info.crewMore" })).toBeNull(); }); + + // E32 S2: the cast row is the shared CastRow now; the page and the player's overlay hand it their + // own palette. Only the overlay sits on the dark art, so only it may carry the white text. + it.each([ + ["page", false], + ["overlay", true], + ] as const)("the %s variant's cast row takes its own palette", (variant, white) => { + wrap(); + expect(screen.getByText("An Actor").className.includes("text-white/90")).toBe(white); + expect(screen.getByText("Hero").className.includes("text-muted")).toBe(!white); + }); }); // E28 review D1 + D2: a collection sibling is a Plex card like any other — its "…" menu, its watched diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx index 7d9d57f6..1242b324 100644 --- a/frontend/src/components/PlexInfo.tsx +++ b/frontend/src/components/PlexInfo.tsx @@ -19,7 +19,6 @@ import { Carousel } from "./ui/Carousel"; import PlexCollectionEditor from "./PlexCollectionEditor"; import PlexPlaylistAdd from "./PlexPlaylistAdd"; import { PLAYABLE_TINT, playableClass } from "./plexPlayable"; -import { navItemAttrs } from "../lib/listNav"; import { Button } from "./ui/Button"; import { IconButton } from "./ui/IconButton"; import { Pressable } from "./ui/Pressable"; @@ -27,7 +26,7 @@ import { SURFACE } from "./ui/surfaces"; import { Badge } from "./ui/Badge"; import { IconLink } from "./ui/LinkButton"; import { PageHeading } from "./ui/Headings"; -import { PlexPosterCard } from "./plexCards"; +import { CastRow, PlexPosterCard } from "./plexCards"; import { PlexMenu } from "./PlexMenu"; import { useContextMenu } from "./ui/useContextMenu"; import { usePlexCardActions } from "./usePlexCardActions"; @@ -594,71 +593,12 @@ export default function PlexInfo({

{t("plex.info.cast")}

- - {cast.map((c, i) => { - const clickable = !!(onOpenPerson || onFilter); - const inner = ( - <> -
- {c.thumb ? ( - - ) : ( -
- {c.name.charAt(0)} -
- )} -
-
- {c.name} -
- {c.role && ( -
- {c.role} -
- )} - - ); - return clickable ? ( - - 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" - > - {inner} - - ) : ( -
- {inner} -
- ); - })} -
+ ); if (overlay) diff --git a/frontend/src/components/plexCards.tsx b/frontend/src/components/plexCards.tsx index d580e6cc..ed1e769e 100644 --- a/frontend/src/components/plexCards.tsx +++ b/frontend/src/components/plexCards.tsx @@ -528,69 +528,97 @@ export function CastStrip({ onOpenPerson?: (name: string) => void; }) { const { t } = useTranslation(); - const clickable = !!(onOpenPerson || onFilter); return (

{t("plex.info.cast")}

- - {cast.map((c, i) => { - const inner = ( - <> -
- {c.thumb ? ( - - ) : ( -
- {c.name.charAt(0)} -
- )} -
- {/* Name + role are each capped to ONE line (truncate → ellipsis) so every tile has the - same text height and the row of circles sits on one baseline regardless of name length. */} -
- {c.name} -
- {c.role && ( -
- {c.role} + +
+ ); +} + +// The ONE cast carousel: a show's page (CastStrip) and a title's info (PlexInfo, page or the +// player's overlay) each frame it with their own heading. Two hand copies of it had drifted apart +// (gap, lazy pictures, the name's tooltip — E32 S2). `tone="overlay"` is the white-on-art palette. +export function CastRow({ + cast, + tone = "panel", + onFilter, + onOpenPerson, +}: { + cast: PlexCastMember[]; + tone?: "panel" | "overlay"; + onFilter?: (patch: Partial) => void; + onOpenPerson?: (name: string) => void; +}) { + const { t } = useTranslation(); + const clickable = !!(onOpenPerson || onFilter); + const overlay = tone === "overlay"; + return ( + + {cast.map((c, i) => { + const inner = ( + <> +
+ {c.thumb ? ( + + ) : ( +
+ {c.name.charAt(0)}
)} - - ); - return clickable ? ( - - 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" - > - {inner} - - ) : ( -
- {inner}
- ); - })} - - + {/* Name + role are each capped to ONE line (truncate → ellipsis) so every tile has the + same text height and the row of circles sits on one baseline regardless of name length. */} +
+ {c.name} +
+ {c.role && ( +
+ {c.role} +
+ )} + + ); + return clickable ? ( + (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" + > + {inner} + + ) : ( +
+ {inner} +
+ ); + })} + ); }