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.
This commit is contained in:
@@ -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.getByTestId("detail-aside").textContent).toContain(`Person ${CREW_SHOWN + 1}`);
|
||||||
expect(screen.queryByRole("button", { name: "plex.info.crewMore" })).toBeNull();
|
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(<PlexInfo onPlayItem={noop} detail={full} variant={variant} />);
|
||||||
|
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
|
// E28 review D1 + D2: a collection sibling is a Plex card like any other — its "…" menu, its watched
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import { Carousel } from "./ui/Carousel";
|
|||||||
import PlexCollectionEditor from "./PlexCollectionEditor";
|
import PlexCollectionEditor from "./PlexCollectionEditor";
|
||||||
import PlexPlaylistAdd from "./PlexPlaylistAdd";
|
import PlexPlaylistAdd from "./PlexPlaylistAdd";
|
||||||
import { PLAYABLE_TINT, playableClass } from "./plexPlayable";
|
import { PLAYABLE_TINT, playableClass } from "./plexPlayable";
|
||||||
import { navItemAttrs } from "../lib/listNav";
|
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
import { IconButton } from "./ui/IconButton";
|
import { IconButton } from "./ui/IconButton";
|
||||||
import { Pressable } from "./ui/Pressable";
|
import { Pressable } from "./ui/Pressable";
|
||||||
@@ -27,7 +26,7 @@ import { SURFACE } from "./ui/surfaces";
|
|||||||
import { Badge } from "./ui/Badge";
|
import { Badge } from "./ui/Badge";
|
||||||
import { IconLink } from "./ui/LinkButton";
|
import { IconLink } from "./ui/LinkButton";
|
||||||
import { PageHeading } from "./ui/Headings";
|
import { PageHeading } from "./ui/Headings";
|
||||||
import { PlexPosterCard } from "./plexCards";
|
import { CastRow, PlexPosterCard } from "./plexCards";
|
||||||
import { PlexMenu } from "./PlexMenu";
|
import { PlexMenu } from "./PlexMenu";
|
||||||
import { useContextMenu } from "./ui/useContextMenu";
|
import { useContextMenu } from "./ui/useContextMenu";
|
||||||
import { usePlexCardActions } from "./usePlexCardActions";
|
import { usePlexCardActions } from "./usePlexCardActions";
|
||||||
@@ -594,71 +593,12 @@ export default function PlexInfo({
|
|||||||
<h2 className={`mb-2 text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
|
<h2 className={`mb-2 text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
|
||||||
{t("plex.info.cast")}
|
{t("plex.info.cast")}
|
||||||
</h2>
|
</h2>
|
||||||
<Carousel
|
<CastRow
|
||||||
ariaLabel={t("plex.info.cast")}
|
cast={cast}
|
||||||
gap="gap-3"
|
tone={overlay ? "overlay" : "panel"}
|
||||||
keyboardNav={!!(onOpenPerson || onFilter)}
|
onFilter={onFilter}
|
||||||
>
|
onOpenPerson={onOpenPerson}
|
||||||
{cast.map((c, i) => {
|
/>
|
||||||
const clickable = !!(onOpenPerson || onFilter);
|
|
||||||
const inner = (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className={`mx-auto mb-1 h-20 w-20 overflow-hidden rounded-full border ${
|
|
||||||
overlay ? "border-white/15 bg-white/10" : "border-border bg-surface"
|
|
||||||
} ${clickable ? "group-hover/cast:border-accent" : ""}`}
|
|
||||||
>
|
|
||||||
{c.thumb ? (
|
|
||||||
<img src={c.thumb} alt="" className="h-full w-full object-cover" />
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className={`grid h-full w-full place-items-center text-lg opacity-40 ${overlay ? "text-white" : ""}`}
|
|
||||||
>
|
|
||||||
{c.name.charAt(0)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`truncate text-xs font-medium ${overlay ? "text-white/90" : ""} ${
|
|
||||||
clickable ? "group-hover/cast:text-accent" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{c.name}
|
|
||||||
</div>
|
|
||||||
{c.role && (
|
|
||||||
<div
|
|
||||||
className={`truncate text-2xs ${overlay ? "text-white/55" : "text-muted"}`}
|
|
||||||
>
|
|
||||||
{c.role}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
return clickable ? (
|
|
||||||
<Pressable
|
|
||||||
selectable
|
|
||||||
key={i}
|
|
||||||
align="center"
|
|
||||||
{...navItemAttrs(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"
|
|
||||||
>
|
|
||||||
{inner}
|
|
||||||
</Pressable>
|
|
||||||
) : (
|
|
||||||
<div key={i} className="w-20 shrink-0 text-center">
|
|
||||||
{inner}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Carousel>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
if (overlay)
|
if (overlay)
|
||||||
|
|||||||
@@ -528,69 +528,97 @@ export function CastStrip({
|
|||||||
onOpenPerson?: (name: string) => void;
|
onOpenPerson?: (name: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const clickable = !!(onOpenPerson || onFilter);
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={`${SURFACE.panel} p-4 sm:p-5 mb-6`}
|
className={`${SURFACE.panel} p-4 sm:p-5 mb-6`}
|
||||||
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
|
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
|
||||||
>
|
>
|
||||||
<h2 className="text-sm font-semibold mb-3">{t("plex.info.cast")}</h2>
|
<h2 className="text-sm font-semibold mb-3">{t("plex.info.cast")}</h2>
|
||||||
<Carousel gap="gap-4" ariaLabel={t("plex.info.cast")} keyboardNav={clickable}>
|
<CastRow cast={cast} onFilter={onFilter} onOpenPerson={onOpenPerson} />
|
||||||
{cast.map((c, i) => {
|
</section>
|
||||||
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"}`}
|
// 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
|
||||||
{c.thumb ? (
|
// (gap, lazy pictures, the name's tooltip — E32 S2). `tone="overlay"` is the white-on-art palette.
|
||||||
<img src={c.thumb} alt="" loading="lazy" className="h-full w-full object-cover" />
|
export function CastRow({
|
||||||
) : (
|
cast,
|
||||||
<div className="grid h-full w-full place-items-center opacity-40">
|
tone = "panel",
|
||||||
{c.name.charAt(0)}
|
onFilter,
|
||||||
</div>
|
onOpenPerson,
|
||||||
)}
|
}: {
|
||||||
</div>
|
cast: PlexCastMember[];
|
||||||
{/* Name + role are each capped to ONE line (truncate → ellipsis) so every tile has the
|
tone?: "panel" | "overlay";
|
||||||
same text height and the row of circles sits on one baseline regardless of name length. */}
|
onFilter?: (patch: Partial<PlexFilters>) => void;
|
||||||
<div
|
onOpenPerson?: (name: string) => void;
|
||||||
className={`mt-1.5 truncate text-xs font-medium leading-tight ${clickable ? "group-hover/cast:text-accent" : ""}`}
|
}) {
|
||||||
title={c.name}
|
const { t } = useTranslation();
|
||||||
>
|
const clickable = !!(onOpenPerson || onFilter);
|
||||||
{c.name}
|
const overlay = tone === "overlay";
|
||||||
</div>
|
return (
|
||||||
{c.role && (
|
<Carousel gap="gap-4" ariaLabel={t("plex.info.cast")} keyboardNav={clickable}>
|
||||||
<div className="truncate text-2xs text-muted" title={c.role}>
|
{cast.map((c, i) => {
|
||||||
{c.role}
|
const inner = (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`mx-auto h-20 w-20 overflow-hidden rounded-full border ${
|
||||||
|
overlay ? "border-white/15 bg-white/10" : "border-border bg-surface"
|
||||||
|
} ${clickable ? "group-hover/cast:border-accent" : ""}`}
|
||||||
|
>
|
||||||
|
{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 text-lg opacity-40 ${overlay ? "text-white" : ""}`}
|
||||||
|
>
|
||||||
|
{c.name.charAt(0)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
);
|
|
||||||
return clickable ? (
|
|
||||||
<Pressable
|
|
||||||
selectable
|
|
||||||
key={i}
|
|
||||||
align="center"
|
|
||||||
{...navItemAttrs(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"
|
|
||||||
>
|
|
||||||
{inner}
|
|
||||||
</Pressable>
|
|
||||||
) : (
|
|
||||||
<div key={i} className="w-20 shrink-0 text-center">
|
|
||||||
{inner}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
{/* 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. */}
|
||||||
</Carousel>
|
<div
|
||||||
</section>
|
className={`mt-1.5 truncate text-xs font-medium leading-tight ${overlay ? "text-white/90" : ""} ${
|
||||||
|
clickable ? "group-hover/cast:text-accent" : ""
|
||||||
|
}`}
|
||||||
|
title={c.name}
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
</div>
|
||||||
|
{c.role && (
|
||||||
|
<div
|
||||||
|
className={`truncate text-2xs ${overlay ? "text-white/55" : "text-muted"}`}
|
||||||
|
title={c.role}
|
||||||
|
>
|
||||||
|
{c.role}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
return clickable ? (
|
||||||
|
<Pressable
|
||||||
|
selectable
|
||||||
|
key={i}
|
||||||
|
align="center"
|
||||||
|
{...navItemAttrs(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"
|
||||||
|
>
|
||||||
|
{inner}
|
||||||
|
</Pressable>
|
||||||
|
) : (
|
||||||
|
<div key={i} className="w-20 shrink-0 text-center">
|
||||||
|
{inner}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Carousel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user