"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"; const FIELD_LABELS: Record = { description: "Description", authors: "Auteurs", publishers: "Éditeurs", start_year: "Année", total_volumes: "Nb volumes", summary: "Résumé", isbn: "ISBN", publish_date: "Date de publication", language: "Langue", }; function fieldLabel(field: string): string { return FIELD_LABELS[field] ?? field; } 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 router = useRouter(); 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 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 || "Search failed"); 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("Network error"); 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 || "Failed to create match"); 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 || "Failed to approve"); 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("Network error"); 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" ? "Metadata Link" : "Search External Metadata"}

{/* Provider selector — visible during searching & results */} {(step === "searching" || step === "results") && (
{PROVIDERS.map((p) => ( ))}
)} {/* SEARCHING */} {step === "searching" && (
Searching for "{seriesName}"...
)} {/* ERROR */} {error && (
{error}
)} {/* RESULTS */} {step === "results" && ( <> {candidates.length === 0 && !error ? (

No results found.

) : (

{candidates.length} result{candidates.length !== 1 ? "s" : ""} found {activeProvider && ( 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 && (

{selectedCandidate.total_volumes} volumes

)}

via {providerLabel(selectedCandidate.provider)}

How would you like to sync?

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

Metadata synced successfully!

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

Série

{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)} locked
))}
)}
)} {/* Books report */} {(syncReport.books.length > 0 || syncReport.books_unmatched > 0) && (

Livres — {syncReport.books_matched} matched{syncReport.books_unmatched > 0 && `, ${syncReport.books_unmatched} unmatched`}

{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)} locked

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

External

{missing.total_external}

Local

{missing.total_local}

Missing

{missing.missing_count}

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

{b.volume_number != null && #{b.volume_number}} {b.title || "Unknown"}

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

Linked to {providerLabel(existingLink.provider)}

{existingLink.external_url && ( View on external source )}
{existingLink.confidence != null && confidenceBadge(existingLink.confidence)}
{initialMissing && (

External

{initialMissing.total_external}

Local

{initialMissing.total_local}

Missing

{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 || "Unknown"}

))}
)}
)}
)}
, document.body, ) : null; return ( <> {/* Inline badge when linked */} {existingLink && existingLink.status === "approved" && initialMissing && initialMissing.missing_count > 0 && ( {initialMissing.missing_count} missing )} {existingLink && existingLink.status === "approved" && ( {providerLabel(existingLink.provider)} )} {modal} ); }