"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) => void; } const BUCKET_META: Record = { 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>(() => { const init: Record = {}; 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 = { 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(
{ if (e.target === e.currentTarget) onCancel(); }} >
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" >
Code collisions detected
{items.length} cover{items.length === 1 ? "" : "s"} share a code with an existing entry. Pick what to do with each.
{(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 (
{meta.label} ({n}) /
); })}
·
{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 (
{meta.label} {it.code && ( {it.code} )}
Existing #{it.existingId}
{it.existingFilename}
{it.existingWidth}×{it.existingHeight} · {fmtBytes(it.existingBytes)}
Incoming
{it.filename}
{it.incomingWidth}×{it.incomingHeight} · {fmtBytes(it.incomingBytes)}
); })}
{replaceCount} replace {" · "} {skipCount} skip {" · "} replacements move the old file to library/.superseded/
, document.body, ); }