Files
siftlode/frontend/src/components/PlexCollectionView.tsx
T
peter 2d1a7ff374 fix(plex): complete the 409->nameTaken sweep + cover PlexLibraryPicker
The notifyCollectionEditError helper localizes the backend's duplicate-name
409 to plex.collection.nameTaken; it was applied to the New-collection panel
but two sibling collection-write sites still routed 409 through
notifyActionError, surfacing the server's raw English detail under the HU UI:

- PlexCollectionView.doRename (rename collision)
- PlexCollectionEditor.createNew (create-in-editor duplicate name)

Both now use notifyCollectionEditError, matching the create path. Also adds a
direct dom test for the extracted PlexLibraryPicker covering the enabled gate,
emptyHint, results, honest error, and no-results branches.
2026-08-23 18:39:22 +02:00

399 lines
14 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 { useConfirm } from "./ConfirmProvider";
import { BackBtn, ActionBtn, DetailHero } from "../lib/plexDetailUi";
import { PlexPosterCard } from "./plexCards";
import PlexLibraryPicker from "./PlexLibraryPicker";
import { toggleWatchState } from "../lib/plexWatch";
import { LoadingState, StateMessage } from "./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.
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();
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
const q = useQuery({
queryKey: qk.plexCollections("detail", collectionId),
queryFn: () => api.plexCollection(collectionId),
});
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);
const invalidate = () => {
qc.invalidateQueries({ queryKey: qk.plexCollections() });
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
};
const items = d?.items ?? [];
// The Play queue is the playable-leaf members (movies/episodes) in order — a show can't queue directly.
const playable = items.filter((c) => c.type !== "show").map((c) => c.id);
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>(qk.plexCollections("detail", collectionId), (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={playable.length === 0}
showPlayOverlay={playable.length > 0}
onPosterClick={() => playable.length > 0 && onPlay(playable[0]!, playable)}
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">
{playable.length > 0 && (
<>
<ActionBtn
primary
onClick={() => onPlay(playable[0]!, playable)}
icon={Play}
label={t("plex.collection.playAll")}
/>
<ActionBtn
onClick={() => {
const order = shuffled(playable);
onPlay(order[0]!, order);
}}
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={items.some((c) => c.type === "show") ? "show" : "movie"}
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>
) : (
<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}
/>
{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>
)}
</>
)}
</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>
);
}