import { Download, FolderArchive, Loader2 } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { formatHeaders } from "@/lib/api"; import { formatFileSize } from "@/lib/download"; import type { DuplicateResult } from "@/stores/duplicate-store"; import { useDuplicateStore } from "@/stores/duplicate-store"; import { useFileStore } from "@/stores/file-store"; type Preset = "exact" | "similar" | "loose"; const PRESET_THRESHOLDS: Record = { exact: 2, similar: 8, loose: 14 }; const PRESET_DESCRIPTIONS: Record = { exact: "Pixel-identical copies, same image in different formats.", similar: "Resized, recompressed, or lightly edited copies.", loose: "Visually related images, mild crops, different exposures.", }; export function FindDuplicatesSettings() { const { files } = useFileStore(); const { results, scanning, bestOverrides, setResults, setScanning, reset: resetDuplicates, } = useDuplicateStore(); const [preset, setPreset] = useState("similar"); const [threshold, setThreshold] = useState(8); const [error, setError] = useState(null); const [uploadProgress, setUploadProgress] = useState(0); const xhrRef = useRef(null); // biome-ignore lint/correctness/useExhaustiveDependencies: files is a store value that triggers reset when changed useEffect(() => { resetDuplicates(); setError(null); setUploadProgress(0); }, [files, resetDuplicates]); useEffect(() => { return () => { xhrRef.current?.abort(); }; }, []); const handlePreset = (p: Preset) => { setPreset(p); setThreshold(PRESET_THRESHOLDS[p]); }; const handleSlider = (val: number) => { setThreshold(val); const match = (Object.entries(PRESET_THRESHOLDS) as [Preset, number][]).find( ([, t]) => t === val, ); setPreset(match ? match[0] : null); }; const handleScan = () => { if (files.length < 2) return; setScanning(true); setError(null); setResults(null); setUploadProgress(0); const formData = new FormData(); for (const file of files) { formData.append("file", file); } formData.append("threshold", String(threshold)); const xhr = new XMLHttpRequest(); xhrRef.current = xhr; xhr.upload.onprogress = (e) => { if (e.lengthComputable) { setUploadProgress(Math.round((e.loaded / e.total) * 100)); } }; xhr.onload = () => { xhrRef.current = null; if (xhr.status >= 200 && xhr.status < 300) { try { const data: DuplicateResult = JSON.parse(xhr.responseText); setResults(data); } catch { setError("Failed to parse scan results"); } } else { try { const body = JSON.parse(xhr.responseText); setError(body.error || `Failed: ${xhr.status}`); } catch { setError(`Failed: ${xhr.status}`); } } setScanning(false); }; xhr.onerror = () => { xhrRef.current = null; setError("Network error during upload. Try with fewer files or check connection."); setScanning(false); }; xhr.ontimeout = () => { xhrRef.current = null; setError("Request timed out. Try with fewer files."); setScanning(false); }; xhr.open("POST", "/api/v1/tools/image/find-duplicates"); xhr.timeout = 300_000; const headers = formatHeaders(); headers.forEach((value, key) => { xhr.setRequestHeader(key, value); }); xhr.send(formData); }; const handleDownloadUnique = useCallback(async () => { if (!results) return; const { zipSync } = await import("fflate"); const duplicateFilenames = new Set(); const bestFilenames = new Set(); for (let gi = 0; gi < results.duplicateGroups.length; gi++) { const group = results.duplicateGroups[gi]; const bestIdx = gi in bestOverrides ? bestOverrides[gi] : group.files.findIndex((f) => f.isBest); for (let fi = 0; fi < group.files.length; fi++) { duplicateFilenames.add(group.files[fi].filename); if (fi === bestIdx) bestFilenames.add(group.files[fi].filename); } } const filesToInclude = files.filter( (f) => !duplicateFilenames.has(f.name) || bestFilenames.has(f.name), ); const zipData: Record = {}; for (const file of filesToInclude) { const buf = await file.arrayBuffer(); zipData[file.name] = new Uint8Array(buf); } const zipped = zipSync(zipData); const blob = new Blob([zipped as Uint8Array], { type: "application/zip" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = "unique-files.zip"; a.click(); URL.revokeObjectURL(url); }, [files, results, bestOverrides]); const handleDownloadGrouped = useCallback(async () => { if (!results || results.duplicateGroups.length === 0) return; const { zipSync } = await import("fflate"); const duplicateFilenames = new Set(); const zipData: Record = {}; const usedPaths = new Set(); const uniquePath = (dir: string, name: string): string => { let path = `${dir}/${name}`; if (!usedPaths.has(path)) { usedPaths.add(path); return path; } const dot = name.lastIndexOf("."); const base = dot > 0 ? name.slice(0, dot) : name; const ext = dot > 0 ? name.slice(dot) : ""; let i = 2; while (usedPaths.has(path)) { path = `${dir}/${base}-${i}${ext}`; i++; } usedPaths.add(path); return path; }; for (let gi = 0; gi < results.duplicateGroups.length; gi++) { const group = results.duplicateGroups[gi]; const similarity = Math.max(...group.files.map((f) => f.similarity)); const folderName = `group-${gi + 1}-${similarity}pct`; for (const gf of group.files) { duplicateFilenames.add(gf.filename); const file = files.find((f) => f.name === gf.filename); if (!file) continue; const buf = await file.arrayBuffer(); zipData[uniquePath(folderName, file.name)] = new Uint8Array(buf); } } const uniqueFiles = files.filter((f) => !duplicateFilenames.has(f.name)); for (const file of uniqueFiles) { const buf = await file.arrayBuffer(); zipData[uniquePath("unique", file.name)] = new Uint8Array(buf); } const zipped = zipSync(zipData); const blob = new Blob([zipped as Uint8Array], { type: "application/zip" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = "duplicates-grouped.zip"; a.click(); URL.revokeObjectURL(url); }, [files, results]); const hasFiles = files.length >= 2; const activeDesc = preset ? PRESET_DESCRIPTIONS[preset] : null; const presetBtnClass = (p: Preset) => `flex-1 text-xs py-1.5 rounded ${preset === p ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`; return (
{/* Sensitivity presets */}
Detection Mode
{/* Sensitivity slider */}
{threshold} / 128
handleSlider(Number(e.target.value))} className="w-full mt-1" />
Strict match Broad match
{activeDesc &&

{activeDesc}

} {error &&

{error}

} {/* Scan button + progress */} {!results && ( <> {scanning && (
)} )} {/* Results: summary + actions */} {results && (
{/* Summary stats */}
Total scanned {results.totalImages}
Duplicate groups {results.duplicateGroups.length}
Unique images {results.uniqueImages}
{results.spaceSaveable > 0 && (
Space saveable {formatFileSize(results.spaceSaveable)}
)} {results.skippedFiles && results.skippedFiles.length > 0 && (
Skipped {results.skippedFiles.length}
)}
{results.skippedFiles && results.skippedFiles.length > 0 && (
{results.skippedFiles.length} file{results.skippedFiles.length > 1 ? "s" : ""} could not be analyzed
    {results.skippedFiles.map((sf) => (
  • {sf.filename}
  • ))}
)} {/* Download actions */} {results.duplicateGroups.length > 0 && ( <> )} {/* Re-scan */}
)}
); }