Initial commit

This commit is contained in:
admin
2026-05-26 22:46:00 +02:00
commit 7e2c2ff89c
256 changed files with 51523 additions and 0 deletions
+244
View File
@@ -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&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>
);
}