refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/
A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/ (ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows). Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the contract tests that scan sources now scan the whole app source, not only components/ — each glob was counted before and after so none went green over fewer files. The locale-format rule matched `/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton check; it now covers every folder that holds components, with a test per folder.
This commit is contained in:
@@ -0,0 +1,974 @@
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Check,
|
||||
Layers,
|
||||
ListPlus,
|
||||
Play,
|
||||
RotateCcw,
|
||||
Star,
|
||||
Tv2,
|
||||
Undo2,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import {
|
||||
api,
|
||||
type PlexCard,
|
||||
type PlexFilters,
|
||||
type PlexPersonDetail,
|
||||
type PlexSeasonDetail,
|
||||
} from "../../lib/api";
|
||||
import { plexLang } from "../../i18n";
|
||||
import { pickRatingBadge } from "../../lib/plexRating";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { toggleWatchState } from "../../lib/plexWatch";
|
||||
import { showEpisodeKeys } from "../../lib/plexQueue";
|
||||
import {
|
||||
ActionBtn,
|
||||
BackBtn,
|
||||
DetailCustomizeMenu,
|
||||
DetailHero,
|
||||
Filterable,
|
||||
WatchedToggle,
|
||||
} from "./plexDetailUi";
|
||||
import { DetailAside, DetailAsideToggles, DetailKeywords } from "./plexDetailAside";
|
||||
import {
|
||||
epLabel,
|
||||
seasonTitle,
|
||||
tmdbBackdropUrl,
|
||||
useCrossfadeBackdrop,
|
||||
useDetailPrefs,
|
||||
useDetailAside,
|
||||
} from "../../lib/plexDetail";
|
||||
import {
|
||||
CastStrip,
|
||||
EpisodeCard,
|
||||
PlexPosterCard,
|
||||
RelatedStrip,
|
||||
SeasonCard,
|
||||
} from "../../features/plexCards/plexCards";
|
||||
import { PlexMenu } from "./PlexMenu";
|
||||
import { usePlexCardActions } from "../../features/plexCards/usePlexCardActions";
|
||||
import { useCopyItems } from "../../components/ui/copyItems";
|
||||
import { ContextMenu } from "../../components/ui/ContextMenu";
|
||||
import { useContextMenu } from "../../components/ui/useContextMenu";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { ClampedText } from "../../components/ui/ClampedText";
|
||||
import { Carousel } from "../../components/ui/Carousel";
|
||||
import PlexPlaylistAdd, { type PlexAddTarget } from "../../features/plexCards/PlexPlaylistAdd";
|
||||
import PlexCollectionEditor from "./PlexCollectionEditor";
|
||||
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
||||
import NavGrid from "../../components/ui/NavGrid";
|
||||
import { Chip } from "../../components/ui/Chip";
|
||||
import { Pressable } from "../../components/ui/Pressable";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
import { PageHeading } from "../../components/ui/Headings";
|
||||
import { vocab } from "../../lib/vocab";
|
||||
import { formatDate, formatDecimal } from "../../lib/format";
|
||||
import RemoteImage from "../../components/ui/RemoteImage";
|
||||
|
||||
// The Plex sub-router's view branches, split out of PlexBrowse. Each is invoked by PlexBrowse with
|
||||
// shell callbacks as props (onBack/onPlay/onFilter/…) — no view reaches back into the shell's state,
|
||||
// which is what made this a pure code-motion split. Cards/strips come from plexCards, the shared
|
||||
// detail-page UI from plexDetailUi.
|
||||
|
||||
// Lazy: the rich movie info panel loads only when an info page is opened.
|
||||
const PlexInfo = lazy(() => import("./PlexInfo"));
|
||||
|
||||
export function PlexInfoView({
|
||||
id,
|
||||
onBack,
|
||||
onPlay,
|
||||
onPlayItem,
|
||||
onFilter,
|
||||
onOpenPerson,
|
||||
onOpenShow,
|
||||
onOpenSeason,
|
||||
onOpenInfo,
|
||||
}: {
|
||||
id: string;
|
||||
onBack: () => void;
|
||||
onPlay: () => void;
|
||||
onPlayItem: (id: string, fromStart?: boolean) => void;
|
||||
onFilter: (patch: Partial<PlexFilters>) => void;
|
||||
onOpenPerson: (name: string) => void;
|
||||
onOpenShow: (showId: string) => void;
|
||||
onOpenSeason: (showId: string, seasonId: string) => void;
|
||||
onOpenInfo: (id: string) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = plexLang(i18n.language);
|
||||
const q = useQuery({ queryKey: qk.plexItem(id, lang), queryFn: () => api.plexItem(id, lang) });
|
||||
return (
|
||||
<div className="w-[90%] max-w-400 mx-auto">
|
||||
<div className="p-4 pb-0">
|
||||
<Button variant="glass" size="xs" onClick={onBack}>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t("plex.backToLibrary")}
|
||||
</Button>
|
||||
</div>
|
||||
{q.isError && !q.data ? (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : q.isLoading || !q.data ? (
|
||||
<LoadingState label={t("plex.loading")} />
|
||||
) : (
|
||||
<Suspense fallback={<p className="p-8 text-muted text-sm">{t("plex.loading")}</p>}>
|
||||
<PlexInfo
|
||||
detail={q.data}
|
||||
variant="page"
|
||||
library={q.data.library ?? undefined}
|
||||
onPlay={onPlay}
|
||||
onPlayItem={onPlayItem}
|
||||
onFilter={onFilter}
|
||||
onOpenPerson={onOpenPerson}
|
||||
onOpenShow={onOpenShow}
|
||||
onOpenSeason={onOpenSeason}
|
||||
onOpenInfo={onOpenInfo}
|
||||
// No onStateChange: PlexInfo.setState already invalidates qk.plexItem(id) (this very
|
||||
// query), so q refetches automatically — a manual refetch here just double-fetched.
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// How often, and for how long, a person page asks again for a bio that is on its way. The
|
||||
// background fetch takes a second or two; twenty seconds covers a slow TMDB without waiting on a dead one.
|
||||
const BIO_POLL_MS = 1500;
|
||||
// From this many "Known for" titles the rail gets half the hero. Fewer (about one person in five) sit
|
||||
// right beside the text, at their posters' width, and what is left over is the panel's end — not a hole
|
||||
// between the text and a lone poster at the far edge (owner pick "b").
|
||||
const KNOWN_FOR_HALF = 4;
|
||||
const BIO_WAIT_MS = 20_000;
|
||||
|
||||
// --- Actor detail: a cast/crew member's filmography (movies + shows in one place) ---------------
|
||||
// The inverse of the cast-name → library-filter link: opened from a cast row (info/show page), it
|
||||
// shows the person's headshot + name, a "filter the library by them" action, and their titles as two
|
||||
// poster-card sections (Movies / Shows). Mirrors PlexInfoView's fetch-wrapper + glass-hero shape.
|
||||
export function PlexPersonView({
|
||||
name,
|
||||
onBack,
|
||||
onOpenCard,
|
||||
onRestartCard,
|
||||
onInfoCard,
|
||||
onFilter,
|
||||
}: {
|
||||
name: string;
|
||||
onBack: () => void;
|
||||
onOpenCard: (card: PlexCard) => void;
|
||||
onRestartCard: (card: PlexCard) => void;
|
||||
onInfoCard: (card: PlexCard) => void;
|
||||
onFilter: (patch: Partial<PlexFilters>) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
// ONE menu for this whole page — the "known for" rail and the two filmography grids are three
|
||||
// lists on one screen, and the popover anchors where it opened, so a second controller would only
|
||||
// mean a second closed popover to keep shut (see ui/ContextMenu).
|
||||
const menu = useContextMenu<PlexCard>();
|
||||
const lang = plexLang(i18n.language);
|
||||
const personKey = qk.plexPerson(name, lang);
|
||||
// A person the people_enrich job hasn't reached yet gets their bio fetched when the page opens;
|
||||
// the answer says so (`enriching`) and the page asks again until it lands — for a while, not
|
||||
// forever: BIO_WAIT_MS after the page opened (by the clock, not by the last answer — a failing ask
|
||||
// leaves that where it was) a TMDB that is down stops being waited on, and a failing ask ends it at
|
||||
// once (the next open tries again). The page is keyed on the person, so the wait starts per person;
|
||||
// a Retry starts it over (`waitRound`), or a retry answered "still on its way" would show nothing.
|
||||
const [waitOver, setWaitOver] = useState(false);
|
||||
const [waitRound, setWaitRound] = useState(0);
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setWaitOver(true), BIO_WAIT_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [waitRound]);
|
||||
const q = useQuery({
|
||||
queryKey: personKey,
|
||||
queryFn: () => api.plexPerson(name, lang),
|
||||
refetchInterval: (query) =>
|
||||
query.state.data?.enriching && !waitOver && query.state.status !== "error"
|
||||
? BIO_POLL_MS
|
||||
: false,
|
||||
});
|
||||
const bioOnItsWay = !!q.data?.enriching && !waitOver && !q.isError;
|
||||
// Cross-dissolve the backdrop through the person's films' art (the touch the roadmap calls
|
||||
// out). Reuses the shared art-backdrop / crossfade prefs the detail pages own — no per-page control.
|
||||
const { artBg, crossfade } = useDetailPrefs();
|
||||
const pd = q.data;
|
||||
useCrossfadeBackdrop(
|
||||
[...(pd?.known_for ?? []), ...(pd?.movies ?? []), ...(pd?.shows ?? [])].map((c) => c.art),
|
||||
artBg,
|
||||
crossfade,
|
||||
);
|
||||
|
||||
// Quick watch-toggle from a filmography card: optimistically flip in THIS person's cached payload
|
||||
// (both the movies and shows arrays — an id matches only one), then persist + resync via the shared
|
||||
// helper; also invalidate the library so its grid agrees.
|
||||
const toggle = (card: PlexCard) =>
|
||||
toggleWatchState(card, t, {
|
||||
optimistic: (next) =>
|
||||
qc.setQueryData<PlexPersonDetail>(personKey, (old) =>
|
||||
old
|
||||
? {
|
||||
...old,
|
||||
movies: old.movies.map((m) => (m.id === card.id ? { ...m, status: next } : m)),
|
||||
shows: old.shows.map((s) => (s.id === card.id ? { ...s, status: next } : s)),
|
||||
known_for: old.known_for.map((k) =>
|
||||
k.id === card.id ? { ...k, status: next } : k,
|
||||
),
|
||||
}
|
||||
: old,
|
||||
),
|
||||
resync: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.plexPerson(name) });
|
||||
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||
},
|
||||
});
|
||||
|
||||
const actions = usePlexCardActions(
|
||||
{ onOpen: onOpenCard, onRestart: onRestartCard, onInfo: onInfoCard, onToggleWatched: toggle },
|
||||
menu.open,
|
||||
);
|
||||
const cardMenu = (c: PlexCard) => ({
|
||||
onRestart: () => onRestartCard(c),
|
||||
onAdd: actions.add,
|
||||
menuProps: menu.triggerProps,
|
||||
menuButtonProps: menu.buttonProps,
|
||||
menuOpen: menu.open && menu.target === c,
|
||||
});
|
||||
|
||||
// Not `section`: a local named like the <section> it renders confused the React Compiler's scope
|
||||
// analysis ("[hoisting] expected identifier to be defined") and it skipped the component.
|
||||
const renderSection = (titleKey: string, cards: PlexCard[]) =>
|
||||
cards.length > 0 ? (
|
||||
<section className="mb-5">
|
||||
<h2 className="text-sm font-semibold mb-3">{t(titleKey, { count: cards.length })}</h2>
|
||||
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(9.375rem,1fr))] gap-3">
|
||||
{cards.map((c, i) => (
|
||||
<PlexPosterCard
|
||||
key={c.id}
|
||||
navIndex={i}
|
||||
card={c}
|
||||
onOpen={() => onOpenCard(c)}
|
||||
onPage={onInfoCard}
|
||||
onToggleWatched={toggle}
|
||||
{...cardMenu(c)}
|
||||
/>
|
||||
))}
|
||||
</NavGrid>
|
||||
</section>
|
||||
) : null;
|
||||
|
||||
const d = q.data;
|
||||
const empty = !!d && d.movies.length === 0 && d.shows.length === 0;
|
||||
return (
|
||||
<div className="w-[90%] max-w-400 mx-auto">
|
||||
<div className="p-4 pb-0">
|
||||
<Button variant="glass" size="xs" onClick={onBack}>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t("plex.backToLibrary")}
|
||||
</Button>
|
||||
</div>
|
||||
{q.isError && !d ? (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : q.isLoading || !d ? (
|
||||
<LoadingState label={t("plex.loading")} />
|
||||
) : (
|
||||
<div className="p-4">
|
||||
{/* Hero (owner pick "A"): the person — headshot, facts, a clamped bio — on the left,
|
||||
what they are known for on the right, so the bio doesn't sit in a full-width panel with an
|
||||
empty half beside it. One column where the panel is narrow. */}
|
||||
<section className={`${SURFACE.panel} @container p-4 sm:p-5 mb-6`}>
|
||||
<div
|
||||
className={
|
||||
d.known_for.length >= KNOWN_FOR_HALF
|
||||
? "grid gap-6 @3xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]"
|
||||
: d.known_for.length > 0
|
||||
? "grid gap-6 @3xl:grid-cols-[auto_auto] @3xl:justify-start"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{/* The person's column is at most the bio's reading measure (text-sm, as the bio is), so a
|
||||
short rail can sit right beside it. */}
|
||||
<div className="measure min-w-0 text-sm">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-full border border-border bg-surface">
|
||||
<RemoteImage
|
||||
src={d.photo}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
fallback={
|
||||
<div className="grid h-full w-full place-items-center opacity-40">
|
||||
<User className="h-8 w-8" />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<PageHeading className="text-xl font-semibold">{d.name}</PageHeading>
|
||||
{d.known_for_department && (
|
||||
<p className="text-xs font-medium text-accent">
|
||||
{vocab(t, "department", d.known_for_department)}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 mb-2 text-xs text-muted">
|
||||
{t("plex.person.credits", { movies: d.movies.length, shows: d.shows.length })}
|
||||
</p>
|
||||
<div className="mb-2 space-y-0.5 text-xs text-muted">
|
||||
{(d.birthday || d.place_of_birth) && (
|
||||
<p>
|
||||
<span className="text-fg/70">{t("plex.person.born")}:</span>{" "}
|
||||
{[formatDate(d.birthday, lang), d.place_of_birth]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
)}
|
||||
{d.deathday && (
|
||||
<p>
|
||||
<span className="text-fg/70">{t("plex.person.died")}:</span>{" "}
|
||||
{formatDate(d.deathday, lang)}
|
||||
</p>
|
||||
)}
|
||||
{d.also_known_as.length > 0 && (
|
||||
<p className="truncate">
|
||||
<span className="text-fg/70">{t("plex.person.aka")}:</span>{" "}
|
||||
{d.also_known_as.slice(0, 4).join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Chip onClick={() => onFilter({ actors: [d.name] })} className="font-medium">
|
||||
{t("plex.person.filterBy", { name: d.name })}
|
||||
</Chip>
|
||||
</div>
|
||||
</div>
|
||||
{d.biography ? (
|
||||
<ClampedText
|
||||
text={d.biography}
|
||||
lines={8}
|
||||
moreLabel={t("plex.textMore")}
|
||||
lessLabel={t("plex.textLess")}
|
||||
className="mt-4"
|
||||
/>
|
||||
) : bioOnItsWay ? (
|
||||
<LoadingState shape="text" label={t("plex.person.bioLoading")} className="mt-4" />
|
||||
) : (
|
||||
// An ask that failed while the bio was on its way is said, not left looking like a
|
||||
// person with no bio; the retry asks (and waits) again.
|
||||
d.enriching &&
|
||||
q.isError && (
|
||||
<StateMessage
|
||||
tone="error"
|
||||
compact
|
||||
onRetry={() => {
|
||||
setWaitOver(false);
|
||||
setWaitRound((n) => n + 1);
|
||||
void q.refetch();
|
||||
}}
|
||||
>
|
||||
{t("plex.person.bioFailed")}
|
||||
</StateMessage>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* "Known for" — the person's most-popular titles that are in our library. */}
|
||||
{d.known_for.length > 0 && (
|
||||
<div className="min-w-0">
|
||||
<Carousel
|
||||
title={t("plex.person.knownFor")}
|
||||
ariaLabel={t("plex.person.knownFor")}
|
||||
keyboardNav
|
||||
>
|
||||
{d.known_for.map((c, i) => (
|
||||
<div key={c.id} className="grid w-32 shrink-0 grid-cols-1">
|
||||
<PlexPosterCard
|
||||
shelf
|
||||
card={c}
|
||||
navIndex={i}
|
||||
onOpen={() => onOpenCard(c)}
|
||||
onPage={onInfoCard}
|
||||
onToggleWatched={toggle}
|
||||
{...cardMenu(c)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Carousel>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{empty ? (
|
||||
<p className="text-sm text-muted">{t("plex.person.empty")}</p>
|
||||
) : (
|
||||
<>
|
||||
{renderSection("plex.person.movies", d.movies)}
|
||||
{renderSection("plex.person.shows", d.shows)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<PlexMenu menu={menu} actions={actions} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Series drill-down: show detail page → season subpage → player -------------------------------
|
||||
// Both the show page and the season page read the SAME cached qk.plexShow(showId) payload (the
|
||||
// season page just picks its season out of it), so opening a season is instant and PlexPlaylistAdd's
|
||||
// whole-show/season gather keeps working. Actions: Resume (on-deck episode), Play (from the start),
|
||||
// Mark whole show/season watched/unwatched, Add to playlist (show/season/episode), and — admin only —
|
||||
// Add the whole show to a collection.
|
||||
|
||||
export function PlexShowView({
|
||||
showId,
|
||||
onBack,
|
||||
onPlay,
|
||||
onOpenSeason,
|
||||
onOpenShow,
|
||||
onPlayShow,
|
||||
onFilter,
|
||||
onOpenPerson,
|
||||
}: {
|
||||
showId: string;
|
||||
onBack: () => void;
|
||||
onPlay: (epRk: string, queue: string[], fromStart?: boolean) => void;
|
||||
onOpenSeason: (seasonId: string) => void;
|
||||
onOpenShow: (id: string) => void;
|
||||
/** A related show's picture: the episode it would play, as on every show card. */
|
||||
onPlayShow: (show: PlexCard, fromStart: boolean) => void;
|
||||
onFilter?: (patch: Partial<PlexFilters>) => void;
|
||||
onOpenPerson?: (name: string) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
|
||||
const lang = plexLang(i18n.language);
|
||||
const q = useQuery({
|
||||
queryKey: qk.plexShow(showId, lang),
|
||||
queryFn: () => api.plexShow(showId, lang),
|
||||
});
|
||||
const d = q.data;
|
||||
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
|
||||
const [collOpen, setCollOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const relatedMenu = useContextMenu<PlexCard>();
|
||||
const related = usePlexCardActions(
|
||||
{
|
||||
onOpen: (c) => onPlayShow(c, false),
|
||||
onRestart: (c) => onPlayShow(c, true),
|
||||
onInfo: (c) => onOpenShow(c.id),
|
||||
},
|
||||
relatedMenu.open,
|
||||
);
|
||||
const seasonMenu = useContextMenu<PlexSeasonDetail>();
|
||||
const copy = useCopyItems();
|
||||
|
||||
const show = d?.show;
|
||||
const showLib = show?.library ?? undefined;
|
||||
const allKeys = showEpisodeKeys(d);
|
||||
const watched = show?.status === "watched";
|
||||
const {
|
||||
artBg,
|
||||
showCast,
|
||||
showRelated,
|
||||
crossfade,
|
||||
toggleArtBg,
|
||||
toggleCast,
|
||||
toggleRelated,
|
||||
toggleCrossfade,
|
||||
} = useDetailPrefs();
|
||||
// The facts / keywords / crew: TMDB enriches shows too, and the page showed none of it.
|
||||
const aside = useDetailAside(show ?? {});
|
||||
useCrossfadeBackdrop(
|
||||
[
|
||||
show?.art,
|
||||
...(show?.tmdb?.backdrops ?? []).map(tmdbBackdropUrl),
|
||||
...(d?.related ?? []).map((r) => r.art),
|
||||
],
|
||||
artBg,
|
||||
crossfade,
|
||||
);
|
||||
async function markAll(w: boolean) {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.plexShowState(showId, w);
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.watchedFailed"));
|
||||
}
|
||||
setBusy(false);
|
||||
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
|
||||
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||
}
|
||||
async function markSeason(seasonRk: string, w: boolean) {
|
||||
try {
|
||||
await api.plexSeasonState(seasonRk, w);
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.watchedFailed"));
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
|
||||
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||
}
|
||||
// A season's add target (none for an empty season, which has nothing to add).
|
||||
const addSeason = (se: PlexSeasonDetail) =>
|
||||
show && se.episodes.length > 0
|
||||
? () =>
|
||||
setAddTarget({
|
||||
kind: "group",
|
||||
ratingKeys: se.episodes.map((e) => e.id),
|
||||
title: `${show.title} — ${seasonTitle(se, t)}`,
|
||||
})
|
||||
: undefined;
|
||||
// A season's one action list: the rows its card shows on hover, plus copy.
|
||||
const seasonItems = (se: PlexSeasonDetail) => {
|
||||
const seWatched = se.status === "watched";
|
||||
const add = addSeason(se);
|
||||
return [
|
||||
{
|
||||
id: "open",
|
||||
label: t("plex.series.openSeason"),
|
||||
icon: <Tv2 className="w-4 h-4" />,
|
||||
onSelect: () => onOpenSeason(se.id),
|
||||
},
|
||||
{
|
||||
id: "watched",
|
||||
label: seWatched
|
||||
? t("plex.series.markSeasonUnwatched")
|
||||
: t("plex.series.markSeasonWatched"),
|
||||
icon: seWatched ? <Undo2 className="w-4 h-4" /> : <Check className="w-4 h-4" />,
|
||||
separatorBefore: true,
|
||||
onSelect: () => void markSeason(se.id, !seWatched),
|
||||
},
|
||||
...(add
|
||||
? [
|
||||
{
|
||||
id: "playlist",
|
||||
label: t("plex.playlist.addSeason"),
|
||||
icon: <ListPlus className="w-4 h-4" />,
|
||||
onSelect: add,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...copy(seasonTitle(se, t), null).map((it) => ({ ...it, separatorBefore: true })),
|
||||
];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 max-w-300 mx-auto glass-media">
|
||||
<BackBtn onBack={onBack} label={t("plex.backToLibrary")} />
|
||||
|
||||
{q.isError && !q.data ? (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : q.isLoading || !d || !show ? (
|
||||
<LoadingState label={t("plex.loading")} />
|
||||
) : (
|
||||
<>
|
||||
{/* Hero */}
|
||||
<DetailHero
|
||||
thumb={show.thumb}
|
||||
posterTitle={show.status === "new" ? t("plex.play") : t("plex.resume")}
|
||||
posterDisabled={!show.resume}
|
||||
showPlayOverlay={!!show.resume}
|
||||
onPosterClick={() => show.resume && onPlay(show.resume.id, allKeys)}
|
||||
customizeMenu={
|
||||
<DetailCustomizeMenu
|
||||
artBg={artBg}
|
||||
onToggleArtBg={toggleArtBg}
|
||||
crossfade={crossfade}
|
||||
onToggleCrossfade={toggleCrossfade}
|
||||
showCast={showCast}
|
||||
onToggleCast={toggleCast}
|
||||
hasCast={show.cast.length > 0}
|
||||
showRelated={showRelated}
|
||||
onToggleRelated={toggleRelated}
|
||||
hasRelated={d.related.length > 0}
|
||||
extra={<DetailAsideToggles aside={aside} />}
|
||||
/>
|
||||
}
|
||||
aside={
|
||||
aside.column && (
|
||||
<DetailAside
|
||||
facts={show}
|
||||
shown={aside.shown}
|
||||
onFilter={onFilter}
|
||||
onOpenPerson={onOpenPerson}
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
<PageHeading className="text-2xl font-semibold leading-tight">{show.title}</PageHeading>
|
||||
{/* The same meta row as the media-info page: filtering values are chips, plain facts flat
|
||||
text, spaced by the gap alone (a '·' between chips reads as noise). */}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted">
|
||||
{show.year != null && (
|
||||
<Filterable
|
||||
tag
|
||||
title={onFilter ? t("plex.info.filterYear", { year: show.year }) : undefined}
|
||||
onClick={
|
||||
onFilter
|
||||
? () => onFilter({ yearMin: show.year, yearMax: show.year })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{show.year}
|
||||
</Filterable>
|
||||
)}
|
||||
{show.content_rating && (
|
||||
<Filterable
|
||||
tag
|
||||
onClick={
|
||||
onFilter
|
||||
? () => onFilter({ contentRatings: [show.content_rating!] })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{vocab(t, "certificate", show.content_rating)}
|
||||
</Filterable>
|
||||
)}
|
||||
{show.season_count != null && (
|
||||
<span>{t("plex.seasons", { count: show.season_count })}</span>
|
||||
)}
|
||||
{(() => {
|
||||
// IMDb score when present, else the Plex audience score — mirrors PlexInfo so a show
|
||||
// with no imdb:// entry (imdb_rating NULL since the IMDb-only change) still shows a
|
||||
// rating instead of dropping the badge entirely.
|
||||
const badge = pickRatingBadge(show.imdb_rating, show.rating);
|
||||
if (!badge) return null;
|
||||
const { score } = badge;
|
||||
return (
|
||||
<Pressable
|
||||
onClick={
|
||||
onFilter ? () => onFilter({ ratingMin: Math.floor(score) }) : undefined
|
||||
}
|
||||
title={
|
||||
onFilter ? t("plex.info.filterRating", { n: Math.floor(score) }) : undefined
|
||||
}
|
||||
className={`inline-flex items-center gap-1 ${onFilter ? "min-h-6 cursor-pointer hover:text-accent touch-target" : "cursor-default"}`}
|
||||
>
|
||||
{/* the rating gold — its own token, not --warn: a score is not a warning */}
|
||||
<Star className="w-3.5 h-3.5 text-rating" fill="currentColor" />
|
||||
{formatDecimal(score, lang)}
|
||||
</Pressable>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{show.genres.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{show.genres.map((g) => (
|
||||
<Filterable
|
||||
key={g}
|
||||
tag
|
||||
onClick={onFilter ? () => onFilter({ genres: [g] }) : undefined}
|
||||
>
|
||||
{g}
|
||||
</Filterable>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{show.summary && (
|
||||
<ClampedText
|
||||
text={show.summary}
|
||||
lines={5}
|
||||
moreLabel={t("plex.textMore")}
|
||||
lessLabel={t("plex.textLess")}
|
||||
className="mt-3"
|
||||
/>
|
||||
)}
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{show.resume && (
|
||||
<ActionBtn
|
||||
primary
|
||||
onClick={() => onPlay(show.resume!.id, allKeys)}
|
||||
icon={Play}
|
||||
label={show.status === "new" ? t("plex.play") : t("plex.resume")}
|
||||
sub={epLabel(show.resume)}
|
||||
/>
|
||||
)}
|
||||
{show.first && show.status !== "new" && (
|
||||
<ActionBtn
|
||||
onClick={() => onPlay(show.first!.id, allKeys, true)}
|
||||
icon={RotateCcw}
|
||||
label={t("plex.series.showFromStart")}
|
||||
sub={epLabel(show.first)}
|
||||
/>
|
||||
)}
|
||||
<WatchedToggle
|
||||
watched={watched}
|
||||
busy={busy}
|
||||
onToggle={() => markAll(!watched)}
|
||||
watchedLabel={t("plex.series.markShowUnwatched")}
|
||||
unwatchedLabel={t("plex.series.markShowWatched")}
|
||||
/>
|
||||
{allKeys.length > 0 && (
|
||||
<ActionBtn
|
||||
onClick={() =>
|
||||
setAddTarget({ kind: "group", ratingKeys: allKeys, title: show.title })
|
||||
}
|
||||
icon={ListPlus}
|
||||
label={t("plex.playlist.addShow")}
|
||||
/>
|
||||
)}
|
||||
{isAdmin && showLib && (
|
||||
<ActionBtn
|
||||
onClick={() => setCollOpen(true)}
|
||||
icon={Layers}
|
||||
label={t("plex.series.addShowCollection")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{aside.shown.keywords && (
|
||||
<DetailKeywords
|
||||
keywords={show.tmdb?.keywords ?? []}
|
||||
onFilter={onFilter}
|
||||
className="mt-5"
|
||||
/>
|
||||
)}
|
||||
</DetailHero>
|
||||
|
||||
{/* Seasons — in a glassy panel like the movie info-page strips. */}
|
||||
<section
|
||||
className={`${SURFACE.panel} p-4 sm:p-5 mb-6`}
|
||||
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
|
||||
>
|
||||
<h2 className="text-sm font-semibold mb-3">{t("plex.series.seasons")}</h2>
|
||||
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(8.75rem,1fr))] gap-3">
|
||||
{d.seasons.map((se, i) => (
|
||||
<SeasonCard
|
||||
key={se.id}
|
||||
navIndex={i}
|
||||
se={se}
|
||||
onOpen={() => onOpenSeason(se.id)}
|
||||
onToggleWatched={() => markSeason(se.id, se.status !== "watched")}
|
||||
onAdd={addSeason(se)}
|
||||
menuProps={seasonMenu.triggerProps}
|
||||
menuButtonProps={seasonMenu.buttonProps}
|
||||
menuOpen={seasonMenu.open && seasonMenu.target === se}
|
||||
/>
|
||||
))}
|
||||
</NavGrid>
|
||||
</section>
|
||||
|
||||
{show.cast.length > 0 && showCast && (
|
||||
<CastStrip cast={show.cast} onFilter={onFilter} onOpenPerson={onOpenPerson} />
|
||||
)}
|
||||
{d.related.length > 0 && showRelated && (
|
||||
<RelatedStrip
|
||||
related={d.related}
|
||||
onOpen={(c) => onOpenShow(c.id)}
|
||||
onPlay={(c) => onPlayShow(c, false)}
|
||||
onRestart={(c) => onPlayShow(c, true)}
|
||||
menuProps={relatedMenu.triggerProps}
|
||||
menuButtonProps={relatedMenu.buttonProps}
|
||||
openId={relatedMenu.open ? (relatedMenu.target?.id ?? null) : null}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<PlexMenu menu={relatedMenu} actions={related} />
|
||||
<ContextMenu
|
||||
menu={seasonMenu}
|
||||
ariaLabel={t("plex.menu")}
|
||||
items={seasonMenu.target ? seasonItems(seasonMenu.target) : []}
|
||||
/>
|
||||
|
||||
{addTarget && <PlexPlaylistAdd target={addTarget} onClose={() => setAddTarget(null)} />}
|
||||
{collOpen && show && showLib && (
|
||||
<PlexCollectionEditor
|
||||
item={{ id: show.id, title: show.title }}
|
||||
library={showLib}
|
||||
memberOf={show.collection_keys}
|
||||
onClose={() => setCollOpen(false)}
|
||||
onChanged={() => qc.invalidateQueries({ queryKey: qk.plexShow(showId) })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlexSeasonView({
|
||||
showId,
|
||||
seasonId,
|
||||
onBack,
|
||||
onPlay,
|
||||
onInfoCard,
|
||||
}: {
|
||||
showId: string;
|
||||
seasonId: string;
|
||||
onBack: () => void;
|
||||
onPlay: (epRk: string, queue: string[], fromStart?: boolean) => void;
|
||||
onInfoCard: (card: PlexCard) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const lang = plexLang(i18n.language);
|
||||
const q = useQuery({
|
||||
queryKey: qk.plexShow(showId, lang),
|
||||
queryFn: () => api.plexShow(showId, lang),
|
||||
});
|
||||
const d = q.data;
|
||||
const se = d?.seasons.find((s) => s.id === seasonId);
|
||||
const epMenu = useContextMenu<PlexCard>();
|
||||
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const seasonKeys = se?.episodes.map((e) => e.id) ?? [];
|
||||
// Play-through queue spans the WHOLE show (all seasons, in order) so a season's last episode rolls
|
||||
// straight into the next season's first — the same cross-season order the show page already uses.
|
||||
// seasonKeys stays only for the season-scoped "add to playlist" action.
|
||||
const allKeys = showEpisodeKeys(d);
|
||||
const watched = se?.status === "watched";
|
||||
const { artBg, showCast, crossfade, toggleArtBg, toggleCast, toggleCrossfade } = useDetailPrefs();
|
||||
useCrossfadeBackdrop(
|
||||
[d?.show.art, ...(d?.show.tmdb?.backdrops ?? []).map(tmdbBackdropUrl)],
|
||||
artBg,
|
||||
crossfade,
|
||||
);
|
||||
async function markAll(w: boolean) {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.plexSeasonState(seasonId, w);
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.watchedFailed"));
|
||||
}
|
||||
setBusy(false);
|
||||
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
|
||||
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||
}
|
||||
async function markEpisode(ep: PlexCard) {
|
||||
const next = ep.status === "watched" ? "new" : "watched";
|
||||
try {
|
||||
await api.plexSetState(ep.id, next);
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.watchedFailed"));
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
|
||||
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||
}
|
||||
// The episode grid's one menu (an episode is not a comfort-shelf kind, so no pin).
|
||||
const episodes = usePlexCardActions(
|
||||
{
|
||||
onOpen: (ep) => onPlay(ep.id, allKeys),
|
||||
onRestart: (ep) => onPlay(ep.id, allKeys, true),
|
||||
onInfo: onInfoCard,
|
||||
onToggleWatched: (ep) => void markEpisode(ep),
|
||||
},
|
||||
epMenu.open,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-4 max-w-300 mx-auto glass-media">
|
||||
<BackBtn onBack={onBack} label={t("plex.series.backToShow")} />
|
||||
|
||||
{q.isError && !q.data ? (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : q.isLoading || !d || !se ? (
|
||||
<LoadingState label={t("plex.loading")} />
|
||||
) : (
|
||||
<>
|
||||
<DetailHero
|
||||
thumb={se.thumb}
|
||||
posterTitle={se.status === "new" ? t("plex.play") : t("plex.resume")}
|
||||
posterDisabled={!se.resume}
|
||||
showPlayOverlay={!!se.resume}
|
||||
onPosterClick={() => se.resume && onPlay(se.resume.id, allKeys)}
|
||||
posterSizeClass="w-28 sm:w-40"
|
||||
customizeMenu={
|
||||
<DetailCustomizeMenu
|
||||
artBg={artBg}
|
||||
onToggleArtBg={toggleArtBg}
|
||||
crossfade={crossfade}
|
||||
onToggleCrossfade={toggleCrossfade}
|
||||
showCast={showCast}
|
||||
onToggleCast={toggleCast}
|
||||
hasCast={false}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Button variant="link-muted" selectable onClick={onBack} className="text-sm">
|
||||
{d.show.title}
|
||||
</Button>
|
||||
<PageHeading
|
||||
className="text-2xl font-semibold leading-tight"
|
||||
tabTitle={`${d.show.title} · ${seasonTitle(se, t)}`}
|
||||
>
|
||||
{seasonTitle(se, t)}
|
||||
</PageHeading>
|
||||
<p className="text-sm text-muted mt-1">
|
||||
{t("plex.series.episodeCount", { count: se.episode_count })}
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{se.resume && (
|
||||
<ActionBtn
|
||||
primary
|
||||
onClick={() => onPlay(se.resume!.id, allKeys)}
|
||||
icon={Play}
|
||||
label={se.status === "new" ? t("plex.play") : t("plex.resume")}
|
||||
sub={epLabel(se.resume)}
|
||||
/>
|
||||
)}
|
||||
{se.first && se.status !== "new" && (
|
||||
<ActionBtn
|
||||
onClick={() => onPlay(se.first!.id, allKeys, true)}
|
||||
icon={RotateCcw}
|
||||
label={t("plex.series.seasonFromStart")}
|
||||
sub={epLabel(se.first)}
|
||||
/>
|
||||
)}
|
||||
<WatchedToggle
|
||||
watched={watched}
|
||||
busy={busy}
|
||||
onToggle={() => markAll(!watched)}
|
||||
watchedLabel={t("plex.series.markSeasonUnwatched")}
|
||||
unwatchedLabel={t("plex.series.markSeasonWatched")}
|
||||
/>
|
||||
{seasonKeys.length > 0 && (
|
||||
<ActionBtn
|
||||
onClick={() =>
|
||||
setAddTarget({
|
||||
kind: "group",
|
||||
ratingKeys: seasonKeys,
|
||||
title: `${d.show.title} — ${seasonTitle(se, t)}`,
|
||||
})
|
||||
}
|
||||
icon={ListPlus}
|
||||
label={t("plex.playlist.addSeason")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DetailHero>
|
||||
|
||||
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(14.375rem,1fr))] gap-x-3 gap-y-4">
|
||||
{se.episodes.map((ep, i) => (
|
||||
<EpisodeCard
|
||||
key={ep.id}
|
||||
navIndex={i}
|
||||
ep={ep}
|
||||
onPlay={() => onPlay(ep.id, allKeys)}
|
||||
onPage={() => onInfoCard(ep)}
|
||||
onRestart={() => onPlay(ep.id, allKeys, true)}
|
||||
onAdd={() => episodes.add(ep)}
|
||||
onToggleWatched={() => markEpisode(ep)}
|
||||
menuProps={epMenu.triggerProps}
|
||||
menuButtonProps={epMenu.buttonProps}
|
||||
menuOpen={epMenu.open && epMenu.target === ep}
|
||||
/>
|
||||
))}
|
||||
</NavGrid>
|
||||
</>
|
||||
)}
|
||||
|
||||
{addTarget && <PlexPlaylistAdd target={addTarget} onClose={() => setAddTarget(null)} />}
|
||||
<PlexMenu menu={epMenu} actions={episodes} pin={false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user