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

256 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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,
);
}