"use client"; import { useCallback, useEffect, useState } from "react"; import { createPortal } from "react-dom"; import { useDropzone } from "react-dropzone"; import { useRouter } from "next/navigation"; import { UploadCloud, Loader2, CheckCircle2, AlertCircle, Copy, X, Check, Download } from "lucide-react"; import { cn } from "@/lib/utils"; import { parseDroppedFilename } from "@/lib/jav/dropParser"; import { lookupActressesByNames, type ActressLookupResult } from "@/app/actions/actressLookup"; import { IngestPreviewDialog, type ActressDecisions, type IngestPreviewFile } from "./IngestPreviewDialog"; import { CollisionReviewDialog, type CollisionReviewItem, type CollisionDecision } from "./CollisionReviewDialog"; interface FailedFile { filename: string; reason: string; } interface DuplicateFile { filename: string; code: string | null; /** Metadata the client sent up alongside this re-upload — these are * the merges that the dedup branch in ingestFile applies to the * existing row (actress links via INSERT OR IGNORE, autoAssign tag, * autoAssign collection). Surfaced in the summary so the user can * tell whether anything actually changed for a "duplicate". */ mergedActresses: string[]; mergedTag: string | null; mergedCollectionId: number | null; } interface UploadState { total: number; done: number; duplicates: DuplicateFile[]; failed: FailedFile[]; finished: boolean; } interface PendingJob { image: File; nfo?: File; parsed: ReturnType; /** Stable id used to correlate jobs with collision-review decisions on * re-submit. Composed from filename + size to survive React re-renders. */ jobId: string; } export function DropZone({ compact = false, autoAssign, }: { compact?: boolean; autoAssign?: { tagName?: string; collectionId?: number }; }) { const router = useRouter(); const [state, setState] = useState(null); const [copied, setCopied] = useState(false); const [preview, setPreview] = useState<{ jobs: PendingJob[]; files: IngestPreviewFile[]; lookup: ActressLookupResult[]; } | null>(null); const [collisionReview, setCollisionReview] = useState<{ items: CollisionReviewItem[]; jobsById: Record; actressDecisions?: ActressDecisions; linkedActressNames?: Record; } | null>(null); const runJobs = useCallback(async ( jobs: PendingJob[], actressDecisions?: ActressDecisions, linkedActressNames?: Record, collisionDecisions?: Record, ) => { let done = 0; const duplicates: DuplicateFile[] = []; const failed: FailedFile[] = []; const collisions: CollisionReviewItem[] = []; setState({ total: jobs.length, done: 0, duplicates: [], failed: [], finished: false }); const CONCURRENCY = 6; const acceptedActressesFor = (job: PendingJob): string[] => { if (!actressDecisions || job.parsed.actresses.length === 0) return []; const perFile = actressDecisions[job.parsed.original] ?? {}; return job.parsed.actresses.flatMap((n) => { const d = perFile[n.toLowerCase()]; if (d === "link") return [linkedActressNames?.[n.toLowerCase()] ?? n]; if (d === "create") return [n]; return []; }); }; const buildBody = (job: PendingJob, accepted: string[]): FormData => { const fd = new FormData(); fd.append("file", job.image); if (job.nfo) fd.append("nfo", job.nfo); if (autoAssign?.tagName) fd.append("autoTag", autoAssign.tagName); if (autoAssign?.collectionId != null) fd.append("autoCollection", String(autoAssign.collectionId)); if (job.parsed.targetFilename !== job.image.name) { fd.append("targetFilename", job.parsed.targetFilename); } if (accepted.length > 0) fd.append("actressNames", JSON.stringify(accepted)); const collisionDecision = collisionDecisions?.[job.jobId]; if (collisionDecision) fd.append("onCollision", collisionDecision); return fd; }; const runOne = async (job: PendingJob): Promise => { const accepted = acceptedActressesFor(job); try { const res = await fetch("/api/upload", { method: "POST", body: buildBody(job, accepted) }); if (!res.ok) { let reason = `HTTP ${res.status}`; try { const j = await res.json(); if (j?.error) reason = String(j.error); } catch {} failed.push({ filename: job.image.name, reason }); } else { const data = await res.json(); if (data.collision) { collisions.push({ jobId: job.jobId, filename: job.image.name, code: data.code ?? null, ...data.collision, }); } else if (data.duplicate) { duplicates.push({ filename: job.image.name, code: data.code ?? null, mergedActresses: accepted, mergedTag: autoAssign?.tagName ?? null, mergedCollectionId: autoAssign?.collectionId ?? null, }); } } } catch (e) { failed.push({ filename: job.image.name, reason: (e as Error).message || "network error" }); } done++; setState({ total: jobs.length, done, duplicates: duplicates.slice(), failed: failed.slice(), finished: false }); }; let cursor = 0; const workers = Array.from({ length: Math.min(CONCURRENCY, jobs.length) }, async () => { while (true) { const idx = cursor++; if (idx >= jobs.length) return; await runOne(jobs[idx]); } }); await Promise.all(workers); if (collisions.length > 0 && !collisionDecisions) { // First pass surfaced code collisions — pause and let the user // decide. Decisions are funnelled back through runJobs with the // colliding subset. const jobsById: Record = {}; for (const j of jobs) jobsById[j.jobId] = j; setCollisionReview({ items: collisions, jobsById, actressDecisions, linkedActressNames }); // Mark the run finished for the non-colliding jobs; the resolution // pass will produce a fresh state when it begins. setState({ total: jobs.length, done, duplicates, failed, finished: true }); return; } setState({ total: jobs.length, done, duplicates, failed, finished: true }); router.refresh(); }, [autoAssign, router]); const onDrop = useCallback(async (accepted: File[]) => { if (accepted.length === 0) return; const byBase = new Map(); for (const f of accepted) { const base = f.name.replace(/\.[^.]+$/, "").toLowerCase(); const slot = byBase.get(base) ?? {}; const ext = f.name.toLowerCase().match(/\.([^.]+)$/)?.[1]; if (ext === "nfo" || ext === "xml") slot.nfo = f; else slot.image = f; byBase.set(base, slot); } const jobs: PendingJob[] = Array.from(byBase.values()) .filter((j) => j.image) .map((j) => ({ image: j.image!, nfo: j.nfo, parsed: parseDroppedFilename(j.image!.name), jobId: `${j.image!.name}::${j.image!.size}`, })); if (jobs.length === 0) return; const filesWithActresses = jobs.filter((j) => j.parsed.actresses.length > 0); if (filesWithActresses.length === 0) { await runJobs(jobs); return; } const uniqueNames = Array.from( new Set(filesWithActresses.flatMap((j) => j.parsed.actresses.map((s) => s.toLowerCase()))), ); const displayNames = uniqueNames.map((lc) => { const found = filesWithActresses.flatMap((j) => j.parsed.actresses).find((n) => n.toLowerCase() === lc); return found ?? lc; }); const lookup = await lookupActressesByNames(displayNames); setPreview({ jobs, files: jobs.map((j) => ({ original: j.parsed.original, targetFilename: j.parsed.targetFilename, code: j.parsed.code, actresses: j.parsed.actresses, })), lookup, }); }, [runJobs]); const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop, accept: { "image/png": [".png"], "image/jpeg": [".jpg", ".jpeg"], "image/webp": [".webp"], "application/xml": [".nfo", ".xml"], "text/xml": [".nfo", ".xml"], }, }); function exportFailures() { if (!state || state.failed.length === 0) return; const lines = ["filename\treason", ...state.failed.map((f) => `${f.filename}\t${f.reason.replace(/\t/g, " ")}`)]; const blob = new Blob([lines.join("\n") + "\n"], { type: "text/tab-separated-values;charset=utf-8" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; const stamp = new Date().toISOString().replace(/[:.]/g, "-"); a.download = `pinkudex-failed-imports-${stamp}.tsv`; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } async function copyFailures() { if (!state || state.failed.length === 0) return; const text = state.failed.map((f) => `${f.filename}\t${f.reason}`).join("\n"); try { await navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 1500); } catch { // Fallback: select-all in a textarea const ta = document.createElement("textarea"); ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0"; document.body.appendChild(ta); ta.select(); document.execCommand("copy"); ta.remove(); setCopied(true); setTimeout(() => setCopied(false), 1500); } } const inProgress = state && !state.finished; // "added" = brand new rows in the DB. Duplicates are excluded because // they hit an existing row by SHA — no insert happened. const dupCount = state?.duplicates.length ?? 0; const addedCount = state ? state.done - state.failed.length - dupCount : 0; const progressPct = state && state.total > 0 ? Math.round((state.done / state.total) * 100) : 0; const [showDuplicates, setShowDuplicates] = useState(true); return ( <>
{state ? ( <> {inProgress ? ( ) : state.failed.length > 0 ? ( ) : ( )}
{state.done} / {state.total} processed
{addedCount} added {dupCount > 0 && · {dupCount} already in library} {state.failed.length > 0 && · {state.failed.length} failed}
) : ( <>
{isDragActive ? "Drop To Import" : "Drop Covers Here"}
{!compact && (
JPG, PNG, WEBP — drop a matching .nfo alongside to auto-fill metadata.
)}
)} {state && (
)}
{state && state.finished && ( setShowDuplicates((s) => !s)} copied={copied} onCopyFailures={copyFailures} onExportFailures={exportFailures} onClose={() => setState(null)} /> )} {preview && ( setPreview(null)} onSkipActresses={() => { const jobs = preview.jobs; setPreview(null); void runJobs(jobs); }} onConfirm={(decisions) => { const jobs = preview.jobs; const linkedNames = Object.fromEntries( preview.lookup .filter((r) => r.match) .map((r) => [r.name.toLowerCase(), r.match!.name]), ); setPreview(null); void runJobs(jobs, decisions, linkedNames); }} /> )} {collisionReview && ( setCollisionReview(null)} onConfirm={(decisions) => { const cr = collisionReview; setCollisionReview(null); const replays = cr.items.map((it) => cr.jobsById[it.jobId]).filter((j): j is PendingJob => !!j); void runJobs(replays, cr.actressDecisions, cr.linkedActressNames, decisions); }} /> )} ); } function ImportSummaryModal({ addedCount, duplicates, failed, showDuplicates, onToggleDuplicates, copied, onCopyFailures, onExportFailures, onClose, }: { addedCount: number; duplicates: DuplicateFile[]; failed: FailedFile[]; showDuplicates: boolean; onToggleDuplicates: () => void; copied: boolean; onCopyFailures: () => void; onExportFailures: () => void; onClose: () => void; }) { useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose]); if (typeof document === "undefined") return null; const dupCount = duplicates.length; const dupsWithMerges = duplicates.filter((d) => d.mergedActresses.length > 0 || d.mergedTag != null || d.mergedCollectionId != null, ); const dupsUntouched = dupCount - dupsWithMerges.length; const tone = failed.length > 0 ? "error" : "ok"; const total = addedCount + dupCount + failed.length; // One-line plain-English narrative explaining what just happened. Helps // when the count summary alone is ambiguous (e.g. "1 imported · 1 dup" // could either mean a fresh insert plus a separate duplicate, or a // single file that was deduped). const narrative = (() => { const parts: string[] = []; if (addedCount > 0) parts.push(`Added ${addedCount} new cover${addedCount === 1 ? "" : "s"} to the library.`); if (dupCount > 0) { if (dupsWithMerges.length > 0 && dupsUntouched > 0) { parts.push(`${dupCount} file${dupCount === 1 ? "" : "s"} matched existing covers — no new rows were inserted; ${dupsWithMerges.length} had fresh metadata merged into the existing entry.`); } else if (dupsWithMerges.length > 0) { parts.push(`${dupCount} file${dupCount === 1 ? " was" : "s were"} already in the library — no new rows were inserted, but the new metadata you picked was merged into the existing entr${dupCount === 1 ? "y" : "ies"}.`); } else { parts.push(`${dupCount} file${dupCount === 1 ? " was" : "s were"} already in the library — no new rows were inserted and there was no extra metadata to merge.`); } } if (failed.length > 0) parts.push(`${failed.length} file${failed.length === 1 ? "" : "s"} failed.`); if (parts.length === 0) parts.push(`Nothing was processed.`); return parts.join(" "); })(); return createPortal(
{ if (e.target === e.currentTarget) onClose(); }} >
{tone === "ok" ? ( ) : ( )}
Import complete · {total} file{total === 1 ? "" : "s"}
{addedCount} added {dupCount > 0 && ( )} {failed.length > 0 && ( · {failed.length} failed )}

{narrative}

{(failed.length > 0 || (showDuplicates && dupCount > 0)) && (
{failed.length > 0 && (
Failed ({failed.length})
{failed.map((f) => (
{f.filename} — {f.reason}
))}
)} {showDuplicates && dupCount > 0 && (
Already in library ({dupCount})
{duplicates.map((d) => { const merges: string[] = []; if (d.mergedActresses.length > 0) { merges.push(`linked actress${d.mergedActresses.length === 1 ? "" : "es"}: ${d.mergedActresses.join(", ")}`); } if (d.mergedTag) merges.push(`tag: ${d.mergedTag}`); if (d.mergedCollectionId != null) merges.push(`collection #${d.mergedCollectionId}`); return (
{d.code ?? "—"}
{d.filename}
{merges.length > 0 ? (
+ {merges.join(" · ")}
) : (
no new metadata to merge
)}
); })}

These files were already imported earlier (matched by SHA-256 of the bytes). The cover row was kept as-is; any new actress / tag / collection picks shown above were merged into the existing entry via INSERT OR IGNORE, so previous links are untouched.

)}
)}
, document.body, ); }