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.
This commit is contained in:
@@ -1,8 +1,16 @@
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SlidersHorizontal } from "lucide-react";
|
||||
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
|
||||
@@ -184,3 +192,130 @@ export function PrefToggle({
|
||||
</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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { api, type PlexCard } from "./api";
|
||||
import { notifyActionError } from "./actionErrors";
|
||||
|
||||
// Persist a watch-state toggle from any Plex surface: flip via the caller's `optimistic` cache
|
||||
// updater, call the API, surface a failure (the click used to look dead on 403/404/429), then
|
||||
// `resync` (invalidate) — which corrects the optimistic flip to truth either way. Shared by the
|
||||
// browse grid and the person page, each of which owns a different cache shape.
|
||||
export async function toggleWatchState(
|
||||
card: PlexCard,
|
||||
t: TFunction,
|
||||
handlers: { optimistic: (next: string) => void; resync: () => void },
|
||||
) {
|
||||
const next = card.status === "watched" ? "new" : "watched";
|
||||
handlers.optimistic(next);
|
||||
try {
|
||||
await api.plexSetState(card.id, next);
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.watchedFailed"));
|
||||
}
|
||||
handlers.resync();
|
||||
}
|
||||
Reference in New Issue
Block a user