"use client"; import { useEffect, useMemo, useState } from "react"; import { createPortal } from "react-dom"; import { X, UserPlus, Link2, MinusCircle, AlertTriangle, Check } from "lucide-react"; import { cn } from "@/lib/utils"; import type { ActressLookupResult } from "@/app/actions/actressLookup"; export type ActressDecision = "link" | "create" | "skip"; export interface IngestPreviewFile { original: string; targetFilename: string; code: string | null; actresses: string[]; } /** Per-file → per-actress decisions. Keyed by `file.original`, then by * `actressName.toLowerCase()`. The two-level shape keeps the same actress * appearing on multiple covers independent — skipping Mitsuki on one cover * must not silently skip her on every other cover sharing the name. */ export type ActressDecisions = Record>; export interface IngestPreviewProps { files: IngestPreviewFile[]; lookup: ActressLookupResult[]; onCancel: () => void; onSkipActresses: () => void; onConfirm: (decisions: ActressDecisions) => void; } const INITIAL_RENDER = 200; const RENDER_INCREMENT = 200; type StatusFilter = "all" | "ok" | "errors"; export function IngestPreviewDialog({ files, lookup, onCancel, onSkipActresses, onConfirm }: IngestPreviewProps) { const [renderLimit, setRenderLimit] = useState(INITIAL_RENDER); const [statusFilter, setStatusFilter] = useState("all"); const lookupMap = useMemo(() => { const m = new Map(); for (const r of lookup) m.set(r.name.toLowerCase(), r); return m; }, [lookup]); // A row is in "errors" when there's something the user should look at // before clicking Confirm. Today: missing code only — a file with no // canonical code imports successfully but has no stable URL handle. The // "new" pill on actresses is a state, not an error, and including it // would make every fresh-actress batch flash red. const isError = (f: IngestPreviewFile) => !f.code; const errorCount = useMemo(() => files.filter(isError).length, [files]); const okCount = files.length - errorCount; const filtered = useMemo(() => { if (statusFilter === "ok") return files.filter((f) => !isError(f)); if (statusFilter === "errors") return files.filter(isError); return files; }, [files, statusFilter]); const [decisions, setDecisions] = useState(() => { const init: ActressDecisions = {}; for (const f of files) { if (f.actresses.length === 0) continue; const perFile: Record = {}; for (const name of f.actresses) { const key = name.toLowerCase(); const lr = lookupMap.get(key); perFile[key] = lr?.match ? "link" : "create"; } init[f.original] = perFile; } return init; }); useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onCancel]); useEffect(() => { setRenderLimit(INITIAL_RENDER); }, [statusFilter]); function setDecision(fileOriginal: string, nameKey: string, d: ActressDecision) { setDecisions((s) => ({ ...s, [fileOriginal]: { ...(s[fileOriginal] ?? {}), [nameKey]: d }, })); } // Total and per-bucket counts are computed across (file, actress) pairs, // so the same actress on N covers contributes N rows to the bulk state. const counts = useMemo(() => { let matched = 0, unmatched = 0; for (const f of files) { for (const name of f.actresses) { const lr = lookupMap.get(name.toLowerCase()); if (lr?.match) matched++; else unmatched++; } } return { matched, unmatched, total: matched + unmatched }; }, [files, lookupMap]); const bulkState = useMemo(() => { let matchedAllLink = counts.matched > 0; let unmatchedAllCreate = counts.unmatched > 0; let allSkip = counts.total > 0; for (const f of files) { const perFile = decisions[f.original] ?? {}; for (const name of f.actresses) { const key = name.toLowerCase(); const lr = lookupMap.get(key); const d = perFile[key]; if (lr?.match) { if (d !== "link") matchedAllLink = false; } else { if (d !== "create") unmatchedAllCreate = false; } if (d !== "skip") allSkip = false; } } return { matchedAllLink, unmatchedAllCreate, allSkip }; }, [files, decisions, lookupMap, counts]); function bulkApply(target: "matched" | "unmatched" | "all", decision: ActressDecision) { setDecisions((s) => { const next: ActressDecisions = { ...s }; for (const f of files) { if (f.actresses.length === 0) continue; const perFile: Record = { ...(next[f.original] ?? {}) }; let touched = false; for (const name of f.actresses) { const key = name.toLowerCase(); const isMatched = !!lookupMap.get(key)?.match; if (target !== "all" && target === "matched" && !isMatched) continue; if (target !== "all" && target === "unmatched" && isMatched) continue; if (decision === "link" && !isMatched) continue; if (decision === "create" && isMatched) continue; perFile[key] = decision; touched = true; } if (touched) next[f.original] = perFile; } return next; }); } if (typeof document === "undefined") return null; return createPortal(
{ if (e.target === e.currentTarget) onCancel(); }} >
Confirm Import
{files.length} file{files.length === 1 ? "" : "s"} · review actress assignments below
{files.length > INITIAL_RENDER && (
Large batch — first {INITIAL_RENDER} of {files.length} files shown. Bulk actions still apply to all {files.length}.
)}
Filter: setStatusFilter("all")}> All {files.length} setStatusFilter("ok")} kind="ok" disabled={okCount === 0} > OK {okCount} setStatusFilter("errors")} kind="error" disabled={errorCount === 0} > Errors {errorCount}
{counts.total > 0 && (
Bulk: {counts.matched > 0 && ( bulkApply("matched", "link")}> Link all matched ({counts.matched}) )} {counts.unmatched > 0 && ( bulkApply("unmatched", "create")}> Create all new ({counts.unmatched}) )} bulkApply("all", "skip")}> Skip all
)}
{filtered.length === 0 && (
No files match this filter.
)} {filtered.slice(0, renderLimit).map((f) => (
{f.targetFilename}
from {f.original}
{f.code ? ( {f.code} ) : ( No code )}
{f.actresses.length > 0 ? (
{f.actresses.map((name) => { const key = name.toLowerCase(); const lr = lookupMap.get(key); const decision = decisions[f.original]?.[key] ?? "skip"; return (
{name}
{lr?.match ? ( match: {lr.match.name} ) : ( new )} setDecision(f.original, key, d)} />
); })}
) : (
No actresses parsed.
)}
))} {filtered.length > renderLimit && ( )}
, document.body, ); } function FilterChip({ active, onClick, disabled, kind, children, }: { active: boolean; onClick: () => void; disabled?: boolean; kind?: "ok" | "error"; children: React.ReactNode; }) { const accent = kind === "ok" ? "var(--color-mint)" : kind === "error" ? "var(--color-coral)" : "var(--color-cyan)"; return ( ); } function BulkButton({ active, onClick, children, }: { active: boolean; onClick: () => void; children: React.ReactNode; }) { return ( ); } function Pill({ kind, children }: { kind: "match" | "new"; children: React.ReactNode }) { const cls = kind === "match" ? "bg-[var(--color-mint)]/15 text-[var(--color-mint)] border-[var(--color-mint)]/30" : "bg-[var(--color-violet)]/15 text-[var(--color-violet)] border-[var(--color-violet)]/30"; return ( {children} ); } function DecisionButtons({ available, value, onChange, }: { available: ActressDecision[]; value: ActressDecision; onChange: (d: ActressDecision) => void; }) { const opts: Array<{ key: ActressDecision; label: string; Icon: React.ComponentType<{ className?: string }> }> = [ { key: "link", label: "Link", Icon: Link2 }, { key: "create", label: "Create", Icon: UserPlus }, { key: "skip", label: "Skip", Icon: MinusCircle }, ]; return (
{opts .filter((o) => available.includes(o.key)) .map(({ key, label, Icon }) => ( ))}
); }