- A glass role's shadow is one token (shadow-float); the header search's drop-shadow utility had replaced its top highlight, and the debug pill's shadow-lg did the same. The surface-role lint rule now refuses a shadow size beside a role. - Plex: every value that filters carries a hint (genre, certificate, studio had none), and the facts panel's year and certificate filter like the header's. filterKeyword -> filterValue. - Downloads: the row resized its buttons with a [&_button] override that the save link escaped (28px beside 32px); the size is the primitive's md rung now.
300 lines
9.2 KiB
TypeScript
300 lines
9.2 KiB
TypeScript
import { useRef, useState, type ReactNode, type Ref } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import {
|
||
ArrowLeft,
|
||
Check,
|
||
CheckCheck,
|
||
Play,
|
||
SlidersHorizontal,
|
||
type LucideIcon,
|
||
} from "lucide-react";
|
||
import { useDismiss } from "../lib/useDismiss";
|
||
import { Button } from "./ui/Button";
|
||
import { IconButton } from "./ui/IconButton";
|
||
import { Pressable } from "./ui/Pressable";
|
||
import { SURFACE } from "./ui/surfaces";
|
||
import { Badge } from "./ui/Badge";
|
||
import { Chip } from "./ui/Chip";
|
||
|
||
// 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. The prefs hook, backdrop helpers and epLabel live in plexDetail.ts.
|
||
|
||
// 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). A filtering value
|
||
// always says so on hover: `title` is a caller's specific hint ("Show titles from 1998"), else the
|
||
// generic "Filter by …" from the value's own text.
|
||
export function Filterable({
|
||
onClick,
|
||
title,
|
||
className,
|
||
tag = false,
|
||
onDark = false,
|
||
children,
|
||
}: {
|
||
onClick?: () => void;
|
||
title?: string;
|
||
className?: string;
|
||
/** A genre / keyword tag rather than a run of text: an edged chip when it filters, a flat badge
|
||
* when it doesn't (E28 S3d: edged = pressable). */
|
||
tag?: boolean;
|
||
onDark?: boolean;
|
||
children: ReactNode;
|
||
}) {
|
||
const { t } = useTranslation();
|
||
const hint =
|
||
title ??
|
||
(onClick && (typeof children === "string" || typeof children === "number")
|
||
? t("plex.info.filterValue", { name: children })
|
||
: undefined);
|
||
if (tag)
|
||
return onClick ? (
|
||
<Chip variant="token" onClick={onClick} title={hint}>
|
||
{children}
|
||
</Chip>
|
||
) : (
|
||
<Badge onDark={onDark}>{children}</Badge>
|
||
);
|
||
if (!onClick) return <span className={className}>{children}</span>;
|
||
return (
|
||
<Pressable
|
||
onClick={onClick}
|
||
title={hint}
|
||
className={`${className ?? ""} cursor-pointer hover:text-accent transition`}
|
||
>
|
||
{children}
|
||
</Pressable>
|
||
);
|
||
}
|
||
|
||
export function DetailCustomizeMenu({
|
||
artBg,
|
||
onToggleArtBg,
|
||
showCast,
|
||
onToggleCast,
|
||
hasCast,
|
||
showRelated,
|
||
onToggleRelated,
|
||
hasRelated,
|
||
crossfade,
|
||
onToggleCrossfade,
|
||
overlay,
|
||
extra,
|
||
}: {
|
||
artBg: boolean;
|
||
onToggleArtBg: () => void;
|
||
showCast: boolean;
|
||
onToggleCast: () => void;
|
||
hasCast: boolean;
|
||
showRelated?: boolean;
|
||
onToggleRelated?: () => void;
|
||
hasRelated?: boolean;
|
||
// Crossfade toggle (E5 S8) — offered only when the art backdrop itself is on; omitted where a page
|
||
// doesn't wire it (e.g. the season page reuses the show's backdrop prefs without its own control).
|
||
crossfade?: boolean;
|
||
onToggleCrossfade?: () => void;
|
||
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">
|
||
<IconButton
|
||
ref={btnRef}
|
||
variant={overlay ? "overlay" : "ghost"}
|
||
size="md"
|
||
onClick={() => setOpen((v) => !v)}
|
||
aria-expanded={open}
|
||
label={t("plex.info.customize")}
|
||
>
|
||
<SlidersHorizontal />
|
||
</IconButton>
|
||
{open && (
|
||
<div
|
||
ref={menuRef}
|
||
className={`${SURFACE.menu} absolute right-0 top-full z-menu mt-1 w-52 text-sm`}
|
||
style={{ background: "color-mix(in srgb, var(--surface) 58%, transparent)" }}
|
||
>
|
||
<PrefToggle label={t("plex.info.prefArtBg")} on={artBg} onClick={onToggleArtBg} />
|
||
{artBg && onToggleCrossfade && (
|
||
<PrefToggle
|
||
label={t("plex.info.prefCrossfade")}
|
||
on={crossfade ?? true}
|
||
onClick={onToggleCrossfade}
|
||
/>
|
||
)}
|
||
{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 (
|
||
<Pressable
|
||
role="switch"
|
||
aria-checked={on}
|
||
layer
|
||
onClick={onClick}
|
||
className="flex w-full items-center justify-between rounded-lg px-2 py-1.5"
|
||
>
|
||
<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 transition-all ${on ? "left-3.5 bg-accent-fg" : "left-0.5 bg-white"}`}
|
||
/>
|
||
</span>
|
||
</Pressable>
|
||
);
|
||
}
|
||
|
||
// --- 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 BackBtn({ onBack, label }: { onBack: () => void; label: string }) {
|
||
return (
|
||
<Button variant="glass" size="sm" icon={<ArrowLeft />} onClick={onBack} className="mb-4">
|
||
{label}
|
||
</Button>
|
||
);
|
||
}
|
||
|
||
export function ActionBtn({
|
||
ref,
|
||
onClick,
|
||
icon: Icon,
|
||
label,
|
||
sub,
|
||
primary,
|
||
disabled,
|
||
}: {
|
||
onClick: () => void;
|
||
icon: LucideIcon;
|
||
label: string;
|
||
sub?: string;
|
||
primary?: boolean;
|
||
disabled?: boolean;
|
||
ref?: Ref<HTMLButtonElement>;
|
||
}) {
|
||
return (
|
||
<Button
|
||
ref={ref}
|
||
variant={primary ? "primary" : "glass"}
|
||
onClick={onClick}
|
||
disabled={disabled}
|
||
icon={<Icon />}
|
||
>
|
||
{label}
|
||
{/* The resume point rides on the same line: the md rung is one line tall (B1). */}
|
||
{sub && <span className="opacity-75">· {sub}</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={`${SURFACE.panel} relative p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5`}>
|
||
<div className="absolute right-3 top-3 z-base">{customizeMenu}</div>
|
||
<Pressable
|
||
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>
|
||
)}
|
||
</Pressable>
|
||
<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}
|
||
/>
|
||
);
|
||
}
|