Files
2026-05-26 22:46:00 +02:00

245 lines
10 KiB
TypeScript
Raw Permalink 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 { useState, useTransition } from "react";
import Link from "next/link";
import { Copy, Loader2, AlertTriangle, ExternalLink } from "lucide-react";
import {
previewNearDupes,
backfillPhashes,
findNearDuplicates,
type NearDupePair,
type NearDupesPreview,
} from "@/app/actions/maintenance";
import { thumbUrl } from "@/lib/assetUrls";
import { useSettingsPanel } from "./SettingsPanelProvider";
type State =
| { kind: "idle" }
| { kind: "scanning" }
| { kind: "preview"; data: NearDupesPreview }
| { kind: "backfilling" }
| { kind: "running" }
| { kind: "result"; pairs: NearDupePair[] };
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 NearDupesButton() {
const [state, setState] = useState<State>({ kind: "idle" });
const [pending, start] = useTransition();
const [threshold, setThreshold] = useState(10);
const { close: closeSettings } = useSettingsPanel();
const scan = () => {
setState({ kind: "scanning" });
start(async () => {
const data = await previewNearDupes();
setState({ kind: "preview", data });
});
};
const backfillAndFind = async () => {
setState({ kind: "backfilling" });
await backfillPhashes();
setState({ kind: "running" });
const pairs = await findNearDuplicates({ threshold });
setState({ kind: "result", pairs });
};
const findOnly = async () => {
setState({ kind: "running" });
const pairs = await findNearDuplicates({ threshold });
setState({ kind: "result", pairs });
};
return (
<div className="py-2">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="text-sm font-medium">Find Near-Duplicate Covers</div>
<div className="text-xs text-[var(--color-fg-muted)] mt-0.5">
Compares perceptual hashes of every cover and surfaces pairs that look the same but
aren&apos;t SHA-identical different encodes, mild crops, or upscales of the same image.
Complements the SHA dedup and code-collision detectors.
</div>
{state.kind === "preview" && (
<div className="text-xs mt-2 text-[var(--color-fg-dim)] space-y-2">
<div>
<span className="font-mono text-[var(--color-cyan)]">{state.data.hashed}</span> hashed
{" · "}
<span className="font-mono text-[var(--color-amber,#fbbf24)]">{state.data.unhashed}</span> need backfill
{" · "}of {state.data.total} covers
</div>
<div className="flex items-center gap-2">
<label className="font-mono text-[10px] uppercase tracking-wider text-[var(--color-fg-muted)]">
Threshold
</label>
<input
type="range"
min={0}
max={20}
step={1}
value={threshold}
onChange={(e) => setThreshold(Number(e.target.value))}
className="flex-1 max-w-[200px] accent-[var(--color-cyan)]"
/>
<span className="font-mono text-[var(--color-cyan)] tabular-nums w-8">{threshold}</span>
</div>
<div className="text-[11px] text-[var(--color-fg-muted)]">
0 = identical · 5 = very tight · 10 = robust default · 20 = noisy
</div>
</div>
)}
{state.kind === "result" && (
<div className="text-xs mt-2 flex items-center gap-1.5">
{state.pairs.length === 0 ? (
<span className="text-[var(--color-mint)]">No near-duplicate pairs found at this threshold.</span>
) : (
<span className="text-[var(--color-coral)] flex items-center gap-1.5">
<AlertTriangle className="w-3.5 h-3.5" />
{state.pairs.length} near-duplicate pair{state.pairs.length === 1 ? "" : "s"} (distance {threshold}).
</span>
)}
</div>
)}
</div>
<div className="flex-shrink-0 flex items-center gap-2">
{state.kind === "idle" && (
<button
onClick={scan}
disabled={pending}
className="inline-flex items-center justify-center gap-1.5 min-w-[100px] text-xs px-3 py-1.5 rounded-lg glass glass-hover text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] whitespace-nowrap"
>
<Copy className="w-3.5 h-3.5" /> Scan
</button>
)}
{(state.kind === "scanning" || state.kind === "backfilling" || state.kind === "running") && (
<span className="flex items-center gap-1.5 text-xs text-[var(--color-fg-dim)]">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
{state.kind === "scanning" ? "Counting…" : state.kind === "backfilling" ? "Hashing…" : "Comparing…"}
</span>
)}
{state.kind === "preview" && (
<>
<button
onClick={() => setState({ kind: "idle" })}
className="inline-flex items-center justify-center min-w-[80px] text-xs px-3 py-1.5 rounded-lg text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]"
>
Cancel
</button>
{state.data.unhashed > 0 ? (
<button
onClick={() => start(() => backfillAndFind())}
disabled={pending}
className="inline-flex items-center justify-center gap-1.5 min-w-[100px] text-xs px-3 py-1.5 rounded-lg bg-[var(--color-cyan)]/15 text-[var(--color-cyan)] border border-[var(--color-cyan)]/40 hover:bg-[var(--color-cyan)]/25 disabled:opacity-40 whitespace-nowrap"
>
Hash {state.data.unhashed} & find pairs
</button>
) : (
<button
onClick={() => start(() => findOnly())}
disabled={pending}
className="inline-flex items-center justify-center gap-1.5 min-w-[100px] text-xs px-3 py-1.5 rounded-lg bg-[var(--color-cyan)]/15 text-[var(--color-cyan)] border border-[var(--color-cyan)]/40 hover:bg-[var(--color-cyan)]/25 disabled:opacity-40 whitespace-nowrap"
>
Find pairs
</button>
)}
</>
)}
{state.kind === "result" && (
<>
<button
onClick={scan}
disabled={pending}
className="inline-flex items-center justify-center gap-1.5 min-w-[100px] text-xs px-3 py-1.5 rounded-lg glass glass-hover text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] whitespace-nowrap"
>
Re-scan
</button>
<button
onClick={() => setState({ kind: "idle" })}
className="inline-flex items-center justify-center min-w-[80px] text-xs px-3 py-1.5 rounded-lg text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]"
>
Dismiss
</button>
</>
)}
</div>
</div>
{state.kind === "result" && state.pairs.length > 0 && (
<div className="mt-3 max-h-96 overflow-y-auto rounded-md border border-[var(--color-glass-border)] bg-[var(--color-bg-1)]/40 divide-y divide-[var(--color-glass-border)]">
{state.pairs.map((p) => {
const aBigger = p.a.width * p.a.height >= p.b.width * p.b.height;
const aMoreBytes = p.a.bytes >= p.b.bytes;
return (
<div key={`${p.a.id}-${p.b.id}`} className="p-2">
<div className="flex items-center gap-2 mb-1.5">
<span className="font-mono text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-[var(--color-coral)]/15 text-[var(--color-coral)]">
Δ {p.distance}
</span>
<span className="text-[11px] text-[var(--color-fg-muted)]">
Hamming distance lower = more similar
</span>
</div>
<div className="grid grid-cols-2 gap-2">
<DupeCell side={p.a} bigger={aBigger} moreBytes={aMoreBytes} onNavigate={closeSettings} />
<DupeCell side={p.b} bigger={!aBigger} moreBytes={!aMoreBytes} onNavigate={closeSettings} />
</div>
</div>
);
})}
</div>
)}
</div>
);
}
function DupeCell({
side,
bigger,
moreBytes,
onNavigate,
}: {
side: NearDupePair["a"];
bigger: boolean;
moreBytes: boolean;
onNavigate: () => void;
}) {
return (
<Link
href={`/image/${side.id}`}
onClick={onNavigate}
className="flex items-center gap-2 rounded-md p-2 hover:bg-[var(--color-glass)] transition-colors"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={thumbUrl({ thumbPath: side.thumbPath, code: side.code, id: side.id })}
alt=""
className="w-12 h-12 object-contain bg-black/40 rounded shrink-0"
loading="lazy"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-xs">
{side.code ? (
<span className="font-mono font-bold text-[var(--color-cyan)]">{side.code}</span>
) : (
<span className="font-mono text-[var(--color-fg-muted)] italic">no code</span>
)}
<span className={`font-mono tabular-nums text-[11px] ${bigger ? "text-[var(--color-mint)]" : "text-[var(--color-fg-muted)]"}`}>
{side.width}×{side.height}
</span>
<span className={`font-mono tabular-nums text-[11px] ${moreBytes ? "text-[var(--color-mint)]" : "text-[var(--color-fg-muted)]"}`}>
{fmtBytes(side.bytes)}
</span>
</div>
<div className="text-[11px] text-[var(--color-fg-dim)] truncate font-mono mt-0.5">
{side.filename}
</div>
</div>
<ExternalLink className="w-3.5 h-3.5 text-[var(--color-fg-muted)] shrink-0" />
</Link>
);
}