224 lines
7.8 KiB
TypeScript
224 lines
7.8 KiB
TypeScript
|
|
import { useMemo, useState } from "react";
|
||
|
|
import { useTranslation } from "react-i18next";
|
||
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||
|
|
import { Check, Plus } from "lucide-react";
|
||
|
|
import { api } from "../lib/api";
|
||
|
|
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 [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: ["plex-collections", 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: ["plex-collections"] });
|
||
|
|
qc.invalidateQueries({ queryKey: ["plex-item", item.id] });
|
||
|
|
qc.invalidateQueries({ queryKey: ["plex-browse"] });
|
||
|
|
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();
|
||
|
|
} 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 outline-none focus:border-accent"
|
||
|
|
/>
|
||
|
|
<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 outline-none focus:border-accent"
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Editable collections with in/out toggle */}
|
||
|
|
<div className="max-h-80 space-y-1 overflow-y-auto">
|
||
|
|
{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 px-1.5 py-0.5 text-xs text-muted hover:text-red-400 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 className="mt-2 max-h-40 space-y-1 overflow-y-auto">
|
||
|
|
{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 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>
|
||
|
|
);
|
||
|
|
}
|