From aa3d843a4b52c3c8032593b4c29b41fd581de663 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sat, 4 Jul 2026 19:36:07 +0100 Subject: [PATCH] refactor(duplicate-finder): modularize component Break down the monolithic DuplicateFinder component into smaller, highly cohesive modules to improve maintainability and separation of concerns. - Extract empty, loading, and intro states into dedicated components (`DuplicateFinderEmptyStates`). - Extract the complex header and action logic into `DuplicateFinderHeader`. - Move `DuplicateGroupCard` to its own dedicated component file. - Relocate formatting utilities like byte conversion and relative time strings to a dedicated `format.ts` file. --- src/components/DuplicateFinder.tsx | 303 ++---------------- .../DuplicateFinderEmptyStates.tsx | 36 +++ .../duplicateFinder/DuplicateFinderHeader.tsx | 173 ++++++++++ .../duplicateFinder/DuplicateGroupCard.tsx | 95 ++++++ src/components/duplicateFinder/format.ts | 29 ++ 5 files changed, 365 insertions(+), 271 deletions(-) create mode 100644 src/components/duplicateFinder/DuplicateFinderEmptyStates.tsx create mode 100644 src/components/duplicateFinder/DuplicateFinderHeader.tsx create mode 100644 src/components/duplicateFinder/DuplicateGroupCard.tsx create mode 100644 src/components/duplicateFinder/format.ts diff --git a/src/components/DuplicateFinder.tsx b/src/components/DuplicateFinder.tsx index a6b546d..c0c2ad6 100644 --- a/src/components/DuplicateFinder.tsx +++ b/src/components/DuplicateFinder.tsx @@ -1,120 +1,10 @@ import { useRef, useState } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; -import { DuplicateGroup, useGalleryStore } from "../store"; -import { FolderScopeDropdown } from "./FolderScopeDropdown"; -import { mediaSrc } from "../lib/mediaSrc"; -import { Tooltip } from "./Tooltip"; -import { WarningIcon } from "./icons"; - -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 = mediaSrc(image.thumbnail_path); - 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`; -} +import { useGalleryStore } from "../store"; +import { DuplicateScanEmptyState, DuplicateScanIntroState, DuplicateScanLoadingState } from "./duplicateFinder/DuplicateFinderEmptyStates"; +import { DuplicateFinderHeader } from "./duplicateFinder/DuplicateFinderHeader"; +import { DuplicateGroupCard } from "./duplicateFinder/DuplicateGroupCard"; +import { duplicateProgressLabel } from "./duplicateFinder/format"; export function DuplicateFinder() { const duplicateGroups = useGalleryStore((state) => state.duplicateGroups); @@ -148,12 +38,6 @@ export function DuplicateFinder() { 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); setConfirmingDelete(false); @@ -168,163 +52,40 @@ export function DuplicateFinder() { } }; - const progressPercent = - duplicateScanProgress && duplicateScanProgress.total > 0 - ? Math.round((duplicateScanProgress.processed / duplicateScanProgress.total) * 100) - : 0; - const progressLabel = duplicateScanProgress - ? duplicateScanProgress.phase === "checking" - ? "Checking file sizes" - : duplicateScanProgress.phase === "hashing" - ? "Hashing duplicate candidates" - : "Confirming exact matches" - : null; + const progressLabel = duplicateProgressLabel(duplicateScanProgress); return (
- {/* Header */} -
-
-
-

Duplicate Finder

-

- {duplicateScanning - ? duplicateScanProgress - ? `${progressLabel}… ${duplicateScanProgress.processed.toLocaleString()} / ${duplicateScanProgress.total.toLocaleString()}${duplicateScanProgress.skipped > 0 ? ` · ${duplicateScanProgress.skipped.toLocaleString()} skipped` : ""}` - : "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 - -
- - {confirmingDelete && !deleting ? ( - <> - {/* Click-away backdrop */} -
setConfirmingDelete(false)} /> -
-
- -

Delete from disk

-
-

- Permanently delete {selectedCount} file{selectedCount === 1 ? "" : "s"} from your computer. - This removes the actual file{selectedCount === 1 ? "" : "s"} from disk and cannot be undone. -

-
- - -
-
- - ) : null} -
- - ) : null} - -
-
+ setConfirmingDelete(false)} + onDelete={handleDelete} + onScan={() => { + setDeleteResult(null); + void scanDuplicates(selectedFolderId); + }} + onSelectKeepFirstAll={selectKeepFirstAllGroups} + selectedCount={selectedCount} + setConfirmingDelete={setConfirmingDelete} + /> - {/* Progress bar */} - {duplicateScanning && duplicateScanProgress ? ( -
-
-
- ) : null} - - {duplicateScanError ? ( -

{duplicateScanError}

- ) : null} - {duplicateScanWarning ? ( -

{duplicateScanWarning}

- ) : null} - {deleteResult ? ( -

{deleteResult}

- ) : null} -
- - {/* Body */} {duplicateScanning && !hasResults ? ( -
-
- {progressLabel ? `${progressLabel}…` : "Preparing scan…"} -
+ ) : !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.

-
+ ) : (
diff --git a/src/components/duplicateFinder/DuplicateFinderEmptyStates.tsx b/src/components/duplicateFinder/DuplicateFinderEmptyStates.tsx new file mode 100644 index 0000000..a3a0c9f --- /dev/null +++ b/src/components/duplicateFinder/DuplicateFinderEmptyStates.tsx @@ -0,0 +1,36 @@ +export function DuplicateScanLoadingState({ progressLabel }: { progressLabel: string | null }) { + return ( +
+
+ {progressLabel ? `${progressLabel}…` : "Preparing scan…"} +
+ ); +} + +export function DuplicateScanIntroState() { + return ( +
+
+ + + +

+ 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. +

+
+
+ ); +} + +export function DuplicateScanEmptyState() { + return ( +
+

No duplicate files found.

+
+ ); +} diff --git a/src/components/duplicateFinder/DuplicateFinderHeader.tsx b/src/components/duplicateFinder/DuplicateFinderHeader.tsx new file mode 100644 index 0000000..c5797f0 --- /dev/null +++ b/src/components/duplicateFinder/DuplicateFinderHeader.tsx @@ -0,0 +1,173 @@ +import { DuplicateGroup, DuplicateScanProgress } from "../../store"; +import { FolderScopeDropdown } from "../FolderScopeDropdown"; +import { Tooltip } from "../Tooltip"; +import { WarningIcon } from "../icons"; +import { + duplicateProgressLabel, + duplicateProgressPercent, + formatBytes, + formatRelativeTime, +} from "./format"; + +export function DuplicateFinderHeader({ + confirmingDelete, + deleteResult, + deleting, + duplicateGroups, + duplicateLastScanned, + duplicateScanError, + duplicateScanning, + duplicateScanProgress, + duplicateScanWarning, + hasResults, + hasScanned, + onClearSelection, + onConfirmDelete, + onDelete, + onScan, + onSelectKeepFirstAll, + selectedCount, + setConfirmingDelete, +}: { + confirmingDelete: boolean; + deleteResult: string | null; + deleting: boolean; + duplicateGroups: DuplicateGroup[]; + duplicateLastScanned: number | null; + duplicateScanError: string | null; + duplicateScanning: boolean; + duplicateScanProgress: DuplicateScanProgress | null; + duplicateScanWarning: string | null; + hasResults: boolean; + hasScanned: boolean; + onClearSelection: () => void; + onConfirmDelete: () => void; + onDelete: () => void; + onScan: () => void; + onSelectKeepFirstAll: () => void; + selectedCount: number; + setConfirmingDelete: (value: boolean | ((current: boolean) => boolean)) => void; +}) { + const totalWasted = duplicateGroups.reduce( + (sum, group) => sum + group.file_size * (group.images.length - 1), + 0, + ); + const totalDuplicateImages = duplicateGroups.reduce((sum, group) => sum + group.images.length - 1, 0); + const progressLabel = duplicateProgressLabel(duplicateScanProgress); + const progressPercent = duplicateProgressPercent(duplicateScanProgress); + + return ( +
+
+
+

Duplicate Finder

+

+ {duplicateScanning + ? duplicateScanProgress + ? `${progressLabel}… ${duplicateScanProgress.processed.toLocaleString()} / ${duplicateScanProgress.total.toLocaleString()}${duplicateScanProgress.skipped > 0 ? ` · ${duplicateScanProgress.skipped.toLocaleString()} skipped` : ""}` + : "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)} +

+ ) : null} +
+
+ + {hasResults && selectedCount === 0 && !deleting ? ( + + + + ) : null} + {selectedCount > 0 ? ( + <> + {selectedCount} marked for deletion + +
+ + {confirmingDelete && !deleting ? ( + <> +
+
+
+ +

Delete from disk

+
+

+ Permanently delete {selectedCount} file{selectedCount === 1 ? "" : "s"} from your computer. + This removes the actual file{selectedCount === 1 ? "" : "s"} from disk and cannot be undone. +

+
+ + +
+
+ + ) : null} +
+ + ) : null} + +
+
+ + {duplicateScanning && duplicateScanProgress ? ( +
+
+
+ ) : null} + + {duplicateScanError ? ( +

{duplicateScanError}

+ ) : null} + {duplicateScanWarning ? ( +

{duplicateScanWarning}

+ ) : null} + {deleteResult ? ( +

{deleteResult}

+ ) : null} +
+ ); +} diff --git a/src/components/duplicateFinder/DuplicateGroupCard.tsx b/src/components/duplicateFinder/DuplicateGroupCard.tsx new file mode 100644 index 0000000..deb3195 --- /dev/null +++ b/src/components/duplicateFinder/DuplicateGroupCard.tsx @@ -0,0 +1,95 @@ +import { DuplicateGroup, useGalleryStore } from "../../store"; +import { mediaSrc } from "../../lib/mediaSrc"; +import { Tooltip } from "../Tooltip"; +import { formatBytes } from "./format"; + +export 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((image) => selectedIds.has(image.id)).length; + const noneSelected = groupSelectedCount === 0; + + const handleKeepFirst = () => { + const toDelete = group.images.slice(1).map((image) => image.id); + for (const image of group.images) { + if (selectedIds.has(image.id)) toggleDuplicateSelected(image.id); + } + selectAllDuplicates(toDelete); + }; + + const handleDeselectGroup = () => { + for (const image of group.images) { + if (selectedIds.has(image.id)) toggleDuplicateSelected(image.id); + } + }; + + return ( +
+
+
+ + {group.images.length} copies + + {formatBytes(group.file_size)} each + + {formatBytes(group.file_size * (group.images.length - 1))} wasted + +
+
+ {noneSelected ? ( + + ) : ( + + )} +
+
+ +
+ {group.images.map((image) => { + const isSelected = selectedIds.has(image.id); + const src = mediaSrc(image.thumbnail_path); + return ( + + + + ); + })} +
+
+ ); +} diff --git a/src/components/duplicateFinder/format.ts b/src/components/duplicateFinder/format.ts new file mode 100644 index 0000000..ca176f2 --- /dev/null +++ b/src/components/duplicateFinder/format.ts @@ -0,0 +1,29 @@ +import { DuplicateScanProgress } from "../../store"; + +export 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`; +} + +export 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 duplicateProgressLabel(progress: DuplicateScanProgress | null): string | null { + if (!progress) return null; + if (progress.phase === "checking") return "Checking file sizes"; + if (progress.phase === "hashing") return "Hashing duplicate candidates"; + return "Confirming exact matches"; +} + +export function duplicateProgressPercent(progress: DuplicateScanProgress | null): number { + return progress && progress.total > 0 + ? Math.round((progress.processed / progress.total) * 100) + : 0; +}