"use client"; import { useState, useEffect, useCallback } from "react"; import { createPortal } from "react-dom"; import { useRouter } from "next/navigation"; import { Icon } from "./ui"; import { ProviderIcon, PROVIDERS, providerLabel } from "./ProviderIcon"; import type { ExternalMetadataLinkDto, SeriesCandidateDto, MissingBooksDto, SyncReport } from "../../lib/api"; import { useTranslation } from "../../lib/i18n/context"; const FIELD_KEYS: string[] = [ "description", "authors", "publishers", "start_year", "total_volumes", "status", "summary", "isbn", "publish_date", "language", ]; function formatValue(value: unknown): string { if (value == null) return "—"; if (Array.isArray(value)) return value.join(", "); if (typeof value === "string") { return value.length > 80 ? value.slice(0, 80) + "…" : value; } return String(value); } interface MetadataSearchModalProps { libraryId: string; seriesName: string; existingLink: ExternalMetadataLinkDto | null; initialMissing: MissingBooksDto | null; } type ModalStep = "idle" | "searching" | "results" | "confirm" | "syncing" | "done" | "linked"; export function MetadataSearchModal({ libraryId, seriesName, existingLink, initialMissing, }: MetadataSearchModalProps) { const { t } = useTranslation(); const router = useRouter(); const fieldLabel = (field: string): string => { if (FIELD_KEYS.includes(field)) { return t(`field.${field}` as any); } return field; }; const [isOpen, setIsOpen] = useState(false); const [step, setStep] = useState("idle"); const [candidates, setCandidates] = useState([]); const [selectedCandidate, setSelectedCandidate] = useState(null); const [error, setError] = useState(null); const [linkId, setLinkId] = useState(existingLink?.id ?? null); const [missing, setMissing] = useState(initialMissing); const [showMissingList, setShowMissingList] = useState(false); const [syncReport, setSyncReport] = useState(null); // Provider selector: empty string = library default const [searchProvider, setSearchProvider] = useState(""); const [activeProvider, setActiveProvider] = useState(""); const [hiddenProviders, setHiddenProviders] = useState>(new Set()); // Fetch metadata provider settings to hide providers without required API keys useEffect(() => { fetch("/api/settings/metadata_providers") .then((r) => r.ok ? r.json() : null) .then((data) => { if (!data) return; const hidden = new Set(); // ComicVine requires an API key if (!data.comicvine?.api_key) hidden.add("comicvine"); setHiddenProviders(hidden); }) .catch(() => {}); }, []); const visibleProviders = PROVIDERS.filter((p) => !hiddenProviders.has(p.value)); const handleOpen = useCallback(() => { setIsOpen(true); if (existingLink && existingLink.status === "approved") { setStep("linked"); } else { doSearch(""); } }, [existingLink]); const handleClose = useCallback(() => { setIsOpen(false); setStep("idle"); setError(null); setCandidates([]); setSelectedCandidate(null); setShowMissingList(false); setSyncReport(null); }, []); useEffect(() => { if (!isOpen) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") handleClose(); }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [isOpen, handleClose]); async function doSearch(provider: string) { setStep("searching"); setError(null); setActiveProvider(provider); try { const body: Record = { library_id: libraryId, series_name: seriesName, }; if (provider) body.provider = provider; const resp = await fetch("/api/metadata/search", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); const data = await resp.json(); if (!resp.ok) { setError(data.error || t("metadata.searchFailed")); setStep("results"); return; } setCandidates(data); // Update activeProvider from first result (the API returns the actual provider used) if (data.length > 0 && data[0].provider) { setActiveProvider(data[0].provider); if (!provider) setSearchProvider(data[0].provider); } setStep("results"); } catch { setError(t("common.networkError")); setStep("results"); } } async function handleSelectCandidate(candidate: SeriesCandidateDto) { setSelectedCandidate(candidate); setStep("confirm"); } async function handleApprove(syncSeries: boolean, syncBooks: boolean) { if (!selectedCandidate) return; setStep("syncing"); setError(null); try { // Create match — use the provider from the candidate const matchResp = await fetch("/api/metadata/match", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ library_id: libraryId, series_name: seriesName, provider: selectedCandidate.provider, external_id: selectedCandidate.external_id, external_url: selectedCandidate.external_url, confidence: selectedCandidate.confidence, title: selectedCandidate.title, metadata_json: { ...selectedCandidate.metadata_json, description: selectedCandidate.description, authors: selectedCandidate.authors, publishers: selectedCandidate.publishers, start_year: selectedCandidate.start_year, }, total_volumes: selectedCandidate.total_volumes, }), }); const matchData = await matchResp.json(); if (!matchResp.ok) { setError(matchData.error || t("metadata.linkFailed")); setStep("results"); return; } const newLinkId = matchData.id; setLinkId(newLinkId); // Approve const approveResp = await fetch("/api/metadata/approve", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: newLinkId, sync_series: syncSeries, sync_books: syncBooks, }), }); const approveData = await approveResp.json(); if (!approveResp.ok) { setError(approveData.error || t("metadata.approveFailed")); setStep("results"); return; } // Store sync report if (approveData.report) { setSyncReport(approveData.report); } // Fetch missing books info if (syncBooks) { try { const missingResp = await fetch(`/api/metadata/missing?id=${newLinkId}`); if (missingResp.ok) { setMissing(await missingResp.json()); } } catch { /* ignore */ } } setStep("done"); } catch { setError(t("common.networkError")); setStep("results"); } } async function handleUnlink() { if (!linkId) return; try { const resp = await fetch(`/api/metadata/links?id=${linkId}`, { method: "DELETE" }); if (resp.ok) { setLinkId(null); setMissing(null); handleClose(); router.refresh(); } } catch { /* ignore */ } } function confidenceBadge(confidence: number) { const color = confidence >= 0.8 ? "bg-green-500/10 text-green-600 border-green-500/30" : confidence >= 0.5 ? "bg-yellow-500/10 text-yellow-600 border-yellow-500/30" : "bg-red-500/10 text-red-600 border-red-500/30"; return ( {Math.round(confidence * 100)}% ); } const modal = isOpen ? createPortal( <>
{/* Header */}

{step === "linked" ? t("metadata.metadataLink") : t("metadata.searchExternal")}

{/* Provider selector — visible during searching & results */} {(step === "searching" || step === "results") && (
{visibleProviders.map((p) => ( ))}
)} {/* SEARCHING */} {step === "searching" && (
{t("metadata.searching", { name: seriesName })}
)} {/* ERROR */} {error && (
{error}
)} {/* RESULTS */} {step === "results" && ( <> {candidates.length === 0 && !error ? (

{t("metadata.noResults")}

) : (

{t("metadata.resultCount", { count: candidates.length, plural: candidates.length !== 1 ? "s" : "" })} {activeProvider && ( {t("common.via")} {providerLabel(activeProvider)} )}

{candidates.map((c, i) => ( ))}
)} )} {/* CONFIRM */} {step === "confirm" && selectedCandidate && (
{selectedCandidate.cover_url && ( {selectedCandidate.title} )}

{selectedCandidate.title}

{selectedCandidate.authors.length > 0 && (

{selectedCandidate.authors.join(", ")}

)} {selectedCandidate.total_volumes != null && (

{selectedCandidate.total_volumes} {selectedCandidate.metadata_json?.volume_source === "chapters" ? t("metadata.chapters") : t("metadata.volumes")} {selectedCandidate.metadata_json?.status === "RELEASING" && ({t("metadata.inProgress")})}

)}

{t("common.via")} {providerLabel(selectedCandidate.provider)}

{t("metadata.howToSync")}

)} {/* SYNCING */} {step === "syncing" && (
{t("metadata.syncingMetadata")}
)} {/* DONE */} {step === "done" && (

{t("metadata.syncSuccess")}

{/* Sync Report */} {syncReport && (
{/* Series report */} {syncReport.series && (syncReport.series.fields_updated.length > 0 || syncReport.series.fields_skipped.length > 0) && (

{t("metadata.seriesLabel")}

{syncReport.series.fields_updated.length > 0 && (
{syncReport.series.fields_updated.map((f, i) => (
{fieldLabel(f.field)} {formatValue(f.new_value)}
))}
)} {syncReport.series.fields_skipped.length > 0 && (
{syncReport.series.fields_skipped.map((f, i) => (
{fieldLabel(f.field)} {t("metadata.locked")}
))}
)}
)} {/* Books message (e.g. provider has no volume data) */} {syncReport.books_message && (

{syncReport.books_message}

)} {/* Books report */} {!syncReport.books_message && (syncReport.books.length > 0 || syncReport.books_unmatched > 0) && (

{t("metadata.booksLabel")} — {t("metadata.booksMatched", { matched: syncReport.books_matched, plural: syncReport.books_matched !== 1 ? "s" : "" })}{syncReport.books_unmatched > 0 && `, ${t("metadata.booksUnmatched", { count: syncReport.books_unmatched, plural: syncReport.books_unmatched !== 1 ? "s" : "" })}`}

{syncReport.books.length > 0 && (
{syncReport.books.map((b, i) => (

{b.volume != null && #{b.volume}} {b.title}

{b.fields_updated.map((f, j) => (

{fieldLabel(f.field)}

))} {b.fields_skipped.map((f, j) => (

{fieldLabel(f.field)} {t("metadata.locked")}

))}
))}
)}
)}
)} {/* Missing books */} {missing && (

{t("metadata.external")}

{missing.total_external}

{t("metadata.local")}

{missing.total_local}

{t("metadata.missingLabel")}

{missing.missing_count}

{missing.missing_books.length > 0 && (
{showMissingList && (
{missing.missing_books.map((b, i) => (

{b.volume_number != null && #{b.volume_number}} {b.title || t("metadata.unknown")}

))}
)}
)}
)}
)} {/* LINKED (already approved) */} {step === "linked" && existingLink && (

{t("metadata.linkedTo")} {providerLabel(existingLink.provider)}

{existingLink.external_url && ( {t("metadata.viewExternal")} )}
{existingLink.confidence != null && confidenceBadge(existingLink.confidence)}
{initialMissing && (

{t("metadata.external")}

{initialMissing.total_external}

{t("metadata.local")}

{initialMissing.total_local}

{t("metadata.missingLabel")}

{initialMissing.missing_count}

)} {initialMissing && initialMissing.missing_books.length > 0 && (
{showMissingList && (
{initialMissing.missing_books.map((b, i) => (

{b.volume_number != null && #{b.volume_number}} {b.title || t("metadata.unknown")}

))}
)}
)}
)}
, document.body, ) : null; return ( <> {/* Inline badge when linked */} {existingLink && existingLink.status === "approved" && initialMissing && initialMissing.missing_count > 0 && ( {t("series.missingCount", { count: initialMissing.missing_count, plural: initialMissing.missing_count !== 1 ? "s" : "" })} )} {existingLink && existingLink.status === "approved" && ( {providerLabel(existingLink.provider)} )} {modal} ); }