Deep-review R3. When 889d2acf put the sort into the collection detail's query key, two things on
the page did not follow it:
- The watched toggle's optimistic write kept the key's old spelling. setQueryData matches exactly,
so it landed on an entry nothing reads, and its resync only invalidates the library root — the
click succeeded server-side and changed nothing on screen (badge, icon, aria-label) until a
reload; a second click toggled it back. One `detailKey` now serves the read and the write.
- Every sort change was a key with no data yet, so the whole page — hero, grid, and the Sort control
with its open panel — was swapped for a spinner, and focus fell to <body>: key and direction could
not be set in one visit. The previous page now stays on screen while the new order loads, for the
SAME collection only (stepping to another must not show this one's hero meanwhile).
Verified on localdev (Rocky Collection): the toggle flips the poster; IMDb rating then Descending set
in one open panel, the hero never blanked, order reversed. 3 mutations, each red on its test.
497 lines
19 KiB
TypeScript
497 lines
19 KiB
TypeScript
import { useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
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 { api, isIntrinsicReadOnly, type PlexCard, type PlexCollectionDetail } from "../lib/api";
|
|
import { formatRuntime } from "../lib/format";
|
|
import { notifyActionError, notifyCollectionEditError } 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 "./ConfirmProvider";
|
|
import { BackBtn, ActionBtn, DetailHero } from "../lib/plexDetailUi";
|
|
import { PlexPosterCard } from "./plexCards";
|
|
import { PlexPinMenu } from "./ComfortPinMenu";
|
|
import { useContextMenu } from "./ui/useContextMenu";
|
|
import PlexLibraryPicker from "./PlexLibraryPicker";
|
|
import { toggleWatchState } from "../lib/plexWatch";
|
|
import { LoadingState, StateMessage } from "./ui/QueryState";
|
|
|
|
// 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,
|
|
onInfoCard,
|
|
}: {
|
|
collectionId: string;
|
|
onBack: () => void;
|
|
onPlay: (rk: string, queue: string[]) => void;
|
|
onOpenCard: (card: PlexCard) => void;
|
|
onInfoCard: (card: PlexCard) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const confirm = useConfirm();
|
|
// One comfort-pin menu for the member grid (see ui/ContextMenu).
|
|
const pinMenu = 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 detailKey = qk.plexCollections("detail", collectionId, sortPref.key, sortPref.dir);
|
|
const q = useQuery({
|
|
queryKey: detailKey,
|
|
queryFn: () => api.plexCollection(collectionId, sortPref),
|
|
// 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 [expanded, setExpanded] = useState(false);
|
|
const [renaming, setRenaming] = useState(false);
|
|
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}`));
|
|
// 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),
|
|
queryFn: () => api.plexShow(c.id),
|
|
});
|
|
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"));
|
|
} finally {
|
|
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,
|
|
);
|
|
},
|
|
resync: () => qc.invalidateQueries({ queryKey: qk.plexLibrary() }),
|
|
});
|
|
|
|
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 — localize its 409 to nameTaken.
|
|
notifyCollectionEditError(e, t("plex.collection.editFailed"), t("plex.collection.nameTaken"));
|
|
} finally {
|
|
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"));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function enableEdit() {
|
|
setBusy(true);
|
|
try {
|
|
await api.plexSetCollectionEditable(collectionId, true);
|
|
invalidate();
|
|
} catch (e) {
|
|
notifyActionError(e, t("plex.collection.editFailed"));
|
|
} finally {
|
|
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,
|
|
d && t("plex.browse.items", { count: d.child_count ?? items.length }),
|
|
formatRuntime(d?.total_runtime_s),
|
|
].filter(Boolean) as string[];
|
|
|
|
return (
|
|
<div className="glass-media mx-auto max-w-[1200px] 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 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)}
|
|
customizeMenu={null}
|
|
>
|
|
{renaming ? (
|
|
<input
|
|
autoFocus
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
onBlur={doRename}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") doRename();
|
|
if (e.key === "Escape") setRenaming(false);
|
|
}}
|
|
aria-label={t("plex.collection.rename")}
|
|
className="glass-card w-full rounded-lg px-2 py-1 text-2xl font-semibold focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
|
/>
|
|
) : (
|
|
<div className="flex items-center gap-2">
|
|
<h1 className="text-2xl font-semibold leading-tight">{d.title}</h1>
|
|
{readOnly && (
|
|
<span
|
|
className="inline-flex items-center gap-1 rounded-full bg-surface px-2 py-0.5 text-2xs text-muted"
|
|
title={t("plex.collection.readOnly")}
|
|
>
|
|
<Lock className="h-3 w-3" />
|
|
{t("plex.collection.readOnly")}
|
|
</span>
|
|
)}
|
|
</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 && (
|
|
<div className="mt-3">
|
|
<p
|
|
className={`text-sm leading-relaxed text-muted ${expanded ? "" : "line-clamp-3"}`}
|
|
>
|
|
{d.summary}
|
|
</p>
|
|
<button
|
|
onClick={() => setExpanded((v) => !v)}
|
|
className="mt-1 text-xs font-medium text-accent hover:underline"
|
|
>
|
|
{expanded ? t("plex.collection.less") : t("plex.collection.more")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<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
|
|
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>
|
|
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6">
|
|
{items.map((c) => (
|
|
<div key={c.id} className="relative">
|
|
<PlexPosterCard
|
|
card={c}
|
|
onOpen={() => onOpenCard(c)}
|
|
onInfo={() => onInfoCard(c)}
|
|
onToggleWatched={toggleWatched}
|
|
menuProps={pinMenu.triggerProps}
|
|
/>
|
|
{canEdit && (
|
|
<button
|
|
onClick={() => doRemove(c)}
|
|
disabled={busy}
|
|
title={t("plex.collection.removeItem")}
|
|
aria-label={t("plex.collection.removeItem")}
|
|
className="glass-card glass-hover absolute right-1.5 top-1.5 z-base grid h-6 w-6 place-items-center rounded-full text-fg disabled:opacity-50"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
<PlexPinMenu menu={pinMenu} />
|
|
</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="glass-card mb-6 rounded-2xl p-4">
|
|
<div className="mb-3 flex items-center gap-2">
|
|
<Search className="h-4 w-4 text-muted" />
|
|
<input
|
|
autoFocus
|
|
value={term}
|
|
onChange={(e) => setTerm(e.target.value)}
|
|
placeholder={t("plex.collection.addSearch")}
|
|
aria-label={t("plex.collection.addSearch")}
|
|
className="glass-card flex-1 rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
|
/>
|
|
<button
|
|
onClick={onClose}
|
|
aria-label={t("common.close")}
|
|
className="glass-hover grid h-8 w-8 place-items-center rounded-lg"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</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
|
|
onClick={() => add(c)}
|
|
disabled={inColl}
|
|
className="glass-card glass-hover inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs disabled:opacity-50"
|
|
>
|
|
{inColl ? <Check className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
|
{inColl ? t("plex.collection.added") : t("plex.collection.add")}
|
|
</button>
|
|
);
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|