Files
siftlode/frontend/src/components/plexDetailUi.tsx
T
peter 383d394e32 fix(ui): a picture that fails to load shows the card's own frame, then retries
A bare <img> sent a Referer that Google's image CDNs refuse and had no retry and no fallback, so a
Home shelf card showed the browser's broken-image mark. ui/RemoteImage gathers what Avatar, the
channel banner and the download rows had each hand-rolled: no Referer, up to three retries past the
browser's negative cache, the caller's fallback (by default the card's empty frame) instead of the
broken mark, the failure remembered by URL. Every picture now goes through it; a lint rule refuses
a bare <img>.
2026-10-14 20:51:08 +02:00

323 lines
10 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 { 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 { DETAIL_POSTER } from "../lib/plexDetail";
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";
import RemoteImage from "./ui/RemoteImage";
// 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 (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
selectable
onClick={onClick}
title={hint}
// min-h-6 + touch-target: a value in the meta line is still a 24 / 44 target (B1, A3).
className={`${className ?? ""} min-h-6 min-w-6 cursor-pointer hover:text-accent transition touch-target`}
>
{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 — 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 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. `aside` is the
* show page's second column (the facts and crew, plexDetailAside): beside the text in a wide hero,
* under it in a narrow one. */
export function DetailHero({
thumb,
posterTitle,
posterDisabled,
showPlayOverlay,
onPosterClick,
posterSizeClass = DETAIL_POSTER,
customizeMenu,
aside,
children,
}: {
thumb: string;
posterTitle: string;
posterDisabled: boolean;
showPlayOverlay: boolean;
onPosterClick: () => void;
posterSizeClass?: string;
customizeMenu: ReactNode;
aside?: ReactNode;
children: ReactNode;
}) {
const main = (
<div className="flex gap-4 sm:gap-5">
<Pressable
onClick={onPosterClick}
disabled={posterDisabled}
className={`group/poster relative ${posterSizeClass} shrink-0 self-start`}
title={posterTitle}
>
<RemoteImage
src={thumb}
alt=""
className="w-full aspect-2/3 object-cover rounded-xl border border-border"
fallback={
<div className="w-full aspect-2/3 rounded-xl border border-border bg-surface" />
}
/>
{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>
);
return (
<div className={`${SURFACE.panel} @container relative p-4 sm:p-5 mb-8`}>
<div className="absolute right-3 top-3 z-base">{customizeMenu}</div>
{aside ? (
<div className="grid gap-6 @5xl:grid-cols-[minmax(0,1fr)_minmax(0,24rem)]">
{main}
{aside}
</div>
) : (
main
)}
</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}
/>
);
}