Initial commit
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
"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'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user