Files
siftlode/frontend/src/components/PlexCollectionEditor.tsx
T
peter 5ca2a0e6ee fix(gate): the plan-code guard catches bare sprint and phase codes, and the purge takes the P ones
The sprint alternative matched only `(S6)` or `S3e2`, so the commonest purged form (`# S6:`,
`S3's`, `"""S6 —`, `S1 justified`) and every phase code (`P1`) would come back unflagged; the phase
codes had in fact survived the purge in 16 lines (Plex client sections, the upload's header shape),
now reworded around them.

One pattern now covers epic, roadmap, sprint and phase codes; the named exceptions are TV episode
notation, a fixture's season or episode title (`title="S1"`) and ruff's `E4/E7/E9`, and a ruff rule
like `S110` does not match. The case test lists the purged forms and no longer claims every form.
audit_metrics copies the definition (ASCII, as JavaScript's \b is) and reports it as
plan_code_lines / plan_code_files.
2026-10-05 00:50:36 +02:00

264 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. 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 { ref: editableFadeRef, style: editableFadeStyle } = useScrollFade();
const { ref: othersFadeRef, style: othersFadeStyle } = 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.
notifyActionError(e, t("plex.collection.editFailed"));
}
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"));
}
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"));
}
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"));
}
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={editableFadeRef}
style={editableFadeStyle}
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={othersFadeRef}
style={othersFadeStyle}
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>
);
}