Files
siftlode/frontend/src/components/PlexCollectionEditor.tsx
T
peter b71b33654a feat(plex): forbid duplicate collection names + a "New collection" button
Two follow-ups after testing the collection editing against real Plex, which revealed that
Plex CONFLATES same-named collections (two rating_keys resolve to one merged member list) —
a genuine footgun.

- Duplicate-name guard: create and rename now reject (409) a title that already exists in the
  target section (case-insensitive), before any Plex write. create() can also derive the
  library from the seed item, so the caller needn't know the section key. The create dialogs
  surface the 409, and the New-collection panel warns inline before submit.
- A "New collection" button on the Collections browse surface (admin only) opens a panel:
  a name field (with the live duplicate check) + a search to pick the FIRST title — Plex can't
  create an empty collection, it needs a seed. Creating opens the new collection's detail page
  straight away, so you can keep adding titles there. This is the B-route the user wanted,
  vs. the only prior path (a movie's "add to collection").

Verified live: the 409 guard (creating an existing name is blocked, no Plex write), and the
full New-collection flow (name + first title → create → lands on the detail page); test
collections created and deleted, real account left clean.

Gate: tsc/lint/knip/prettier clean, 424 vitest; backend ruff clean, 7 collections tests
(+ a duplicate-name-rejection assertion).
2026-08-23 05:51:48 +02:00

247 lines
8.5 KiB
TypeScript

import { useMemo, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Plus } from "lucide-react";
import { api } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { useScrollFade } from "../lib/useScrollFade";
import Modal from "./Modal";
import { useConfirm } from "./ConfirmProvider";
// Admin-only "manage this movie's collections" dialog (Collections P2). Lists the library's EDITABLE
// collections with an in/out toggle (add/remove the movie, written back to Plex), plus a field to
// create a new collection seeded with this movie. Read-only collections (smart / IMDb-TMDb auto-lists)
// never appear here. Opened from the movie info page.
export default function PlexCollectionEditor({
item,
library,
memberOf,
onClose,
onChanged,
}: {
item: { id: string; title: string };
library: string;
memberOf: string[]; // collection ids this movie is already in
onClose: () => void;
onChanged: () => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const editableFade = useScrollFade();
const othersFade = useScrollFade();
const [members, setMembers] = useState<Set<string>>(() => new Set(memberOf));
const [q, setQ] = useState("");
const [newName, setNewName] = useState("");
const [busy, setBusy] = useState<string | null>(null); // collection id currently mutating
const listQ = useQuery({
queryKey: qk.plexCollections(library),
queryFn: () => api.plexCollections(library),
});
const editable = useMemo(
() => (listQ.data?.collections ?? []).filter((c) => c.can_edit),
[listQ.data],
);
// Existing Plex collections you could take over (plain, non-smart, non-auto) but haven't marked yet.
const eligible = useMemo(
() =>
(listQ.data?.collections ?? []).filter(
(c) => !c.can_edit && !c.smart && c.source === "collection",
),
[listQ.data],
);
const term = q.trim().toLowerCase();
const shown = term ? editable.filter((c) => c.title.toLowerCase().includes(term)) : editable;
const refresh = () => {
qc.invalidateQueries({ queryKey: qk.plexCollections() });
qc.invalidateQueries({ queryKey: qk.plexItem(item.id) });
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
onChanged();
};
async function toggle(id: string) {
if (busy) return;
setBusy(id);
const inIt = members.has(id);
try {
if (inIt) await api.plexCollectionRemoveItem(id, item.id);
else await api.plexCollectionAddItem(id, item.id);
setMembers((prev) => {
const next = new Set(prev);
if (inIt) next.delete(id);
else next.add(id);
return next;
});
refresh();
} finally {
setBusy(null);
}
}
async function markEditable(id: string) {
if (busy) return;
setBusy(id);
try {
await api.plexSetCollectionEditable(id, true);
refresh();
} finally {
setBusy(null);
}
}
async function createNew() {
const title = newName.trim();
if (!title || busy) return;
setBusy("__new__");
try {
const col = await api.plexCreateCollection(library, title, item.id);
setNewName("");
setMembers((prev) => new Set(prev).add(col.id));
refresh();
} catch (e) {
// Surfaces the 409 when the name duplicates an existing collection (Plex conflates same names).
notifyActionError(e, t("plex.collection.editFailed"));
} finally {
setBusy(null);
}
}
async function removeCollection(id: string, title: string) {
if (!(await confirm({ message: t("plex.collEditor.deleteConfirm", { title }), danger: true })))
return;
setBusy(id);
try {
await api.plexDeleteCollection(id);
setMembers((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
refresh();
} finally {
setBusy(null);
}
}
return (
<Modal title={t("plex.collEditor.title", { title: item.title })} onClose={onClose}>
{/* Create new */}
<div className="mb-3 flex gap-2">
<input
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && createNew()}
placeholder={t("plex.collEditor.newPlaceholder")}
className="glass-card min-w-0 flex-1 rounded-lg px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<button
onClick={createNew}
disabled={!newName.trim() || !!busy}
className="glass-card glass-hover inline-flex shrink-0 items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-50"
>
<Plus className="h-4 w-4" />
{t("plex.collEditor.create")}
</button>
</div>
{/* Filter */}
{editable.length > 6 && (
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={t("plex.collEditor.search")}
className="glass-card mb-2 w-full rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
)}
{/* Editable collections with in/out toggle */}
<div
ref={editableFade.ref}
style={editableFade.style}
className="max-h-80 space-y-1 overflow-y-auto no-scrollbar"
>
{listQ.isLoading ? (
<p className="py-4 text-center text-sm text-muted">{t("plex.loading")}</p>
) : shown.length === 0 ? (
<p className="py-4 text-center text-sm text-muted">{t("plex.collEditor.none")}</p>
) : (
shown.map((c) => {
const inIt = members.has(c.id);
return (
<div
key={c.id}
className="glass-card flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm"
>
<button
onClick={() => toggle(c.id)}
disabled={!!busy}
className={`inline-flex min-w-0 flex-1 items-center gap-2 text-left disabled:opacity-50 ${
inIt ? "text-accent" : ""
}`}
>
<span
className={`grid h-5 w-5 shrink-0 place-items-center rounded border ${
inIt ? "border-accent bg-accent/20" : "border-border"
}`}
>
{inIt && <Check className="h-3.5 w-3.5" />}
</span>
<span className="truncate">{c.title}</span>
<span className="shrink-0 text-xs text-muted">
{t("plex.collEditor.count", { count: c.child_count ?? 0 })}
</span>
</button>
<button
onClick={() => removeCollection(c.id, c.title)}
disabled={!!busy}
className="shrink-0 rounded-sm px-1.5 py-0.5 text-xs text-muted hover:text-danger disabled:opacity-50"
>
{t("plex.collEditor.delete")}
</button>
</div>
);
})
)}
</div>
{/* Take over an existing plain Plex collection (mark it "mine to edit"). */}
{eligible.length > 0 && (
<details className="mt-3">
<summary className="cursor-pointer text-xs text-muted hover:text-fg">
{t("plex.collEditor.others", { count: eligible.length })}
</summary>
<div
ref={othersFade.ref}
style={othersFade.style}
className="mt-2 max-h-40 space-y-1 overflow-y-auto no-scrollbar"
>
{eligible.map((c) => (
<div
key={c.id}
className="glass-card flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm"
>
<span className="min-w-0 flex-1 truncate">{c.title}</span>
<span className="shrink-0 text-xs text-muted">
{t("plex.collEditor.count", { count: c.child_count ?? 0 })}
</span>
<button
onClick={() => markEditable(c.id)}
disabled={!!busy}
className="glass-card glass-hover shrink-0 rounded-sm px-2 py-0.5 text-xs disabled:opacity-50"
>
{t("plex.collEditor.makeEditable")}
</button>
</div>
))}
</div>
</details>
)}
<p className="mt-3 text-xs text-muted">{t("plex.collEditor.note")}</p>
</Modal>
);
}