Files
siftlode/frontend/src/components/PlexCollectionEditor.tsx
T
peter 47e2293de3 feat(e10): migrate the notification, message, auth and Plex action buttons
Second sweep, 34 more sites: NotificationsPanel (14 -- eight identical
accent text links, three ghost icon buttons, two ghost text actions),
Welcome's five auth links, KeyGate, Messages, NavSidebar, ChatThread,
ShareDialog, ChannelPage, PlexPlaylistView, PlexPlaylistAdd,
PlexCollectionEditor and plexViews.

Moves the font-weight out of Button's BASE and into each variant, so the
`link` variant can be semibold (which is what every text link in this app
looks like) while the action variants stay medium, and no element can end
up with two font-* utilities.

Welcome is the one signed-out surface this session cannot see, so only
its byte-identical link swaps were taken; its primary submit and the
bg-card button family are left for a pass that can UAT the screen.

3x tsc, eslint, knip, prettier, 689 vitest green; UAT on Notifications,
Messages and the nav rail, clean console in a fresh tab.
2026-09-05 18:39:22 +02:00

251 lines
8.6 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 { notifyCollectionEditError } from "../lib/actionErrors";
import { useScrollFade } from "../lib/useScrollFade";
import { Button } from "./ui/Button";
import Modal from "./ui/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) {
// The backend rejects a duplicate name with 409 (Plex conflates same names) — localize it to
// nameTaken rather than surfacing the server's raw English detail.
notifyCollectionEditError(e, t("plex.collection.editFailed"), t("plex.collection.nameTaken"));
} 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
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
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>
);
}