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:
2026-10-22 23:12:45 +02:00
parent fdf8076013
commit 63a0fecc8b
356 changed files with 2433 additions and 2332 deletions
+974
View File
@@ -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>
);
}