Files
siftlode/frontend/src/components/PlexCollectionEditor.tsx
T
peter 3ef2c38eb4 feat(design): dialogs open by purpose and keep their commit row pinned (E28 S3f1)
Modal takes a required initialFocus (field / close / action), a field that
loads late takes the focus while it still rests on the close button, and
ModalFooter portals a dialog's commit row under the scrolling body from any
depth. The header and footer fades follow the body's scroll timeline, and the
viewport's interactive-widget=resizes-content keeps the footer above an
on-screen keyboard.
2026-09-27 01:33:37 +02:00

268 lines
8.9 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 { Plus } from "lucide-react";
import { MenuCheck } from "./ui/MenuCheck";
import { api } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { useScrollFade } from "../lib/useScrollFade";
import { Button } from "./ui/Button";
import Modal from "./ui/Modal";
import { useConfirm } from "./confirmContext";
import { Pressable } from "./ui/Pressable";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
// 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();
} catch (e) {
// 403/404 are left to the caller by `req` (see reportFailure), so without this the tick
// simply never appeared and the user clicked again — a collection deleted on the Plex server
// or flipped read-only failed in silence (deep-review round 5).
notifyActionError(e, t("plex.collection.editFailed"));
} finally {
setBusy(null);
}
}
async function markEditable(id: string) {
if (busy) return;
setBusy(id);
try {
await api.plexSetCollectionEditable(id, true);
refresh();
} catch (e) {
notifyActionError(e, t("plex.collection.editFailed"));
} 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) {
// The backend rejects a duplicate name with 409 (Plex conflates same names), structured — so
// it reads in the reader's language (see localizeDetail).
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();
} catch (e) {
// The worst of the three to lose: the user has just confirmed a destructive dialog, and the
// row stayed where it was with nothing said.
notifyActionError(e, t("plex.collection.deleteFailed"));
} finally {
setBusy(null);
}
}
return (
<Modal
initialFocus="field"
title={t("plex.collEditor.title", { title: item.title })}
onClose={onClose}
>
{/* Create new */}
<div className="mb-3 flex gap-2">
<Input
className="flex-1"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && createNew()}
placeholder={t("plex.collEditor.newPlaceholder")}
/>
<Button
variant="glass"
size="md"
className="shrink-0"
onClick={createNew}
disabled={!newName.trim() || !!busy}
>
<Plus className="h-4 w-4" />
{t("plex.collEditor.create")}
</Button>
</div>
{/* Filter */}
{editable.length > 6 && (
<Input
className="mb-2 w-full"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={t("plex.collEditor.search")}
/>
)}
{/* 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={`${SURFACE.card} flex items-center gap-2 px-2 py-1.5 text-sm`}
>
<Pressable
role="checkbox"
aria-checked={inIt}
onClick={() => toggle(c.id)}
disabled={!!busy}
className={`inline-flex min-w-0 flex-1 items-center gap-2 disabled:opacity-50 ${
inIt ? "text-accent" : ""
}`}
>
<MenuCheck kind="many" on={inIt} />
<span className="truncate">{c.title}</span>
<span className="shrink-0 text-xs text-muted">
{t("plex.collEditor.count", { count: c.child_count ?? 0 })}
</span>
</Pressable>
<Button
variant="outline-danger"
size="xs"
onClick={() => removeCollection(c.id, c.title)}
disabled={!!busy}
>
{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={`${SURFACE.card} flex items-center gap-2 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
variant="glass"
size="xs"
onClick={() => markEditable(c.id)}
disabled={!!busy}
>
{t("plex.collEditor.makeEditable")}
</Button>
</div>
))}
</div>
</details>
)}
<p className="mt-3 text-xs text-muted">{t("plex.collEditor.note")}</p>
</Modal>
);
}