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.
264 lines
8.9 KiB
TypeScript
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>
|
|
);
|
|
}
|