Files
siftlode/frontend/src/lib/plexDetailUi.tsx
T
peter 180ab56379 refactor(plex): split PlexBrowse god-file into cards + views + shared UI (E4 S4)
Pure behaviour-preserving code-motion — PlexBrowse 1751 -> 538 lines. The ~11 inlined
components are self-contained (every view is invoked with shell callbacks as props; no
view reaches back into shell state), so they just move to sibling files.

New files:
- lib/plexWatch.ts: toggleWatchState (shared by the grid + person page)
- components/plexCards.tsx: PlexPosterCard, SeasonCard, CastStrip, RelatedStrip, EpisodeCard
- components/plexViews.tsx: PlexInfoView, PlexPersonView, PlexShowView, PlexSeasonView
- lib/plexDetailUi.tsx (extended): BackBtn, ActionBtn, WatchedToggle, DetailHero, epLabel
  (the file's own remit — "shared detail-page UI")

PlexBrowse keeps the Sub router + PLEX_SUB_URL + PAGE + PlexBrowse() shell. Repointed
the only external importer (PlexPersonView.dom.test) to plexCards/plexViews.

Self-E2E (localdev): Plex grid renders (poster cards + episodes); The Expanse show-drill
renders the hero + action buttons + season cards; ?v=show URL-mirroring intact; 0 console
errors. Gate green: typecheck, knip, eslint (0 err), prettier, 247 vitest.
2026-08-19 06:10:48 +02:00

322 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import {
ArrowLeft,
Check,
CheckCheck,
Play,
SlidersHorizontal,
type LucideIcon,
} from "lucide-react";
import { useDismiss } from "./useDismiss";
import { usePref, useSavePref } from "./usePref";
import type { PlexCard } from "./api";
// Shared "detail page" UI reused by the movie info page (PlexInfo) and the series show/season pages,
// so they look consistent (the standing glassy/HTPC brief): the faint fixed art backdrop, the two
// per-user display prefs (art background / cast row — same keys as the movie info page), and the
// customize menu that toggles them.
export function useDetailPrefs() {
// Three per-user detail-page toggles, each a server pref adopted on login (usePref). Default ON:
// undefined → true, only an explicit false hides. The detail pages mount after `me` is cached, so
// usePref seeds from the cache (no localStorage mirror).
const [artBg, setArtBg] = usePref<boolean>("plexInfoArtBg", true);
const [showCast, setShowCast] = usePref<boolean>("plexInfoCast", true);
const [showRelated, setShowRelated] = usePref<boolean>("plexInfoRelated", true);
const savePref = useSavePref();
return {
artBg,
showCast,
showRelated,
savePref, // the raw persister for callers with extra per-user prefs (e.g. strip sources)
toggleArtBg: () => setArtBg(!artBg),
toggleCast: () => setShowCast(!showCast),
toggleRelated: () => setShowRelated(!showRelated),
};
}
// A metadata value that filters the grid when clicked (if onClick is wired), else plain text. Shared
// by the movie info page (PlexInfo) and the unified grid / show pages (PlexBrowse). The optional
// `title` gives a hover hint (e.g. "Filter by 1998"); callers that don't need it just omit it.
export function Filterable({
onClick,
title,
className,
children,
}: {
onClick?: () => void;
title?: string;
className?: string;
children: ReactNode;
}) {
if (!onClick) return <span className={className}>{children}</span>;
return (
<button
onClick={onClick}
title={title}
className={`${className ?? ""} cursor-pointer hover:text-accent transition`}
>
{children}
</button>
);
}
// Paint the item's art as a faint FULL-VIEWPORT backdrop (HTPC-style) so the glass chrome floats over
// it. Painted on the app root (#root) — which is exactly the viewport and sits BEHIND the transparent
// nav / header / content — so the art fills the whole page edge-to-edge (under the rail + floating
// header too), not just the inset <main> box. (A `position:fixed` layer at negative z-index does NOT
// work here: the body's opaque background is painted over it, so it's invisible; #root's background,
// being a real ancestor of the content, is the reliable full-viewport layer.) `attachment:fixed`
// keeps it viewport-anchored while the content scrolls. Cleared on unmount / disable / no art.
export function useArtBackdrop(art: string | null | undefined, enabled: boolean) {
useLayoutEffect(() => {
const el = document.getElementById("root");
if (!el) return;
const s = el.style;
const clear = () => {
s.backgroundImage = "";
s.backgroundSize = "";
s.backgroundPosition = "";
s.backgroundRepeat = "";
s.backgroundAttachment = "";
};
if (enabled && art) {
// The faint --bg overlay keeps it subtle and text-safe (same tuning the old paint used).
s.backgroundImage =
`linear-gradient(color-mix(in srgb, var(--bg) 60%, transparent), ` +
`color-mix(in srgb, var(--bg) 64%, transparent)), url("${art}")`;
s.backgroundSize = "cover";
s.backgroundPosition = "center 20%";
s.backgroundRepeat = "no-repeat";
s.backgroundAttachment = "fixed";
} else {
clear();
}
return clear;
}, [art, enabled]);
}
export function DetailCustomizeMenu({
artBg,
onToggleArtBg,
showCast,
onToggleCast,
hasCast,
showRelated,
onToggleRelated,
hasRelated,
overlay,
extra,
}: {
artBg: boolean;
onToggleArtBg: () => void;
showCast: boolean;
onToggleCast: () => void;
hasCast: boolean;
showRelated?: boolean;
onToggleRelated?: () => void;
hasRelated?: boolean;
overlay?: boolean; // white-on-dark button styling when floated over the player
extra?: ReactNode; // additional toggles rendered after the built-in ones (e.g. strip-source buckets)
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const btnRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
useDismiss(open, () => setOpen(false), [menuRef, btnRef]);
// Deliberately NOT on the R8 popover platform. In the player's OVERLAY variant this menu renders
// inside PlexPlayer's `transform: scale(1.25)` windowed subtree, and the platform portals its panel
// out to <body> — which would paint it at 1× while the surrounding player UI is at 1.25×. A plain
// `absolute` child stays in the scaled subtree and matches, in both this variant and the unscaled
// detail-page variant. Same reason the player's own gear/tracks menus stay hand-rolled; migrating
// any of them needs the platform to portal INTO a caller-named (scaled) container.
return (
<div className="relative shrink-0">
<button
ref={btnRef}
onClick={() => setOpen((v) => !v)}
title={t("plex.info.customize")}
className={`p-1.5 rounded-lg ${overlay ? "text-white/80 hover:bg-white/15" : "text-muted hover:bg-surface hover:text-fg"}`}
>
<SlidersHorizontal className="w-4 h-4" />
</button>
{open && (
<div
ref={menuRef}
className="glass-menu absolute right-0 top-full z-menu mt-1 w-52 rounded-xl p-1.5 text-sm"
style={{ background: "color-mix(in srgb, var(--surface) 58%, transparent)" }}
>
<PrefToggle label={t("plex.info.prefArtBg")} on={artBg} onClick={onToggleArtBg} />
{hasCast && (
<PrefToggle label={t("plex.info.prefCast")} on={showCast} onClick={onToggleCast} />
)}
{hasRelated && onToggleRelated && (
<PrefToggle
label={t("plex.info.prefRelated")}
on={showRelated ?? true}
onClick={onToggleRelated}
/>
)}
{extra}
</div>
)}
</div>
);
}
/** One switch row inside `DetailCustomizeMenu`. Independent on/off toggles (all can be on at once),
* so a plain button, not a menuitemradio. */
export function PrefToggle({
label,
on,
onClick,
}: {
label: string;
on: boolean;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className="flex w-full items-center justify-between rounded-lg px-2 py-1.5 text-left hover:bg-surface"
>
<span>{label}</span>
<span
className={`relative h-4 w-7 rounded-full transition ${on ? "bg-accent" : "bg-border"}`}
>
<span
className={`absolute top-0.5 h-3 w-3 rounded-full bg-white transition-all ${on ? "left-3.5" : "left-0.5"}`}
/>
</span>
</button>
);
}
// --- Series detail-page shared UI (the show page + the season subpage share these) -------------------
// Moved out of PlexBrowse — small building blocks the show/season detail views render verbatim.
export function epLabel(ep?: PlexCard | null): string | undefined {
if (!ep) return undefined;
if (ep.season_number != null && ep.episode_number != null)
return `S${ep.season_number} · E${ep.episode_number}`;
return ep.title;
}
export function BackBtn({ onBack, label }: { onBack: () => void; label: string }) {
return (
<button
onClick={onBack}
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs mb-4"
>
<ArrowLeft className="w-4 h-4" />
{label}
</button>
);
}
export function ActionBtn({
onClick,
icon: Icon,
label,
sub,
primary,
disabled,
}: {
onClick: () => void;
icon: LucideIcon;
label: string;
sub?: string;
primary?: boolean;
disabled?: boolean;
}) {
return (
<button
onClick={onClick}
disabled={disabled}
className={`inline-flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium transition disabled:opacity-50 ${
primary ? "btn-primary shadow-xs" : "glass-card glass-hover"
}`}
>
<Icon className="w-4 h-4 shrink-0" />
<span className="flex flex-col items-start leading-tight">
<span>{label}</span>
{sub && <span className="text-2xs font-normal opacity-70">{sub}</span>}
</span>
</button>
);
}
/** 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. */
export 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. */
export 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}
/>
);
}