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 (
+
+
+ }
+ />
+
+
+ {/* 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...
+
+
+
+
+
+ )}
+
+ )
}