diff --git a/src/components/assets/duplicate-assets/DuplicateAssetRecord.tsx b/src/components/assets/duplicate-assets/DuplicateAssetRecord.tsx index 1248450..758350f 100644 --- a/src/components/assets/duplicate-assets/DuplicateAssetRecord.tsx +++ b/src/components/assets/duplicate-assets/DuplicateAssetRecord.tsx @@ -16,15 +16,17 @@ interface DuplicateAssetRecordProps { onDeleteRecord: (record: IDuplicateAssetRecord) => void onKeepSelected: (record: IDuplicateAssetRecord, selectedIds: string[], unselectedIds: string[]) => void onKeepAllInRecord: (record: IDuplicateAssetRecord) => void + selectionMode: 'keep' | 'discard' } interface DuplicateAssetItemProps { asset: IDuplicateAsset isSelected: boolean onSelect: (assetId: string, isShiftClick?: boolean) => void + selectionMode: 'keep' | 'discard' } -function DuplicateAssetItem({ asset, isSelected, onSelect }: DuplicateAssetItemProps) { +function DuplicateAssetItem({ asset, isSelected, onSelect, selectionMode }: DuplicateAssetItemProps) { const handleCheckboxChange = (event: React.MouseEvent) => { const isShiftClick = event.shiftKey onSelect(asset.id, isShiftClick) @@ -51,16 +53,30 @@ function DuplicateAssetItem({ asset, isSelected, onSelect }: DuplicateAssetItemP {/* Keep/Trash label */}
- {isSelected ? ( -
- - KEEP -
+ {selectionMode === 'keep' ? ( + isSelected ? ( +
+ + KEEP +
+ ) : ( +
+ + DISCARD +
+ ) ) : ( -
- - TRASH -
+ isSelected ? ( +
+ + DISCARD +
+ ) : ( +
+ + KEEP +
+ ) )}
@@ -120,7 +136,8 @@ export default function DuplicateAssetRecord({ onAssetSelect, onDeleteRecord, onKeepSelected, - onKeepAllInRecord + onKeepAllInRecord, + selectionMode }: DuplicateAssetRecordProps) { const recordAssetIds = record.assets.map(asset => asset.id) const selectedInRecord = recordAssetIds.filter(id => selectedAssets.has(id)).length @@ -162,7 +179,13 @@ export default function DuplicateAssetRecord({ if (selectedAssetIds.length === 0) return - onKeepSelected(record, selectedAssetIds, unselectedAssetIds) + if (selectionMode === 'keep') { + // Keep selected, delete unselected + onKeepSelected(record, selectedAssetIds, unselectedAssetIds) + } else { + // Delete selected, keep unselected + onKeepSelected(record, unselectedAssetIds, selectedAssetIds) + } } return ( @@ -180,14 +203,29 @@ export default function DuplicateAssetRecord({ {selectedInRecord > 0 && (
-
- - {selectedInRecord} to keep ({humanizeBytes(selectedSize)}) -
-
- - {unselectedInRecord} to delete ({humanizeBytes(unselectedSize)} savings) -
+ {selectionMode === 'keep' ? ( + <> +
+ + {selectedInRecord} to keep ({humanizeBytes(selectedSize)}) +
+
+ + {unselectedInRecord} to delete ({humanizeBytes(unselectedSize)} savings) +
+ + ) : ( + <> +
+ + {selectedInRecord} to delete ({humanizeBytes(selectedSize)} savings) +
+
+ + {unselectedInRecord} to keep ({humanizeBytes(unselectedSize)}) +
+ + )}
)} @@ -211,8 +249,11 @@ export default function DuplicateAssetRecord({ {selectedInRecord > 0 && ( - - Keep Selected ({selectedInRecord}) + {selectionMode === 'keep' ? : } + {selectionMode === 'keep' ? 'Keep' : 'Delete'} Selected ({selectedInRecord}) )} @@ -257,6 +301,7 @@ export default function DuplicateAssetRecord({ asset={asset} isSelected={selectedAssets.has(asset.id)} onSelect={onAssetSelect} + selectionMode={selectionMode} /> ))} diff --git a/src/components/assets/duplicate-assets/VirtualizedDuplicateList.tsx b/src/components/assets/duplicate-assets/VirtualizedDuplicateList.tsx index 8e580c3..85b9a15 100644 --- a/src/components/assets/duplicate-assets/VirtualizedDuplicateList.tsx +++ b/src/components/assets/duplicate-assets/VirtualizedDuplicateList.tsx @@ -11,6 +11,7 @@ interface VirtualizedDuplicateListProps { onKeepSelected: (record: IDuplicateAssetRecord, selectedIds: string[], unselectedIds: string[]) => void onKeepAllInRecord: (record: IDuplicateAssetRecord) => void height: number + selectionMode: 'keep' | 'discard' } interface ListItemProps { @@ -23,11 +24,12 @@ interface ListItemProps { onDeleteRecord: (record: IDuplicateAssetRecord) => void onKeepSelected: (record: IDuplicateAssetRecord, selectedIds: string[], unselectedIds: string[]) => void onKeepAllInRecord: (record: IDuplicateAssetRecord) => void + selectionMode: 'keep' | 'discard' } } const ListItem: React.FC = ({ index, style, data }) => { - const { duplicates, selectedAssets, onAssetSelect, onDeleteRecord, onKeepSelected, onKeepAllInRecord } = data + const { duplicates, selectedAssets, onAssetSelect, onDeleteRecord, onKeepSelected, onKeepAllInRecord, selectionMode } = data const record = duplicates[index] return ( @@ -40,6 +42,7 @@ const ListItem: React.FC = ({ index, style, data }) => { onDeleteRecord={onDeleteRecord} onKeepSelected={onKeepSelected} onKeepAllInRecord={onKeepAllInRecord} + selectionMode={selectionMode} /> @@ -68,7 +71,8 @@ export default function VirtualizedDuplicateList({ onDeleteRecord, onKeepSelected, onKeepAllInRecord, - height + height, + selectionMode }: VirtualizedDuplicateListProps) { // Custom item size getter for VariableSizeList const getItemHeight = (index: number) => { @@ -83,7 +87,8 @@ export default function VirtualizedDuplicateList({ onAssetSelect, onDeleteRecord, onKeepSelected, - onKeepAllInRecord + onKeepAllInRecord, + selectionMode } // For better performance with many items, we'll use a custom implementation diff --git a/src/pages/assets/bulk-duplicate-finder.tsx b/src/pages/assets/bulk-duplicate-finder.tsx index 3623ee6..8842b7d 100644 --- a/src/pages/assets/bulk-duplicate-finder.tsx +++ b/src/pages/assets/bulk-duplicate-finder.tsx @@ -6,323 +6,363 @@ import Header from '@/components/shared/Header' import VirtualizedDuplicateList from '@/components/assets/duplicate-assets/VirtualizedDuplicateList' import Loader from '@/components/ui/loader' import { Button } from '@/components/ui/button' -import { RefreshCw, Search } from 'lucide-react' +import { RefreshCw, Search, Shield, Trash2 } from 'lucide-react' import { toast } from '@/components/ui/use-toast' import { humanizeBytes } from '@/helpers/string.helper' export default function BulkDuplicatePage() { - const [duplicates, setDuplicates] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [selectedAssets, setSelectedAssets] = useState>(new Set()); - const [isDeleting, setIsDeleting] = useState(false); - const [lastSelectedIndex, setLastSelectedIndex] = useState(-1); - const [containerHeight, setContainerHeight] = useState(600); - const containerRef = useRef(null); + const [duplicates, setDuplicates] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [selectedAssets, setSelectedAssets] = useState>(new Set()); + const [isDeleting, setIsDeleting] = useState(false); + const [lastSelectedIndex, setLastSelectedIndex] = useState(-1); + const [containerHeight, setContainerHeight] = useState(600); + const [selectionMode, setSelectionMode] = useState<'keep' | 'discard'>('keep'); + const containerRef = useRef(null); - const fetchDuplicates = async () => { - setLoading(true); - setError(null); - try { - const duplicates = await listDuplicates(); - setDuplicates(duplicates); - setSelectedAssets(new Set()); // Clear selection when refetching - } catch (error: any) { - setError(error.message || 'Failed to load duplicate assets'); - toast({ - title: "Error", - description: "Failed to load duplicate assets. Please try again.", - variant: "destructive", - }); - } finally { - setLoading(false); - } + const fetchDuplicates = async () => { + setLoading(true); + setError(null); + try { + const duplicates = await listDuplicates(); + setDuplicates(duplicates); + setSelectedAssets(new Set()); // Clear selection when refetching + } catch (error: any) { + setError(error.message || 'Failed to load duplicate assets'); + toast({ + title: "Error", + description: "Failed to load duplicate assets. Please try again.", + variant: "destructive", + }); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchDuplicates(); + }, []); + + // Handle container height calculation and resize + useEffect(() => { + const updateHeight = () => { + // Calculate available height: full viewport minus header (48px) + const viewportHeight = window.innerHeight; + const headerHeight = 48; // h-12 = 48px + const availableHeight = viewportHeight - headerHeight; + setContainerHeight(availableHeight); }; - useEffect(() => { - fetchDuplicates(); - }, []); + // Initial calculation + updateHeight(); - // Handle container height calculation and resize - useEffect(() => { - const updateHeight = () => { - // Calculate available height: full viewport minus header (48px) - const viewportHeight = window.innerHeight; - const headerHeight = 48; // h-12 = 48px - const availableHeight = viewportHeight - headerHeight; - setContainerHeight(availableHeight); - }; + // Update on window resize + window.addEventListener('resize', updateHeight); - // Initial calculation - updateHeight(); + // Cleanup + return () => { + window.removeEventListener('resize', updateHeight); + }; + }, [duplicates]); // Re-calculate when duplicates change - // Update on window resize - window.addEventListener('resize', updateHeight); - - // Cleanup - return () => { - window.removeEventListener('resize', updateHeight); - }; - }, [duplicates]); // Re-calculate when duplicates change + // Flatten all asset IDs for selection operations + const allAssetIds = useMemo(() => { + return duplicates.flatMap(record => record.assets.map(asset => asset.id)); + }, [duplicates]); - // Flatten all asset IDs for selection operations - const allAssetIds = useMemo(() => { - return duplicates.flatMap(record => record.assets.map(asset => asset.id)); - }, [duplicates]); + const handleAssetSelect = useCallback((assetId: string, isShiftClick?: boolean) => { + setSelectedAssets(prev => { + const newSelection = new Set(prev); - const handleAssetSelect = useCallback((assetId: string, isShiftClick?: boolean) => { - setSelectedAssets(prev => { - const newSelection = new Set(prev); - - if (isShiftClick && lastSelectedIndex >= 0) { - // Find the indices of the current and last selected assets - const currentIndex = allAssetIds.indexOf(assetId); - const start = Math.min(lastSelectedIndex, currentIndex); - const end = Math.max(lastSelectedIndex, currentIndex); - - // Select all assets between the two indices - for (let i = start; i <= end; i++) { - newSelection.add(allAssetIds[i]); - } - setLastSelectedIndex(currentIndex); - } else { - // Regular selection toggle - if (newSelection.has(assetId)) { - newSelection.delete(assetId); - } else { - newSelection.add(assetId); - } - setLastSelectedIndex(allAssetIds.indexOf(assetId)); - } - - return newSelection; + if (isShiftClick && lastSelectedIndex >= 0) { + // Find the indices of the current and last selected assets + const currentIndex = allAssetIds.indexOf(assetId); + const start = Math.min(lastSelectedIndex, currentIndex); + const end = Math.max(lastSelectedIndex, currentIndex); + + // Select all assets between the two indices + for (let i = start; i <= end; i++) { + newSelection.add(allAssetIds[i]); + } + setLastSelectedIndex(currentIndex); + } else { + // Regular selection toggle + if (newSelection.has(assetId)) { + newSelection.delete(assetId); + } else { + newSelection.add(assetId); + } + setLastSelectedIndex(allAssetIds.indexOf(assetId)); + } + + return newSelection; + }); + }, [allAssetIds, lastSelectedIndex]); + + + + const handleDeleteRecord = async (record: IDuplicateAssetRecord) => { + setIsDeleting(true); + try { + const assetIds = record.assets.map(asset => asset.id); + const totalSize = record.assets.reduce((sum, asset) => sum + asset.exifInfo.fileSizeInByte, 0); + await deleteAssets(assetIds); + + // Remove the entire record from duplicates + setDuplicates(prev => prev.filter(r => r.duplicateId !== record.duplicateId)); + + // Remove any selected assets from this record + setSelectedAssets(prev => { + const newSelection = new Set(prev); + assetIds.forEach(id => newSelection.delete(id)); + return newSelection; + }); + + setLastSelectedIndex(-1); + + toast({ + title: "Success", + description: `Successfully deleted ${record.assets.length} duplicate assets and saved ${humanizeBytes(totalSize)} of storage.`, + }); + } catch (error: any) { + toast({ + title: "Error", + description: error.message || "Failed to delete assets. Please try again.", + variant: "destructive", + }); + } finally { + setIsDeleting(false); + } + }; + + const handleKeepAllInRecord = useCallback(async (record: IDuplicateAssetRecord) => { + setIsDeleting(true); + try { + const assetIds = record.assets.map(asset => asset.id); + + // Update assets to remove duplicateId (set to null) + await updateAssets({ + ids: assetIds, + duplicateId: null + }); + + // Remove the entire record from duplicates + setDuplicates(prev => prev.filter(r => r.duplicateId !== record.duplicateId)); + + // Remove any selected assets from this record + setSelectedAssets(prev => { + const newSelection = new Set(prev); + assetIds.forEach(id => newSelection.delete(id)); + return newSelection; + }); + + setLastSelectedIndex(-1); + + toast({ + title: "Success", + description: `Successfully kept ${record.assets.length} assets. They will no longer appear as duplicates.`, + }); + } catch (error: any) { + toast({ + title: "Error", + description: error.message || "Failed to keep assets. Please try again.", + variant: "destructive", + }); + } finally { + setIsDeleting(false); + } + }, [duplicates]); + + const handleKeepSelected = async (record: IDuplicateAssetRecord, selectedIds: string[], unselectedIds: string[]) => { + setIsDeleting(true); + try { + const unselectedSize = record.assets + .filter(asset => unselectedIds.includes(asset.id)) + .reduce((sum, asset) => sum + asset.exifInfo.fileSizeInByte, 0); + + // First, update selected assets to remove duplicateId (mark as kept) + if (selectedIds.length > 0) { + await updateAssets({ + ids: selectedIds, + duplicateId: null }); - }, [allAssetIds, lastSelectedIndex]); + } + + // Then, delete unselected assets + if (unselectedIds.length > 0) { + await deleteAssets(unselectedIds); + } + + // Remove the entire record from duplicates (since selected assets are no longer duplicates) + setDuplicates(prev => prev.filter(r => r.duplicateId !== record.duplicateId)); + + // Remove all assets from this record from selection + setSelectedAssets(prev => { + const newSelection = new Set(prev); + [...selectedIds, ...unselectedIds].forEach(id => newSelection.delete(id)); + return newSelection; + }); + + setLastSelectedIndex(-1); + + toast({ + title: "Success", + description: `Successfully kept ${selectedIds.length} assets and deleted ${unselectedIds.length} duplicates. Storage saved: ${humanizeBytes(unselectedSize)}.`, + }); + } catch (error: any) { + toast({ + title: "Error", + description: error.message || "Failed to process selected assets. Please try again.", + variant: "destructive", + }); + } finally { + setIsDeleting(false); + } + }; - const handleDeleteRecord = async (record: IDuplicateAssetRecord) => { - setIsDeleting(true); - try { - const assetIds = record.assets.map(asset => asset.id); - const totalSize = record.assets.reduce((sum, asset) => sum + asset.exifInfo.fileSizeInByte, 0); - await deleteAssets(assetIds); - - // Remove the entire record from duplicates - setDuplicates(prev => prev.filter(r => r.duplicateId !== record.duplicateId)); - - // Remove any selected assets from this record - setSelectedAssets(prev => { - const newSelection = new Set(prev); - assetIds.forEach(id => newSelection.delete(id)); - return newSelection; - }); - - setLastSelectedIndex(-1); - - toast({ - title: "Success", - description: `Successfully deleted ${record.assets.length} duplicate assets and saved ${humanizeBytes(totalSize)} of storage.`, - }); - } catch (error: any) { - toast({ - title: "Error", - description: error.message || "Failed to delete assets. Please try again.", - variant: "destructive", - }); - } finally { - setIsDeleting(false); - } - }; - - const handleKeepAllInRecord = useCallback(async (record: IDuplicateAssetRecord) => { - setIsDeleting(true); - try { - const assetIds = record.assets.map(asset => asset.id); - - // Update assets to remove duplicateId (set to null) - await updateAssets({ - ids: assetIds, - duplicateId: null - }); - - // Remove the entire record from duplicates - setDuplicates(prev => prev.filter(r => r.duplicateId !== record.duplicateId)); - - // Remove any selected assets from this record - setSelectedAssets(prev => { - const newSelection = new Set(prev); - assetIds.forEach(id => newSelection.delete(id)); - return newSelection; - }); - - setLastSelectedIndex(-1); - - toast({ - title: "Success", - description: `Successfully kept ${record.assets.length} assets. They will no longer appear as duplicates.`, - }); - } catch (error: any) { - toast({ - title: "Error", - description: error.message || "Failed to keep assets. Please try again.", - variant: "destructive", - }); - } finally { - setIsDeleting(false); - } - }, [duplicates]); - - const handleKeepSelected = async (record: IDuplicateAssetRecord, selectedIds: string[], unselectedIds: string[]) => { - setIsDeleting(true); - try { - const unselectedSize = record.assets - .filter(asset => unselectedIds.includes(asset.id)) - .reduce((sum, asset) => sum + asset.exifInfo.fileSizeInByte, 0); - - // First, update selected assets to remove duplicateId (mark as kept) - if (selectedIds.length > 0) { - await updateAssets({ - ids: selectedIds, - duplicateId: null - }); - } - - // Then, delete unselected assets - if (unselectedIds.length > 0) { - await deleteAssets(unselectedIds); - } - - // Remove the entire record from duplicates (since selected assets are no longer duplicates) - setDuplicates(prev => prev.filter(r => r.duplicateId !== record.duplicateId)); - - // Remove all assets from this record from selection - setSelectedAssets(prev => { - const newSelection = new Set(prev); - [...selectedIds, ...unselectedIds].forEach(id => newSelection.delete(id)); - return newSelection; - }); - - setLastSelectedIndex(-1); - - toast({ - title: "Success", - description: `Successfully kept ${selectedIds.length} assets and deleted ${unselectedIds.length} duplicates. Storage saved: ${humanizeBytes(unselectedSize)}.`, - }); - } catch (error: any) { - toast({ - title: "Error", - description: error.message || "Failed to process selected assets. Please try again.", - variant: "destructive", - }); - } finally { - setIsDeleting(false); - } - }; - - - - return ( - -
- - Refresh - - } - /> - -
- {/* Description - only show when loading or no duplicates */} - {(loading || error || duplicates.length === 0) && ( -
-

- Manage and remove duplicate assets from your library -

-
- )} - - {loading && ( -
- - - Scanning for duplicate assets... - -
- )} - - {error && ( -
-
-
- Error: - {error} -
- -
-
- )} - - {!loading && !error && duplicates.length === 0 && ( -
- -

- No duplicates found -

-

- Great! No duplicate assets were detected in your library. -

-
- )} - - {!loading && !error && duplicates.length > 0 && ( -
- -
- )} + return ( + +
+ {/* Selection Mode Button Group */} +
+ + Select to: + +
+ + +
+ + {/* Refresh Button */} + +
+ } + /> + +
+ {/* Description - only show when loading or no duplicates */} + {(loading || error || duplicates.length === 0) && ( +
+

+ Manage and remove duplicate assets from your library +

+
+ )} + + {loading && ( +
+ + + Scanning for duplicate assets... + +
+ )} + + {error && ( +
+
+
+ Error: + {error} +
+ +
+
+ )} + + {!loading && !error && duplicates.length === 0 && ( +
+ +

+ No duplicates found +

+

+ Great! No duplicate assets were detected in your library. +

+
+ )} + + {!loading && !error && duplicates.length > 0 && ( +
+ +
+ )} +
- {isDeleting && ( -
-
-
- -
-

- Deleting Assets -

-

- Removing duplicate assets... -

-
-
-
-
- )} - - ) + {isDeleting && ( +
+
+
+ +
+

+ Deleting Assets +

+

+ Removing duplicate assets... +

+
+
+
+
+ )} + + ) }