import { useState } from "react"; import { convertFileSrc } from "@tauri-apps/api/core"; import { DuplicateGroup, useGalleryStore } from "../store"; function formatBytes(bytes: number): string { if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`; if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`; if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`; return `${bytes} B`; } function DuplicateGroupCard({ group }: { group: DuplicateGroup }) { const selectedIds = useGalleryStore((state) => state.duplicateSelectedIds); const toggleDuplicateSelected = useGalleryStore((state) => state.toggleDuplicateSelected); const selectAllDuplicates = useGalleryStore((state) => state.selectAllDuplicates); const groupSelectedCount = group.images.filter((img) => selectedIds.has(img.id)).length; const noneSelected = groupSelectedCount === 0; // "Keep all but the first" — a common quick action const handleKeepFirst = () => { const toDelete = group.images.slice(1).map((img) => img.id); // Clear any selection for this group first, then add the ones to delete for (const img of group.images) { if (selectedIds.has(img.id)) toggleDuplicateSelected(img.id); } selectAllDuplicates(toDelete); }; return (
{/* Group header */}
{group.images.length} copies {formatBytes(group.file_size)} each {formatBytes(group.file_size * (group.images.length - 1))} wasted
{noneSelected ? ( ) : ( )}
{/* Image grid */}
{group.images.map((image) => { const isSelected = selectedIds.has(image.id); const src = image.thumbnail_path ? convertFileSrc(image.thumbnail_path) : null; return ( ); })}
); } function formatRelativeTime(unixSecs: number): string { const diff = Math.floor(Date.now() / 1000) - unixSecs; if (diff < 60) return "just now"; if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; return `${Math.floor(diff / 86400)}d ago`; } export function DuplicateFinder() { const duplicateGroups = useGalleryStore((state) => state.duplicateGroups); const duplicateScanning = useGalleryStore((state) => state.duplicateScanning); const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress); const duplicateScanError = useGalleryStore((state) => state.duplicateScanError); const duplicateSelectedIds = useGalleryStore((state) => state.duplicateSelectedIds); const duplicateLastScanned = useGalleryStore((state) => state.duplicateLastScanned); const selectedFolderId = useGalleryStore((state) => state.selectedFolderId); const scanDuplicates = useGalleryStore((state) => state.scanDuplicates); const clearDuplicateSelection = useGalleryStore((state) => state.clearDuplicateSelection); const selectKeepFirstAllGroups = useGalleryStore((state) => state.selectKeepFirstAllGroups); const deleteSelectedDuplicates = useGalleryStore((state) => state.deleteSelectedDuplicates); const [deleting, setDeleting] = useState(false); const [deleteResult, setDeleteResult] = useState(null); const selectedCount = duplicateSelectedIds.size; const hasResults = duplicateGroups.length > 0; const hasScanned = hasResults || duplicateLastScanned !== null || (!duplicateScanning && duplicateScanProgress !== null); const totalWasted = duplicateGroups.reduce( (sum, g) => sum + g.file_size * (g.images.length - 1), 0, ); const totalDuplicateImages = duplicateGroups.reduce((sum, g) => sum + g.images.length - 1, 0); const handleDelete = async () => { setDeleting(true); setDeleteResult(null); try { const deleted = await deleteSelectedDuplicates(); setDeleteResult(`Deleted ${deleted} file${deleted === 1 ? "" : "s"}.`); } catch (e) { setDeleteResult(String(e)); } finally { setDeleting(false); } }; const progressPercent = duplicateScanProgress && duplicateScanProgress.total > 0 ? Math.round((duplicateScanProgress.scanned / duplicateScanProgress.total) * 100) : 0; return (
{/* Header */}

Duplicate Finder

{duplicateScanning ? duplicateScanProgress ? `Scanning… ${duplicateScanProgress.scanned.toLocaleString()} / ${duplicateScanProgress.total.toLocaleString()}` : "Starting scan…" : hasResults ? `${duplicateGroups.length} group${duplicateGroups.length === 1 ? "" : "s"} · ${formatBytes(totalWasted)} reclaimable` : duplicateLastScanned !== null ? "No duplicates found" : "Scan your library for identical files"}

{!duplicateScanning && duplicateLastScanned !== null && (

Last scanned {formatRelativeTime(duplicateLastScanned)}

)}
{/* Batch select — only shown when there are groups and nothing is selected yet */} {hasResults && selectedCount === 0 && !deleting && ( )} {selectedCount > 0 ? ( <> {selectedCount} marked for deletion ) : null}
{/* Progress bar */} {duplicateScanning && duplicateScanProgress ? (
) : null} {duplicateScanError ? (

{duplicateScanError}

) : null} {deleteResult ? (

{deleteResult}

) : null}
{/* Body */} {duplicateScanning && !hasResults ? (
Hashing files…
) : !hasScanned ? (

Finds files with identical content regardless of filename or location. Click Scan for duplicates to begin.

Large libraries may take a minute — files are hashed from disk.

) : duplicateGroups.length === 0 ? (

No duplicate files found.

) : (
{duplicateGroups.map((group) => ( ))}
)}
); }