diff --git a/apps/admin-ui/src/app/pending/page.tsx b/apps/admin-ui/src/app/pending/page.tsx new file mode 100644 index 0000000..167c332 --- /dev/null +++ b/apps/admin-ui/src/app/pending/page.tsx @@ -0,0 +1,318 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Nav, useAuth } from "@/components/Nav"; + +interface PendingFolder { + key: string; + nodeId: string; + nodeName: string; + folder: string; + parsedTitle: string | null; + fileCount: number; + seasons: number[]; + samplePaths: string[]; +} + +interface MetaHit { + tmdbId: number; + title: string; + year: number | null; + overview: string | null; + posterUrl: string | null; + imdbId: string | null; +} + +export default function PendingSeriesPage() { + useAuth(); + const [pending, setPending] = useState([]); + const [totalFiles, setTotalFiles] = useState(0); + const [msg, setMsg] = useState(""); + const [link, setLink] = useState(null); + const [query, setQuery] = useState(""); + const [hits, setHits] = useState([]); + const [busy, setBusy] = useState(false); + const [linkMsg, setLinkMsg] = useState(""); + + function load() { + fetch("/api/v1/admin/library/pending-series", { credentials: "include" }) + .then((r) => r.json()) + .then((d) => { + setPending(d.pending ?? []); + setTotalFiles(d.totalFiles ?? 0); + }); + } + + useEffect(() => { + load(); + }, []); + + function openLink(row: PendingFolder) { + setLink(row); + setQuery(row.parsedTitle || row.folder.split("/").filter(Boolean).pop() || ""); + setHits([]); + setLinkMsg(""); + void searchMeta(row.parsedTitle || row.folder.split("/").filter(Boolean).pop() || ""); + } + + async function searchMeta(q = query) { + const term = q.trim(); + if (!term) return; + setBusy(true); + setLinkMsg(""); + try { + const res = await fetch( + `/api/v1/admin/metadata/search?type=series&q=${encodeURIComponent(term)}`, + { credentials: "include" } + ); + const data = await res.json(); + if (!res.ok) { + setLinkMsg(data.error?.message ?? "Zoeken mislukt"); + setHits([]); + return; + } + setHits(data.results ?? []); + if (!(data.results ?? []).length) setLinkMsg("Geen resultaten"); + } finally { + setBusy(false); + } + } + + async function applyHit(hit: MetaHit) { + if (!link) return; + if ( + !confirm( + `${link.fileCount} bestanden in\n${link.folder}\n(${link.nodeName})\nkoppelen aan “${hit.title}${hit.year ? ` (${hit.year})` : ""}”?` + ) + ) { + return; + } + setBusy(true); + setLinkMsg("Koppelen…"); + try { + const res = await fetch("/api/v1/admin/library/relink-folder", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + nodeId: link.nodeId, + folder: link.folder, + tmdbId: hit.tmdbId, + ...(hit.imdbId ? { imdbId: hit.imdbId } : {}), + }), + }); + const data = await res.json(); + if (!res.ok) { + setLinkMsg(data.error?.message ?? "Koppelen mislukt"); + return; + } + setMsg( + `Gekoppeld → ${data.seriesTitle}: ${data.moved}/${data.total}` + + (data.failed ? ` (${data.failed} mislukt)` : "") + ); + setLink(null); + load(); + } finally { + setBusy(false); + } + } + + async function applyImdb() { + const imdb = query.trim(); + if (!/^tt\d+$/i.test(imdb) || !link) { + setLinkMsg("Vul een IMDb-id in (tt…) of zoek op titel."); + return; + } + setBusy(true); + setLinkMsg("Koppelen via IMDb…"); + try { + const res = await fetch("/api/v1/admin/library/relink-folder", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + nodeId: link.nodeId, + folder: link.folder, + imdbId: imdb, + }), + }); + const data = await res.json(); + if (!res.ok) { + setLinkMsg(data.error?.message ?? "Koppelen mislukt"); + return; + } + setMsg(`Gekoppeld → ${data.seriesTitle}: ${data.moved}/${data.total}`); + setLink(null); + load(); + } finally { + setBusy(false); + } + } + + return ( + <> +