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).
247 lines
8.5 KiB
TypeScript
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>
|
|
);
|
|
}
|