From 5e70cf258bc48b4697e3a30dcf1f1d397ae8a1da Mon Sep 17 00:00:00 2001 From: Ben Brooks Date: Sun, 1 Mar 2026 11:55:48 +0000 Subject: [PATCH] feat: add search and album filters to bulk duplicate finder Co-Authored-By: Claude Opus 4.6 --- .../duplicate-assets/AlbumFilterDropdown.tsx | 78 +++++++++ .../VirtualizedDuplicateList.tsx | 10 +- src/pages/assets/bulk-duplicate-finder.tsx | 156 +++++++++++++++--- 3 files changed, 221 insertions(+), 23 deletions(-) create mode 100644 src/components/assets/duplicate-assets/AlbumFilterDropdown.tsx diff --git a/src/components/assets/duplicate-assets/AlbumFilterDropdown.tsx b/src/components/assets/duplicate-assets/AlbumFilterDropdown.tsx new file mode 100644 index 0000000..ef9e277 --- /dev/null +++ b/src/components/assets/duplicate-assets/AlbumFilterDropdown.tsx @@ -0,0 +1,78 @@ +import * as React from "react" +import { Check, FolderOpen } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" + +interface AlbumFilterDropdownProps { + options: { label: string; value: string }[] + selectedIds: Set + onSelectionChange: (ids: Set) => void +} + +export default function AlbumFilterDropdown({ + options, + selectedIds, + onSelectionChange, +}: AlbumFilterDropdownProps) { + const [open, setOpen] = React.useState(false) + + const handleSelect = (value: string) => { + const next = new Set(selectedIds) + if (next.has(value)) { + next.delete(value) + } else { + next.add(value) + } + onSelectionChange(next) + } + + return ( + + + + + + + + + No albums found. + + {options.map((option) => ( + handleSelect(option.value)} + > + + {option.label} + + ))} + + + + + + ) +} diff --git a/src/components/assets/duplicate-assets/VirtualizedDuplicateList.tsx b/src/components/assets/duplicate-assets/VirtualizedDuplicateList.tsx index 14e8be4..1d709b8 100644 --- a/src/components/assets/duplicate-assets/VirtualizedDuplicateList.tsx +++ b/src/components/assets/duplicate-assets/VirtualizedDuplicateList.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react' +import React, { useEffect, useRef } from 'react' import { VariableSizeList as List } from 'react-window' import { IDuplicateAssetRecord } from '@/types/asset' import { IAssetAlbumInfo } from '@/handlers/api/asset.handler' @@ -79,6 +79,13 @@ export default function VirtualizedDuplicateList({ selectionMode, assetAlbums }: VirtualizedDuplicateListProps) { + const listRef = useRef(null) + + // Reset cached row heights when the duplicates list changes (e.g. after filtering) + useEffect(() => { + listRef.current?.resetAfterIndex(0) + }, [duplicates]) + // Custom item size getter for VariableSizeList const getItemHeight = (index: number) => { if (index >= duplicates.length) return 0 @@ -107,6 +114,7 @@ export default function VirtualizedDuplicateList({ ) : ( >({}); const [albumTransferMode, setAlbumTransferMode] = useState('always'); const [pendingDedup, setPendingDedup] = useState(null); + const [searchInputText, setSearchInputText] = useState(''); + const [searchText, setSearchText] = useState(''); + const [selectedAlbumIds, setSelectedAlbumIds] = useState>(new Set()); const containerRef = useRef(null); // Load album move mode from localStorage @@ -46,6 +51,12 @@ export default function BulkDuplicatePage() { } }, []); + // Debounce search text + useEffect(() => { + const timer = setTimeout(() => setSearchText(searchInputText), 200); + return () => clearTimeout(timer); + }, [searchInputText]); + const handleAlbumTransferModeChange = (mode: AlbumTransferMode) => { setAlbumTransferMode(mode); localStorage.setItem('duplicateFinderAlbumTransfer', mode); @@ -58,6 +69,9 @@ export default function BulkDuplicatePage() { const duplicates = await listDuplicates(); setDuplicates(duplicates); setSelectedAssets(new Set()); // Clear selection when refetching + setSearchInputText(''); + setSearchText(''); + setSelectedAlbumIds(new Set()); // Fetch album data for all assets const allIds = duplicates.flatMap((r: IDuplicateAssetRecord) => r.assets.map(a => a.id)); @@ -86,10 +100,11 @@ export default function BulkDuplicatePage() { // Handle container height calculation and resize useEffect(() => { const updateHeight = () => { - // Calculate available height: full viewport minus header (48px) + // Calculate available height: full viewport minus header (48px) and filter bar (48px) const viewportHeight = window.innerHeight; const headerHeight = 48; // h-12 = 48px - const availableHeight = viewportHeight - headerHeight; + const filterBarHeight = duplicates.length > 0 ? 48 : 0; + const availableHeight = viewportHeight - headerHeight - filterBarHeight; setContainerHeight(availableHeight); }; @@ -121,10 +136,67 @@ export default function BulkDuplicatePage() { }; }, [selectedAssets.size]); - // Flatten all asset IDs for selection operations + // Derive unique album options from assetAlbums + const allAlbumOptions = useMemo(() => { + const seen = new Map(); + Object.values(assetAlbums).forEach(albums => { + albums.forEach(a => { + if (!seen.has(a.albumId)) { + seen.set(a.albumId, a.albumName); + } + }); + }); + return Array.from(seen, ([value, label]) => ({ label, value })) + .sort((a, b) => a.label.localeCompare(b.label)); + }, [assetAlbums]); + + const hasActiveFilters = searchText.length > 0 || selectedAlbumIds.size > 0; + + // Filter duplicates based on search text and album selection + const filteredDuplicates = useMemo(() => { + if (!hasActiveFilters) return duplicates; + + const lowerSearch = searchText.toLowerCase(); + + return duplicates.filter(record => + record.assets.some(asset => { + // Text filter + if (searchText.length > 0) { + const fields = [ + asset.originalFileName, + asset.originalPath, + asset.exifInfo?.city, + asset.exifInfo?.state, + asset.exifInfo?.country, + asset.exifInfo?.description, + ]; + const textMatch = fields.some( + f => f && f.toLowerCase().includes(lowerSearch) + ); + if (!textMatch) return false; + } + + // Album filter + if (selectedAlbumIds.size > 0) { + const assetAlbumList = assetAlbums[asset.id] || []; + const albumMatch = assetAlbumList.some(a => selectedAlbumIds.has(a.albumId)); + if (!albumMatch) return false; + } + + return true; + }) + ); + }, [duplicates, searchText, selectedAlbumIds, assetAlbums]); + + // Reset lastSelectedIndex when filter inputs change + useEffect(() => { + setLastSelectedIndex(-1); + }, [searchText, selectedAlbumIds]); + + // Flatten all asset IDs for selection operations (from filtered view) const allAssetIds = useMemo(() => { - return duplicates.flatMap(record => record.assets.map(asset => asset.id)); - }, [duplicates]); + return filteredDuplicates.flatMap(record => record.assets.map(asset => asset.id)); + }, [filteredDuplicates]); // Calculate selected assets info for discard mode const selectedAssetsInfo = useMemo(() => { @@ -252,8 +324,8 @@ export default function BulkDuplicatePage() { const handleAutoSelect = (criteria: 'lowest-quality' | 'smallest-size') => { const newSelection = new Set(); - - duplicates.forEach(record => { + + filteredDuplicates.forEach(record => { if (record.assets.length <= 1) return; // Skip if only one asset let selectedAsset = record.assets[0]; @@ -413,7 +485,7 @@ export default function BulkDuplicatePage() { // In discard mode, the selected assets are discarded, the rest are kept const keptIds: string[] = []; - duplicates.forEach(record => { + filteredDuplicates.forEach(record => { record.assets.forEach(asset => { if (!selectedAssets.has(asset.id)) { keptIds.push(asset.id); @@ -570,19 +642,59 @@ export default function BulkDuplicatePage() { )} {!loading && !error && duplicates.length > 0 && ( -
- -
+ <> + {/* Filter bar */} +
+
+ + setSearchInputText(e.target.value)} + className="w-64 pl-8 h-8 text-sm" + /> +
+ +
+ + {hasActiveFilters + ? `Showing ${filteredDuplicates.length} of ${duplicates.length} groups` + : `${duplicates.length} groups`} + + {hasActiveFilters && ( + + )} +
+
+ +
+ )}