Initial commit
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
"use client";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X, Replace, SkipForward, ArrowUp, ArrowDown, Equal, Shuffle } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type CollisionBucket = "upgrade" | "downgrade" | "sidegrade" | "mixed";
|
||||
export type CollisionDecision = "replace" | "skip";
|
||||
|
||||
export interface CollisionReviewItem {
|
||||
jobId: string;
|
||||
filename: string;
|
||||
code: string | null;
|
||||
existingId: number;
|
||||
existingFilename: string;
|
||||
existingWidth: number;
|
||||
existingHeight: number;
|
||||
existingBytes: number;
|
||||
existingThumbPath: string;
|
||||
incomingWidth: number;
|
||||
incomingHeight: number;
|
||||
incomingBytes: number;
|
||||
bucket: CollisionBucket;
|
||||
}
|
||||
|
||||
export interface CollisionReviewProps {
|
||||
items: CollisionReviewItem[];
|
||||
onCancel: () => void;
|
||||
onConfirm: (decisions: Record<string, CollisionDecision>) => void;
|
||||
}
|
||||
|
||||
const BUCKET_META: Record<CollisionBucket, { label: string; icon: typeof ArrowUp; color: string; defaultDecision: CollisionDecision }> = {
|
||||
upgrade: { label: "Upgrade", icon: ArrowUp, color: "var(--color-mint)", defaultDecision: "replace" },
|
||||
downgrade: { label: "Downgrade", icon: ArrowDown, color: "var(--color-coral)", defaultDecision: "skip" },
|
||||
sidegrade: { label: "Sidegrade", icon: Equal, color: "var(--color-amber, #fbbf24)", defaultDecision: "skip" },
|
||||
mixed: { label: "Mixed", icon: Shuffle, color: "var(--color-violet)", defaultDecision: "skip" },
|
||||
};
|
||||
|
||||
function fmtBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
export function CollisionReviewDialog({ items, onCancel, onConfirm }: CollisionReviewProps) {
|
||||
const [decisions, setDecisions] = useState<Record<string, CollisionDecision>>(() => {
|
||||
const init: Record<string, CollisionDecision> = {};
|
||||
for (const it of items) init[it.jobId] = BUCKET_META[it.bucket].defaultDecision;
|
||||
return init;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); };
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onCancel]);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const c: Record<CollisionBucket, number> = { upgrade: 0, downgrade: 0, sidegrade: 0, mixed: 0 };
|
||||
for (const it of items) c[it.bucket]++;
|
||||
return c;
|
||||
}, [items]);
|
||||
|
||||
function applyToBucket(bucket: CollisionBucket | "all", decision: CollisionDecision) {
|
||||
setDecisions((s) => {
|
||||
const next = { ...s };
|
||||
for (const it of items) {
|
||||
if (bucket === "all" || it.bucket === bucket) next[it.jobId] = decision;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const replaceCount = Object.values(decisions).filter((d) => d === "replace").length;
|
||||
const skipCount = items.length - replaceCount;
|
||||
|
||||
if (typeof document === "undefined") return null;
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 backdrop-blur-sm"
|
||||
// Backdrop dismiss on mousedown (not click) so a text-drag-select
|
||||
// started inside the dialog and released over the backdrop doesn't
|
||||
// discard the user's review decisions.
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) onCancel(); }}
|
||||
>
|
||||
<div
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="relative w-[min(95vw,900px)] max-h-[85vh] flex flex-col rounded-2xl border border-[var(--color-glass-border-strong)] bg-[var(--color-bg-0)] shadow-2xl"
|
||||
>
|
||||
<div className="px-5 py-4 border-b border-[var(--color-glass-border)] flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-base font-medium">Code collisions detected</div>
|
||||
<div className="text-xs text-[var(--color-fg-muted)] mt-0.5">
|
||||
{items.length} cover{items.length === 1 ? "" : "s"} share a code with an existing entry. Pick what to do with each.
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onCancel} className="text-[var(--color-fg-muted)] hover:text-[var(--color-fg)]">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-3 border-b border-[var(--color-glass-border)] flex flex-wrap items-center gap-2 text-xs">
|
||||
{(Object.keys(BUCKET_META) as CollisionBucket[]).map((b) => {
|
||||
const meta = BUCKET_META[b];
|
||||
const Icon = meta.icon;
|
||||
const n = counts[b];
|
||||
if (n === 0) return null;
|
||||
return (
|
||||
<div key={b} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded-md font-mono uppercase tracking-wider text-[10px]"
|
||||
style={{ color: meta.color, border: `1px solid ${meta.color}50` }}
|
||||
>
|
||||
<Icon className="w-3 h-3" />
|
||||
{meta.label} ({n})
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => applyToBucket(b, "replace")}
|
||||
className="text-[var(--color-fg-dim)] hover:text-[var(--color-mint)] underline-offset-2 hover:underline"
|
||||
>
|
||||
replace all
|
||||
</button>
|
||||
<span className="text-[var(--color-fg-muted)]">/</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => applyToBucket(b, "skip")}
|
||||
className="text-[var(--color-fg-dim)] hover:text-[var(--color-coral)] underline-offset-2 hover:underline"
|
||||
>
|
||||
skip all
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => applyToBucket("all", "replace")}
|
||||
className="text-[var(--color-fg-dim)] hover:text-[var(--color-fg)]"
|
||||
>
|
||||
Replace all
|
||||
</button>
|
||||
<span className="text-[var(--color-fg-muted)]">·</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => applyToBucket("all", "skip")}
|
||||
className="text-[var(--color-fg-dim)] hover:text-[var(--color-fg)]"
|
||||
>
|
||||
Skip all
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-3 py-2 space-y-1">
|
||||
{items.map((it) => {
|
||||
const meta = BUCKET_META[it.bucket];
|
||||
const Icon = meta.icon;
|
||||
const decision = decisions[it.jobId];
|
||||
const incomingBigger = it.incomingWidth * it.incomingHeight > it.existingWidth * it.existingHeight;
|
||||
return (
|
||||
<div
|
||||
key={it.jobId}
|
||||
className="rounded-lg border border-[var(--color-glass-border)] bg-[var(--color-bg-1)]/40 px-3 py-2"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded font-mono uppercase tracking-wider text-[9px]"
|
||||
style={{ color: meta.color, border: `1px solid ${meta.color}50` }}
|
||||
>
|
||||
<Icon className="w-2.5 h-2.5" />
|
||||
{meta.label}
|
||||
</span>
|
||||
{it.code && (
|
||||
<span className="text-xs font-mono font-bold text-[var(--color-cyan)]">{it.code}</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDecisions((s) => ({ ...s, [it.jobId]: "replace" }))}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2 py-1 rounded-md text-xs",
|
||||
decision === "replace"
|
||||
? "bg-[var(--color-mint)]/20 text-[var(--color-mint)] ring-1 ring-[var(--color-mint)]/50"
|
||||
: "text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
|
||||
)}
|
||||
>
|
||||
<Replace className="w-3 h-3" />
|
||||
Replace
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDecisions((s) => ({ ...s, [it.jobId]: "skip" }))}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2 py-1 rounded-md text-xs",
|
||||
decision === "skip"
|
||||
? "bg-[var(--color-coral)]/20 text-[var(--color-coral)] ring-1 ring-[var(--color-coral)]/50"
|
||||
: "text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
|
||||
)}
|
||||
>
|
||||
<SkipForward className="w-3 h-3" />
|
||||
Skip
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||
<div className={cn("rounded-md p-2", !incomingBigger && "bg-[var(--color-mint)]/5")}>
|
||||
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)] mb-1">Existing #{it.existingId}</div>
|
||||
<div className="font-mono truncate text-[var(--color-fg-dim)]" title={it.existingFilename}>{it.existingFilename}</div>
|
||||
<div className="text-[var(--color-fg-muted)] tabular-nums mt-0.5">
|
||||
{it.existingWidth}×{it.existingHeight} · {fmtBytes(it.existingBytes)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn("rounded-md p-2", incomingBigger && "bg-[var(--color-mint)]/5")}>
|
||||
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)] mb-1">Incoming</div>
|
||||
<div className="font-mono truncate text-[var(--color-fg-dim)]" title={it.filename}>{it.filename}</div>
|
||||
<div className="text-[var(--color-fg-muted)] tabular-nums mt-0.5">
|
||||
{it.incomingWidth}×{it.incomingHeight} · {fmtBytes(it.incomingBytes)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-3 border-t border-[var(--color-glass-border)] flex items-center justify-between">
|
||||
<div className="text-xs text-[var(--color-fg-muted)]">
|
||||
<span className="text-[var(--color-mint)]">{replaceCount} replace</span>
|
||||
{" · "}
|
||||
<span className="text-[var(--color-coral)]">{skipCount} skip</span>
|
||||
{" · "}
|
||||
replacements move the old file to <span className="font-mono">library/.superseded/</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="text-sm px-3 py-1.5 rounded-lg text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onConfirm(decisions)}
|
||||
className="text-sm px-3 py-1.5 rounded-lg bg-[var(--color-cyan)]/20 text-[var(--color-cyan)] ring-1 ring-[var(--color-cyan)]/50 hover:bg-[var(--color-cyan)]/30"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
"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<typeof parseDroppedFilename>;
|
||||
/** 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<UploadState | null>(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<string, PendingJob>;
|
||||
actressDecisions?: ActressDecisions;
|
||||
linkedActressNames?: Record<string, string>;
|
||||
} | null>(null);
|
||||
|
||||
const runJobs = useCallback(async (
|
||||
jobs: PendingJob[],
|
||||
actressDecisions?: ActressDecisions,
|
||||
linkedActressNames?: Record<string, string>,
|
||||
collisionDecisions?: Record<string, CollisionDecision>,
|
||||
) => {
|
||||
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<void> => {
|
||||
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<string, PendingJob> = {};
|
||||
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<string, { image?: File; nfo?: File }>();
|
||||
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 (
|
||||
<>
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={cn(
|
||||
"relative cursor-pointer rounded-2xl border-2 border-dashed transition-all overflow-hidden group",
|
||||
compact ? "px-4 py-3 flex items-center gap-3" : "px-12 py-7 flex flex-col items-center gap-3 text-center",
|
||||
isDragActive
|
||||
? "border-[var(--color-cyan)] bg-[color-mix(in_oklch,var(--color-cyan)_8%,transparent)] shadow-[var(--shadow-glow-cyan)]"
|
||||
: "border-[var(--color-glass-border-strong)] hover:border-[var(--color-cyan)] hover:bg-[var(--color-glass)]",
|
||||
)}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
{state ? (
|
||||
<>
|
||||
{inProgress ? (
|
||||
<Loader2 className={cn("text-[var(--color-cyan)] animate-spin", compact ? "w-5 h-5" : "w-8 h-8")} />
|
||||
) : state.failed.length > 0 ? (
|
||||
<AlertCircle className={cn("text-[var(--color-coral)]", compact ? "w-5 h-5" : "w-8 h-8")} />
|
||||
) : (
|
||||
<CheckCircle2 className={cn("text-[var(--color-mint)]", compact ? "w-5 h-5" : "w-8 h-8")} />
|
||||
)}
|
||||
<div className={compact ? "text-sm" : ""}>
|
||||
<div className="font-medium">{state.done} / {state.total} processed</div>
|
||||
<div className="text-xs text-[var(--color-fg-muted)] mt-0.5 flex items-center gap-2 justify-center flex-wrap">
|
||||
<span className="text-[var(--color-mint)]">{addedCount} added</span>
|
||||
{dupCount > 0 && <span>· {dupCount} already in library</span>}
|
||||
{state.failed.length > 0 && <span className="text-[var(--color-coral)]">· {state.failed.length} failed</span>}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UploadCloud className={cn("text-[var(--color-fg-dim)] group-hover:text-[var(--color-cyan)] transition-colors", compact ? "w-5 h-5" : "w-10 h-10")} />
|
||||
<div>
|
||||
<div className={cn("font-medium", compact ? "text-sm" : "text-base")}>
|
||||
{isDragActive ? "Drop To Import" : "Drop Covers Here"}
|
||||
</div>
|
||||
{!compact && (
|
||||
<div className="text-sm text-[var(--color-fg-muted)] mt-1">
|
||||
JPG, PNG, WEBP — drop a matching <span className="font-mono">.nfo</span> alongside to auto-fill metadata.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-[var(--color-glass-border)]">
|
||||
<div
|
||||
className="h-full bg-[var(--color-cyan)] transition-all"
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{state && state.finished && (
|
||||
<ImportSummaryModal
|
||||
addedCount={addedCount}
|
||||
duplicates={state.duplicates}
|
||||
failed={state.failed}
|
||||
showDuplicates={showDuplicates}
|
||||
onToggleDuplicates={() => setShowDuplicates((s) => !s)}
|
||||
copied={copied}
|
||||
onCopyFailures={copyFailures}
|
||||
onExportFailures={exportFailures}
|
||||
onClose={() => setState(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{preview && (
|
||||
<IngestPreviewDialog
|
||||
files={preview.files}
|
||||
lookup={preview.lookup}
|
||||
onCancel={() => 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 && (
|
||||
<CollisionReviewDialog
|
||||
items={collisionReview.items}
|
||||
onCancel={() => 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(
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 backdrop-blur-sm"
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div className="relative w-[min(95vw,720px)] max-h-[85vh] flex flex-col rounded-2xl border border-[var(--color-glass-border-strong)] bg-[var(--color-bg-0)] shadow-2xl">
|
||||
<div className="px-5 py-4 border-b border-[var(--color-glass-border)] flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
{tone === "ok" ? (
|
||||
<CheckCircle2 className="w-6 h-6 text-[var(--color-mint)] shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<AlertCircle className="w-6 h-6 text-[var(--color-coral)] shrink-0 mt-0.5" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="text-base font-medium">Import complete · {total} file{total === 1 ? "" : "s"}</div>
|
||||
<div className="text-xs text-[var(--color-fg-muted)] mt-0.5 flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[var(--color-mint)] font-medium">{addedCount} added</span>
|
||||
{dupCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleDuplicates}
|
||||
className="hover:text-[var(--color-fg)] underline-offset-2 hover:underline"
|
||||
title="Toggle duplicate list"
|
||||
>
|
||||
· {dupCount} already in library
|
||||
</button>
|
||||
)}
|
||||
{failed.length > 0 && (
|
||||
<span className="text-[var(--color-coral)] font-medium">· {failed.length} failed</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-[var(--color-fg-dim)] mt-2 leading-relaxed">{narrative}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] shrink-0"
|
||||
title="Dismiss"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(failed.length > 0 || (showDuplicates && dupCount > 0)) && (
|
||||
<div className="overflow-y-auto px-5 py-3 space-y-3">
|
||||
{failed.length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-[11px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">
|
||||
Failed ({failed.length})
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopyFailures}
|
||||
className="flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-md border border-[var(--color-glass-border)] hover:bg-[var(--color-glass)] text-[var(--color-fg-dim)] hover:text-[var(--color-fg)]"
|
||||
title="Copy failed filenames + reasons to clipboard"
|
||||
>
|
||||
{copied ? <Check className="w-3.5 h-3.5 text-[var(--color-mint)]" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExportFailures}
|
||||
className="flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-md border border-[var(--color-glass-border)] hover:bg-[var(--color-glass)] text-[var(--color-fg-dim)] hover:text-[var(--color-fg)]"
|
||||
title="Download failed list as a .tsv file"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-60 overflow-y-auto text-xs font-mono space-y-0.5 rounded-md border border-[var(--color-glass-border)] p-2">
|
||||
{failed.map((f) => (
|
||||
<div key={f.filename} className="flex items-baseline gap-2 text-[var(--color-fg-dim)]">
|
||||
<AlertCircle className="w-3 h-3 text-[var(--color-coral)] shrink-0 translate-y-0.5" />
|
||||
<span className="truncate text-[var(--color-fg)]">{f.filename}</span>
|
||||
<span className="text-[var(--color-fg-muted)] truncate">— {f.reason}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{showDuplicates && dupCount > 0 && (
|
||||
<section>
|
||||
<div className="text-[11px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)] mb-2">
|
||||
Already in library ({dupCount})
|
||||
</div>
|
||||
<div className="max-h-72 overflow-y-auto text-xs space-y-1 rounded-md border border-[var(--color-glass-border)] p-2">
|
||||
{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 (
|
||||
<div key={d.filename} className="flex items-start gap-2 font-mono text-[var(--color-fg-dim)]">
|
||||
<span className="text-[10px] uppercase tracking-wider text-[var(--color-cyan)] shrink-0 w-20 truncate translate-y-0.5" title={d.code ?? "no code"}>
|
||||
{d.code ?? "—"}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[var(--color-fg)]">{d.filename}</div>
|
||||
{merges.length > 0 ? (
|
||||
<div className="text-[var(--color-mint)]/80 text-[11px] mt-0.5">
|
||||
+ {merges.join(" · ")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[var(--color-fg-muted)] text-[11px] mt-0.5 italic">
|
||||
no new metadata to merge
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-[11px] text-[var(--color-fg-muted)] mt-2 leading-relaxed">
|
||||
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 <span className="font-mono">INSERT OR IGNORE</span>, so previous links are untouched.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-5 py-3 border-t border-[var(--color-glass-border)] flex items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-sm px-4 py-1.5 rounded-lg bg-[var(--color-cyan)]/20 text-[var(--color-cyan)] ring-1 ring-[var(--color-cyan)]/50 hover:bg-[var(--color-cyan)]/30"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
"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<string, Record<string, ActressDecision>>;
|
||||
|
||||
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<StatusFilter>("all");
|
||||
const lookupMap = useMemo(() => {
|
||||
const m = new Map<string, ActressLookupResult>();
|
||||
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<ActressDecisions>(() => {
|
||||
const init: ActressDecisions = {};
|
||||
for (const f of files) {
|
||||
if (f.actresses.length === 0) continue;
|
||||
const perFile: Record<string, ActressDecision> = {};
|
||||
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<string, ActressDecision> = { ...(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(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center px-4 py-6 bg-black/50 fade-in"
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) onCancel(); }}
|
||||
>
|
||||
<div className="bg-[var(--color-bg-0)] border border-[var(--color-glass-border)] shadow-2xl rounded-2xl w-[min(720px,calc(100vw-32px))] max-h-[calc(100vh-48px)] flex flex-col">
|
||||
<div className="flex items-center justify-between p-5 border-b border-[var(--color-glass-border)]">
|
||||
<div>
|
||||
<div className="text-base font-medium">Confirm Import</div>
|
||||
<div className="text-xs text-[var(--color-fg-muted)] mt-0.5">
|
||||
{files.length} file{files.length === 1 ? "" : "s"} · review actress assignments below
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onCancel} className="text-[var(--color-fg-muted)] hover:text-[var(--color-fg)]">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{files.length > INITIAL_RENDER && (
|
||||
<div className="px-5 py-2 border-b border-[var(--color-glass-border)] text-[11px] text-[var(--color-fg-muted)] bg-[var(--color-cyan)]/5">
|
||||
Large batch — first {INITIAL_RENDER} of {files.length} files shown. Bulk actions still apply to <strong className="text-[var(--color-fg)]">all {files.length}</strong>.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-5 py-3 border-b border-[var(--color-glass-border)] flex items-center gap-1.5 text-xs">
|
||||
<span className="font-mono text-[var(--color-fg-muted)] mr-1">Filter:</span>
|
||||
<FilterChip active={statusFilter === "all"} onClick={() => setStatusFilter("all")}>
|
||||
All {files.length}
|
||||
</FilterChip>
|
||||
<FilterChip
|
||||
active={statusFilter === "ok"}
|
||||
onClick={() => setStatusFilter("ok")}
|
||||
kind="ok"
|
||||
disabled={okCount === 0}
|
||||
>
|
||||
OK {okCount}
|
||||
</FilterChip>
|
||||
<FilterChip
|
||||
active={statusFilter === "errors"}
|
||||
onClick={() => setStatusFilter("errors")}
|
||||
kind="error"
|
||||
disabled={errorCount === 0}
|
||||
>
|
||||
Errors {errorCount}
|
||||
</FilterChip>
|
||||
</div>
|
||||
|
||||
{counts.total > 0 && (
|
||||
<div className="px-5 py-3 border-b border-[var(--color-glass-border)] flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="font-mono text-[var(--color-fg-muted)] mr-1">Bulk:</span>
|
||||
{counts.matched > 0 && (
|
||||
<BulkButton active={bulkState.matchedAllLink} onClick={() => bulkApply("matched", "link")}>
|
||||
Link all matched ({counts.matched})
|
||||
</BulkButton>
|
||||
)}
|
||||
{counts.unmatched > 0 && (
|
||||
<BulkButton active={bulkState.unmatchedAllCreate} onClick={() => bulkApply("unmatched", "create")}>
|
||||
Create all new ({counts.unmatched})
|
||||
</BulkButton>
|
||||
)}
|
||||
<BulkButton active={bulkState.allSkip} onClick={() => bulkApply("all", "skip")}>
|
||||
Skip all
|
||||
</BulkButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-y-auto p-5 space-y-4">
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-center py-8 text-sm text-[var(--color-fg-muted)] italic">
|
||||
No files match this filter.
|
||||
</div>
|
||||
)}
|
||||
{filtered.slice(0, renderLimit).map((f) => (
|
||||
<div
|
||||
key={f.original}
|
||||
className="rounded-xl border border-[var(--color-glass-border)] bg-[var(--color-bg-1)]/30 p-3"
|
||||
style={{ contentVisibility: "auto", containIntrinsicSize: "0 120px" } as React.CSSProperties}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium font-mono truncate">{f.targetFilename}</div>
|
||||
<div className="text-[11px] text-[var(--color-fg-muted)] truncate">from {f.original}</div>
|
||||
</div>
|
||||
{f.code ? (
|
||||
<span className="shrink-0 text-[10px] uppercase tracking-wider font-mono px-2 py-0.5 rounded-full bg-[var(--color-cyan)]/15 text-[var(--color-cyan)] border border-[var(--color-cyan)]/30">
|
||||
{f.code}
|
||||
</span>
|
||||
) : (
|
||||
<span className="shrink-0 text-[10px] uppercase tracking-wider font-mono px-2 py-0.5 rounded-full bg-[var(--color-coral)]/15 text-[var(--color-coral)] border border-[var(--color-coral)]/30 flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" /> No code
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{f.actresses.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
{f.actresses.map((name) => {
|
||||
const key = name.toLowerCase();
|
||||
const lr = lookupMap.get(key);
|
||||
const decision = decisions[f.original]?.[key] ?? "skip";
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-2 text-sm">
|
||||
<div className="flex-1 min-w-0 truncate">{name}</div>
|
||||
{lr?.match ? (
|
||||
<Pill kind="match">match: {lr.match.name}</Pill>
|
||||
) : (
|
||||
<Pill kind="new">new</Pill>
|
||||
)}
|
||||
<DecisionButtons
|
||||
available={lr?.match ? ["link", "skip"] : ["create", "skip"]}
|
||||
value={decision}
|
||||
onChange={(d) => setDecision(f.original, key, d)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-[var(--color-fg-muted)] italic">No actresses parsed.</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{filtered.length > renderLimit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRenderLimit((n) => n + RENDER_INCREMENT)}
|
||||
className="w-full text-xs px-3 py-2 rounded-lg border border-dashed border-[var(--color-glass-border)] text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]"
|
||||
>
|
||||
Showing {renderLimit} of {filtered.length} files — load {Math.min(RENDER_INCREMENT, filtered.length - renderLimit)} more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 p-4 border-t border-[var(--color-glass-border)]">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="text-sm px-3 py-2 rounded-lg glass glass-hover"
|
||||
>
|
||||
Cancel import
|
||||
</button>
|
||||
<button
|
||||
onClick={onSkipActresses}
|
||||
className="text-sm px-3 py-2 rounded-lg glass glass-hover"
|
||||
title="Import the files but don't attach any actresses"
|
||||
>
|
||||
Skip actress assignment
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={() => onConfirm(decisions)}
|
||||
className="text-sm px-4 py-2 rounded-lg bg-[var(--color-cyan)] text-black font-medium"
|
||||
>
|
||||
Confirm import
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"px-2.5 py-1 rounded-md text-xs border transition-colors disabled:opacity-40 disabled:cursor-not-allowed",
|
||||
active
|
||||
? "text-black border-transparent font-medium"
|
||||
: "border-[var(--color-glass-border)] text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
|
||||
)}
|
||||
style={active && !disabled ? { background: accent } : undefined}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function BulkButton({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-2.5 py-1 rounded-md border transition-colors",
|
||||
active
|
||||
? "bg-[var(--color-cyan)] text-black border-transparent font-medium"
|
||||
: "border-[var(--color-glass-border)] text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
|
||||
)}
|
||||
>
|
||||
<Check className={cn("w-3 h-3", active ? "visible" : "invisible")} />
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<span className={cn("text-[10px] uppercase tracking-wider font-mono px-2 py-0.5 rounded-full border", cls)}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex items-center gap-1">
|
||||
{opts
|
||||
.filter((o) => available.includes(o.key))
|
||||
.map(({ key, label, Icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => onChange(key)}
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-[11px] font-mono px-2 py-1 rounded-md border transition-colors",
|
||||
value === key
|
||||
? "bg-[var(--color-cyan)] text-black border-transparent"
|
||||
: "border-[var(--color-glass-border)] text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
|
||||
)}
|
||||
>
|
||||
<Icon className="w-3 h-3" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
import { DropZone } from "./DropZone";
|
||||
|
||||
export interface AutoAssign {
|
||||
tagName?: string;
|
||||
collectionId?: number;
|
||||
}
|
||||
|
||||
export function UploadCard({ autoAssign }: { autoAssign?: AutoAssign } = {}) {
|
||||
return <DropZone autoAssign={autoAssign} />;
|
||||
}
|
||||
Reference in New Issue
Block a user