Files
siftlode/frontend/src/components/PlexCollectionView.tsx
T
peter 4c7f73af9d feat(plex): a title opens its page, the picture plays; an episode links up
A card title lit like a link but played the item. Now the picture plays and
the title opens the page of what it names: a movie's or an episode's info
page, a show's page, and for an episode standing as its show (Recently
watched) the show. Every poster and episode card carries it (grid, rails,
person, collection, season, related, Home), a card with no page keeps a plain
title, and the season page gains the menu's Details row too.

The episode page had no way up: the detail now serves the show's and the
season's rating keys, and on the page the show title opens the show and
"S1·E25" the season (Back returns to where the page was).
2026-10-09 00:06:18 +02:00

540 lines
20 KiB
TypeScript

import { useState } from "react";
import { useTranslation } from "react-i18next";
import { vocab } from "../lib/vocab";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Play, Shuffle, Pencil, Trash2, Plus, X, Lock, Check, Search } from "lucide-react";
import { qk } from "../lib/queryKeys";
import { plexLang } from "../i18n";
import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollectionDetail } from "../lib/api";
import { formatRuntime } from "../lib/format";
import { notifyActionError } from "../lib/actionErrors";
import { notify } from "../lib/notifications";
import { showEpisodeKeys } from "../lib/plexQueue";
import { healPlexSort, plexSortOptions } from "../lib/plexSort";
import { usePref } from "../lib/usePref";
import SortControl from "./ui/SortControl";
import { type SortState } from "../lib/columnSort";
import { useConfirm } from "./confirmContext";
import { BackBtn, ActionBtn, DetailHero } from "./plexDetailUi";
import { PlexPosterCard } from "./plexCards";
import { PlexMenu } from "./PlexMenu";
import { useContextMenu } from "./ui/useContextMenu";
import { usePlexCardActions } from "./usePlexCardActions";
import PlexLibraryPicker from "./PlexLibraryPicker";
import { toggleWatchState } from "../lib/plexWatch";
import { LoadingState, StateMessage } from "./ui/QueryState";
import NavGrid from "./ui/NavGrid";
import { Button } from "./ui/Button";
import { ClampedText } from "./ui/ClampedText";
import { IconButton } from "./ui/IconButton";
import { Input } from "./ui/Input";
import { useReturnFocus } from "./ui/useReturnFocus";
import { SURFACE } from "./ui/surfaces";
import { Badge } from "./ui/Badge";
import { PageHeading } from "./ui/Headings";
// The Collection info page (ask B): a hero (art/title/cert/year-range/count/runtime/summary) + the
// ordered member grid, a Play-all / Shuffle over the playable leaves, and — for a NON-external,
// admin-enabled collection — rename / delete / add / remove, all write-through to Plex (the mutation
// endpoints round-trip to Plex, so each edit awaits and re-fetches; no optimism). Read-only ones (smart
// / imdb / tmdb / … auto-lists) are marked with a lock.
// The order this page had before it grew a Sort control, and still its default: a franchise reads in
// release order. MUST match the endpoint's `DEFAULT_MEMBER_SORT` — the two only diverge if a request
// goes out without the params, which is why the control always sends them.
const COLLECTION_SORT_DEFAULT: NonNullable<SortState> = { key: "year", dir: "asc" };
function shuffled(a: string[]): string[] {
const r = [...a];
for (let i = r.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const tmp = r[i]!;
r[i] = r[j]!;
r[j] = tmp;
}
return r;
}
export default function PlexCollectionView({
collectionId,
onBack,
onPlay,
onOpenCard,
onRestartCard,
onInfoCard,
}: {
collectionId: string;
onBack: () => void;
onPlay: (rk: string, queue: string[]) => void;
onOpenCard: (card: PlexCard) => void;
onRestartCard: (card: PlexCard) => void;
onInfoCard: (card: PlexCard) => void;
}) {
const { t, i18n } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
// One menu for the member grid (see ui/ContextMenu).
const menu = useContextMenu<PlexCard>();
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
// The member order is a PREFERENCE (server-synced, adopted on login), not an ephemeral filter — the
// same treatment the feed's view and the player's auto-advance get. It is deliberately its OWN key
// rather than the browse grid's sort: opening a collection must not silently re-sort the library
// behind it.
const [sortPref, setSortPref] = usePref<NonNullable<SortState>>(
"plexCollectionSort",
COLLECTION_SORT_DEFAULT,
);
// ONE key for the read and every write into it: when the sort joined the key, the watched toggle's
// optimistic write kept the old spelling and landed on an entry nothing reads (setQueryData matches
// exactly), so the click did nothing visible until a reload.
const lang = plexLang(i18n.language);
const detailKey = qk.plexCollections("detail", collectionId, sortPref.key, sortPref.dir, lang);
const q = useQuery({
queryKey: detailKey,
queryFn: () => api.plexCollection(collectionId, sortPref, lang),
// A re-sort keeps the current page on screen while the new order loads. Without it every sort
// change was a key with no data yet, and the whole page — the Sort control and its open panel
// with it — was swapped for a spinner, so key and direction could not be set in one visit. Only
// within the SAME collection: stepping to another one must not show this one's hero meanwhile.
placeholderData: (prev, prevQuery) =>
prevQuery?.queryKey[2] === collectionId ? prev : undefined,
});
const d = q.data;
const [renaming, setRenaming] = useState(false);
const { target: renameTarget, arm: armRenameReturn } = useReturnFocus(renaming);
const [name, setName] = useState("");
const [adding, setAdding] = useState(false);
const [busy, setBusy] = useState(false);
// Building the play queue can await show → episode expansion (see buildQueue); disable Play/Shuffle
// meanwhile so a double-tap can't fire two queues.
const [queueBusy, setQueueBusy] = useState(false);
const invalidate = () => {
qc.invalidateQueries({ queryKey: qk.plexCollections() });
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
};
const items = d?.items ?? [];
// A collection lives in ONE library, so its members are all movies or all shows. That decides both
// what the add-panel searches and which sort keys are on offer (only a movie has a runtime).
const scope = items.some((c) => c.type === "show") ? "show" : "movie";
const sortState = healPlexSort(sortPref, scope);
const sortOptions = plexSortOptions(scope, (k) => t(`plex.sort.${k}`), lang);
// Movies/episodes are playable leaves that queue as themselves; a show can't queue directly, so
// Play-all expands it into its episodes (buildQueue). Either kind means there's something to play —
// this is what a show-collection needs: before, `playable` held only leaves, so a show-collection's
// Play-all was empty and did nothing.
const leafKeys = items.filter((c) => c.type !== "show").map((c) => c.id);
const hasShows = items.some((c) => c.type === "show");
const canPlay = leafKeys.length > 0 || hasShows;
// Build the ordered play queue: leaf members as themselves, each show member expanded into its
// episodes (season then episode order). Shows are fetched lazily HERE (on Play-all/Shuffle click),
// in parallel, but concatenated in member order so the queue follows the grid — not on page load,
// which would fetch every show up front for a button that may never be pressed.
async function buildQueue(): Promise<string[]> {
const perMember = await Promise.all(
items.map(async (c) => {
if (c.type !== "show") return [c.id];
const detail = await qc.fetchQuery({
queryKey: qk.plexShow(c.id, lang),
queryFn: () => api.plexShow(c.id, lang),
});
return showEpisodeKeys(detail);
}),
);
return perMember.flat();
}
async function playAll(shuffle: boolean) {
setQueueBusy(true);
try {
let queue = await buildQueue();
if (shuffle) queue = shuffled(queue);
// canPlay gates on "has a show member", but a show can expand to zero episodes (unmatched /
// empty). An enabled Play-all that silently no-ops is a dishonest state, so say so plainly.
if (queue.length > 0) onPlay(queue[0]!, queue);
else notify({ level: "info", message: t("plex.collection.playEmpty") });
} catch (e) {
notifyActionError(e, t("plex.collection.playFailed"));
}
setQueueBusy(false);
}
const canEdit = isAdmin && !!d?.can_edit;
// A plain (non-smart, non-external) collection can be admin-enabled for editing. Shares
// isIntrinsicReadOnly with the browse tile so the lock/edit affordance can't drift between them.
const canEnableEdit = isAdmin && !d?.can_edit && !d?.editable && !!d && !isIntrinsicReadOnly(d);
const readOnly = !d?.can_edit && !canEnableEdit; // external / smart auto-list
const toggleWatched = (card: PlexCard) =>
toggleWatchState(card, t, {
optimistic: (next) => {
qc.setQueryData<PlexCollectionDetail>(detailKey, (old) =>
old
? {
...old,
items: old.items.map((c) => (c.id === card.id ? { ...c, status: next } : c)),
}
: old,
);
},
// This page's OWN key too, not just the library's: the flip above lands on the entry the page
// reads, and resync is what corrects it to the truth when the toggle fails — invalidating only
// `plex-library` left a failed toggle flipped on screen. The prefix (no sort in it) also
// refreshes the other sort orders of this collection cached behind this one.
resync: () => {
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
qc.invalidateQueries({ queryKey: qk.plexCollections("detail", collectionId) });
},
});
const actions = usePlexCardActions(
{
onOpen: onOpenCard,
onRestart: onRestartCard,
onInfo: onInfoCard,
onToggleWatched: toggleWatched,
},
menu.open,
);
async function doRename() {
const title = name.trim();
if (!title || !d || title === d.title) {
setRenaming(false);
return;
}
setBusy(true);
try {
await api.plexRenameCollection(collectionId, title);
invalidate();
setRenaming(false);
} catch (e) {
// The backend enforces per-library name uniqueness — a taken name comes back localized.
notifyActionError(e, t("plex.collection.editFailed"));
}
setBusy(false);
}
async function doDelete() {
if (!d) return;
if (!(await confirm({ message: t("plex.collection.deleteConfirm", { title: d.title }) })))
return;
setBusy(true);
try {
await api.plexDeleteCollection(collectionId);
invalidate();
onBack();
} catch (e) {
notifyActionError(e, t("plex.collection.editFailed"));
setBusy(false);
}
}
async function doRemove(card: PlexCard) {
// Write-through to the shared Plex library — confirm first, matching the delete-collection guard.
if (!(await confirm({ message: t("plex.collection.removeConfirm", { title: card.title }) })))
return;
setBusy(true);
try {
await api.plexCollectionRemoveItem(collectionId, card.id);
invalidate();
} catch (e) {
notifyActionError(e, t("plex.collection.editFailed"));
}
setBusy(false);
}
async function enableEdit() {
setBusy(true);
try {
await api.plexSetCollectionEditable(collectionId, true);
invalidate();
} catch (e) {
notifyActionError(e, t("plex.collection.editFailed"));
}
setBusy(false);
}
const meta = [
d?.year_min != null &&
(d.year_max && d.year_max !== d.year_min ? `${d.year_min} — ${d.year_max}` : `${d.year_min}`),
d?.content_rating && vocab(t, "certificate", d.content_rating),
d && t("plex.browse.items", { count: d.child_count ?? items.length }),
formatRuntime(d?.total_runtime_s, lang),
].filter(Boolean) as string[];
return (
<div className="glass-media mx-auto max-w-300 p-4">
<BackBtn onBack={onBack} label={t("plex.backToLibrary")} />
{q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading || !d ? (
<LoadingState shape="poster" label={t("plex.loading")} />
) : (
<>
<DetailHero
thumb={d.thumb ?? `/api/plex/image/${d.id}`}
posterTitle={t("plex.play")}
posterDisabled={!canPlay || queueBusy}
showPlayOverlay={canPlay}
onPosterClick={() => canPlay && !queueBusy && playAll(false)}
// Sized to the text beside it (the owner's pick): a title, one meta line and a short
// description sat beside a show-sized poster, two thirds of the hero empty under them.
posterSizeClass="w-20 sm:w-24"
customizeMenu={null}
>
{renaming ? (
<Input
className="w-full text-2xl font-semibold"
size="heading"
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onBlur={doRename}
onKeyDown={(e) => {
if (e.key === "Enter") {
armRenameReturn(e);
void doRename(); // reports its own failure
}
if (e.key === "Escape") {
armRenameReturn(e);
setRenaming(false);
}
}}
aria-label={t("plex.collection.rename")}
/>
) : (
<div className="flex items-center gap-2">
<PageHeading className="text-2xl font-semibold leading-tight">
{d.title}
</PageHeading>
{readOnly && (
<Badge title={t("plex.collection.readOnly")}>
<Lock />
{t("plex.collection.readOnly")}
</Badge>
)}
</div>
)}
{meta.length > 0 && (
<div className="mt-1 flex flex-wrap items-center gap-x-2 text-sm text-muted">
{meta.map((m, i) => (
<span key={i}>
{i > 0 && "· "}
{m}
</span>
))}
</div>
)}
{d.summary && (
<ClampedText
text={d.summary}
lines={3}
moreLabel={t("plex.textMore")}
lessLabel={t("plex.textLess")}
className="mt-3"
/>
)}
<div className="mt-4 flex flex-wrap gap-2">
{canPlay && (
<>
<ActionBtn
primary
onClick={() => playAll(false)}
disabled={queueBusy}
icon={Play}
label={t("plex.collection.playAll")}
/>
<ActionBtn
onClick={() => playAll(true)}
disabled={queueBusy}
icon={Shuffle}
label={t("plex.collection.shuffle")}
/>
</>
)}
{canEdit && (
<>
<ActionBtn
ref={renameTarget}
onClick={() => {
setName(d.title);
setRenaming(true);
}}
icon={Pencil}
label={t("plex.collection.rename")}
/>
<ActionBtn
onClick={() => setAdding((v) => !v)}
icon={Plus}
label={t("plex.collection.addItem")}
/>
<ActionBtn
onClick={doDelete}
icon={Trash2}
label={t("plex.collection.delete")}
disabled={busy}
/>
</>
)}
{canEnableEdit && (
<ActionBtn
onClick={enableEdit}
icon={Check}
label={t("plex.collection.enableEdit")}
/>
)}
</div>
</DetailHero>
{adding && canEdit && (
<AddItemPanel
collectionId={collectionId}
scope={scope}
existing={new Set(items.map((c) => c.id))}
onAdded={invalidate}
onClose={() => setAdding(false)}
/>
)}
{items.length === 0 ? (
<p className="text-muted text-sm">{t("plex.collection.empty")}</p>
) : (
<>
{/* Sort sits directly above the grid, pinned right — the same placement (and the same
control, options and labels) as the browse grid, so the two read as one idea. */}
<div className="mb-2 flex items-center">
<div className="ml-auto flex shrink-0 items-center gap-1.5">
<SortControl
options={sortOptions}
sort={sortState}
onChange={(next) => setSortPref(next ?? COLLECTION_SORT_DEFAULT)}
label={t("plex.filter.sort")}
ascLabel={t("plex.filter.dir.asc")}
descLabel={t("plex.filter.dir.desc")}
/>
</div>
</div>
{/* Two columns on a phone, like every other poster grid (≥140px a card): three left
an ~85px card whose title shared its line with the touch "…". */}
<NavGrid className="grid grid-cols-2 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6">
{items.map((c, i) => (
<PlexPosterCard
key={c.id}
card={c}
navIndex={i}
onOpen={() => onOpenCard(c)}
onPage={onInfoCard}
onRestart={() => onRestartCard(c)}
onToggleWatched={toggleWatched}
onAdd={actions.add}
menuProps={menu.triggerProps}
menuButtonProps={menu.buttonProps}
menuOpen={menu.open && menu.target === c}
/>
))}
</NavGrid>
</>
)}
</>
)}
<PlexMenu
menu={menu}
actions={actions}
extra={(c) =>
canEdit
? [
{
// A write to the shared Plex library: a menu row (the card's hover
// row owns its corner now), still behind the confirmation in `doRemove`.
id: "collection-remove",
label: t("plex.collection.removeItem"),
icon: <X className="w-4 h-4" />,
separatorBefore: true,
danger: true,
disabled: busy,
onSelect: () => void doRemove(c),
},
]
: []
}
/>
</div>
);
}
// A compact search-and-add panel: search the collection's library, click a result to add it (write-
// through to Plex). Stays open so several items can be added in a row; already-present items are marked.
function AddItemPanel({
collectionId,
scope,
existing,
onAdded,
onClose,
}: {
collectionId: string;
scope: string;
existing: Set<string>;
onAdded: () => void;
onClose: () => void;
}) {
const { t } = useTranslation();
const [term, setTerm] = useState("");
const [added, setAdded] = useState<Set<string>>(new Set());
async function add(card: PlexCard) {
try {
await api.plexCollectionAddItem(collectionId, card.id);
setAdded((s) => new Set(s).add(card.id));
onAdded();
} catch (e) {
notifyActionError(e, t("plex.collection.editFailed"));
}
}
return (
<div className={`${SURFACE.panel} mb-6 p-4`}>
<div className="mb-3 flex items-center gap-2">
<Search className="h-4 w-4 text-muted" />
<Input
className="flex-1"
autoFocus
value={term}
onChange={(e) => setTerm(e.target.value)}
placeholder={t("plex.collection.addSearch")}
aria-label={t("plex.collection.addSearch")}
/>
<IconButton size="md" onClick={onClose} label={t("common.close")}>
<X />
</IconButton>
</div>
<PlexLibraryPicker
scope={scope}
term={term}
emptyHint={<p className="text-muted text-sm">{t("plex.collection.addHint")}</p>}
renderAction={(c) => {
const inColl = existing.has(c.id) || added.has(c.id);
return (
<Button
variant="glass"
size="xs"
icon={inColl ? <Check /> : <Plus />}
onClick={() => add(c)}
disabled={inColl}
>
{inColl ? t("plex.collection.added") : t("plex.collection.add")}
</Button>
);
}}
/>
</div>
);
}