Files
pinkudex/components/ingest/IngestPreviewDialog.tsx
2026-05-26 22:46:00 +02:00

420 lines
16 KiB
TypeScript

"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>
);
}