feat: implement selection mode for managing duplicate assets
- Added a `selectionMode` prop to `DuplicateAssetRecord`, `DuplicateAssetItem`, and `VirtualizedDuplicateList` components to toggle between "keep" and "discard" modes. - Updated UI elements to reflect the current selection mode, including button states and labels. - Enhanced the logic for keeping or deleting selected assets based on the selected mode in the `BulkDuplicatePage` component. - Improved user experience by providing clear visual feedback for asset selection actions.
This commit is contained in:
1 parent
6259481d3e
commit
afb2fb09d4
3 files changed
+416
-326
No files matched your search
@@ -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 */}
|
||||
<div className="absolute top-2 right-2">
|
||||
{isSelected ? (
|
||||
<div className="bg-green-600 text-white text-xs px-2 py-1 rounded font-bold flex items-center gap-1">
|
||||
<Shield size={12} />
|
||||
KEEP
|
||||
</div>
|
||||
{selectionMode === 'keep' ? (
|
||||
isSelected ? (
|
||||
<div className="bg-green-600 text-white text-xs px-2 py-1 rounded font-bold flex items-center gap-1">
|
||||
<Shield size={12} />
|
||||
KEEP
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-red-600 text-white text-xs px-2 py-1 rounded font-bold flex items-center gap-1">
|
||||
<Trash2 size={12} />
|
||||
DISCARD
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="bg-red-600 text-white text-xs px-2 py-1 rounded font-bold flex items-center gap-1">
|
||||
<Trash2 size={12} />
|
||||
TRASH
|
||||
</div>
|
||||
isSelected ? (
|
||||
<div className="bg-red-600 text-white text-xs px-2 py-1 rounded font-bold flex items-center gap-1">
|
||||
<Trash2 size={12} />
|
||||
DISCARD
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-green-600 text-white text-xs px-2 py-1 rounded font-bold flex items-center gap-1">
|
||||
<Shield size={12} />
|
||||
KEEP
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
{selectedInRecord > 0 && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className="flex items-center gap-1 text-green-600 dark:text-green-400 font-medium">
|
||||
<Shield size={16} />
|
||||
{selectedInRecord} to keep ({humanizeBytes(selectedSize)})
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-red-600 dark:text-red-400 font-medium">
|
||||
<Trash2 size={16} />
|
||||
{unselectedInRecord} to delete ({humanizeBytes(unselectedSize)} savings)
|
||||
</div>
|
||||
{selectionMode === 'keep' ? (
|
||||
<>
|
||||
<div className="flex items-center gap-1 text-green-600 dark:text-green-400 font-medium">
|
||||
<Shield size={16} />
|
||||
{selectedInRecord} to keep ({humanizeBytes(selectedSize)})
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-red-600 dark:text-red-400 font-medium">
|
||||
<Trash2 size={16} />
|
||||
{unselectedInRecord} to delete ({humanizeBytes(unselectedSize)} savings)
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-1 text-red-600 dark:text-red-400 font-medium">
|
||||
<Trash2 size={16} />
|
||||
{selectedInRecord} to delete ({humanizeBytes(selectedSize)} savings)
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-green-600 dark:text-green-400 font-medium">
|
||||
<Shield size={16} />
|
||||
{unselectedInRecord} to keep ({humanizeBytes(unselectedSize)})
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -211,8 +249,11 @@ export default function DuplicateAssetRecord({
|
||||
|
||||
{selectedInRecord > 0 && (
|
||||
<AlertDialog
|
||||
title="Keep Selected Assets"
|
||||
description={`Keep ${selectedInRecord} selected assets and delete ${unselectedInRecord} unselected assets? The selected assets will be removed from duplicate detection. Storage savings: ${humanizeBytes(unselectedSize)}. This action cannot be undone.`}
|
||||
title={selectionMode === 'keep' ? "Keep Selected Assets" : "Delete Selected Assets"}
|
||||
description={selectionMode === 'keep'
|
||||
? `Keep ${selectedInRecord} selected assets and delete ${unselectedInRecord} unselected assets? The selected assets will be removed from duplicate detection. Storage savings: ${humanizeBytes(unselectedSize)}. This action cannot be undone.`
|
||||
: `Delete ${selectedInRecord} selected assets and keep ${unselectedInRecord} unselected assets? The unselected assets will be removed from duplicate detection. Storage savings: ${humanizeBytes(selectedSize)}. This action cannot be undone.`
|
||||
}
|
||||
onConfirm={handleKeepSelected}
|
||||
variant="destructive"
|
||||
asChild
|
||||
@@ -221,10 +262,13 @@ export default function DuplicateAssetRecord({
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex items-center gap-1"
|
||||
title={`Keep selected, delete others (${humanizeBytes(unselectedSize)} savings)`}
|
||||
title={selectionMode === 'keep'
|
||||
? `Keep selected, delete others (${humanizeBytes(unselectedSize)} savings)`
|
||||
: `Delete selected, keep others (${humanizeBytes(selectedSize)} savings)`
|
||||
}
|
||||
>
|
||||
<Shield size={16} />
|
||||
Keep Selected ({selectedInRecord})
|
||||
{selectionMode === 'keep' ? <Shield size={16} /> : <Trash2 size={16} />}
|
||||
{selectionMode === 'keep' ? 'Keep' : 'Delete'} Selected ({selectedInRecord})
|
||||
</Button>
|
||||
</AlertDialog>
|
||||
)}
|
||||
@@ -257,6 +301,7 @@ export default function DuplicateAssetRecord({
|
||||
asset={asset}
|
||||
isSelected={selectedAssets.has(asset.id)}
|
||||
onSelect={onAssetSelect}
|
||||
selectionMode={selectionMode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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<ListItemProps> = ({ 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<ListItemProps> = ({ index, style, data }) => {
|
||||
onDeleteRecord={onDeleteRecord}
|
||||
onKeepSelected={onKeepSelected}
|
||||
onKeepAllInRecord={onKeepAllInRecord}
|
||||
selectionMode={selectionMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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
|
||||
|
||||
@@ -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<IDuplicateAssetRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedAssets, setSelectedAssets] = useState<Set<string>>(new Set());
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [lastSelectedIndex, setLastSelectedIndex] = useState<number>(-1);
|
||||
const [containerHeight, setContainerHeight] = useState<number>(600);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [duplicates, setDuplicates] = useState<IDuplicateAssetRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedAssets, setSelectedAssets] = useState<Set<string>>(new Set());
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [lastSelectedIndex, setLastSelectedIndex] = useState<number>(-1);
|
||||
const [containerHeight, setContainerHeight] = useState<number>(600);
|
||||
const [selectionMode, setSelectionMode] = useState<'keep' | 'discard'>('keep');
|
||||
const containerRef = useRef<HTMLDivElement>(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 (
|
||||
<PageLayout>
|
||||
<Header
|
||||
leftComponent="Bulk Duplicate Finder"
|
||||
rightComponent={
|
||||
<Button
|
||||
onClick={fetchDuplicates}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||
Refresh
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="h-full overflow-hidden">
|
||||
{/* Description - only show when loading or no duplicates */}
|
||||
{(loading || error || duplicates.length === 0) && (
|
||||
<div className="p-6 pb-0">
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Manage and remove duplicate assets from your library
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12 px-6">
|
||||
<Loader />
|
||||
<span className="ml-2 text-gray-600 dark:text-gray-400">
|
||||
Scanning for duplicate assets...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="px-6">
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-red-600 dark:text-red-400">Error:</span>
|
||||
<span className="text-red-800 dark:text-red-200">{error}</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={fetchDuplicates}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && duplicates.length === 0 && (
|
||||
<div className="text-center py-12 px-6">
|
||||
<Search size={48} className="mx-auto text-gray-400 mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
No duplicates found
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Great! No duplicate assets were detected in your library.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && duplicates.length > 0 && (
|
||||
<div ref={containerRef} style={{ height: containerHeight }} className="overflow-hidden">
|
||||
<VirtualizedDuplicateList
|
||||
duplicates={duplicates}
|
||||
selectedAssets={selectedAssets}
|
||||
onAssetSelect={handleAssetSelect}
|
||||
onDeleteRecord={handleDeleteRecord}
|
||||
onKeepSelected={handleKeepSelected}
|
||||
onKeepAllInRecord={handleKeepAllInRecord}
|
||||
height={containerHeight}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<PageLayout>
|
||||
<Header
|
||||
leftComponent="Bulk Duplicate Finder"
|
||||
rightComponent={
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Selection Mode Button Group */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Select to:
|
||||
</span>
|
||||
<div className="flex rounded-lg border border-gray-300 dark:border-gray-600 overflow-hidden">
|
||||
<Button
|
||||
variant={selectionMode === 'keep' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setSelectionMode('keep')}
|
||||
className={`rounded-none border-0 ${
|
||||
selectionMode === 'keep'
|
||||
? 'bg-green-600 hover:bg-green-700 text-white'
|
||||
: 'hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Shield size={14} className="mr-1" />
|
||||
Keep
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectionMode === 'discard' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setSelectionMode('discard')}
|
||||
className={`rounded-none border-0 ${
|
||||
selectionMode === 'discard'
|
||||
? 'bg-red-600 hover:bg-red-700 text-white'
|
||||
: 'hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Trash2 size={14} className="mr-1" />
|
||||
Discard
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Refresh Button */}
|
||||
<Button
|
||||
onClick={fetchDuplicates}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="h-full overflow-hidden">
|
||||
{/* Description - only show when loading or no duplicates */}
|
||||
{(loading || error || duplicates.length === 0) && (
|
||||
<div className="p-6 pb-0">
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Manage and remove duplicate assets from your library
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12 px-6">
|
||||
<Loader />
|
||||
<span className="ml-2 text-gray-600 dark:text-gray-400">
|
||||
Scanning for duplicate assets...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="px-6">
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-red-600 dark:text-red-400">Error:</span>
|
||||
<span className="text-red-800 dark:text-red-200">{error}</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={fetchDuplicates}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && duplicates.length === 0 && (
|
||||
<div className="text-center py-12 px-6">
|
||||
<Search size={48} className="mx-auto text-gray-400 mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
No duplicates found
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Great! No duplicate assets were detected in your library.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && duplicates.length > 0 && (
|
||||
<div ref={containerRef} style={{ height: containerHeight }} className="overflow-hidden">
|
||||
<VirtualizedDuplicateList
|
||||
duplicates={duplicates}
|
||||
selectedAssets={selectedAssets}
|
||||
onAssetSelect={handleAssetSelect}
|
||||
onDeleteRecord={handleDeleteRecord}
|
||||
onKeepSelected={handleKeepSelected}
|
||||
onKeepAllInRecord={handleKeepAllInRecord}
|
||||
height={containerHeight}
|
||||
selectionMode={selectionMode}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{isDeleting && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-sm w-full mx-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader />
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">
|
||||
Deleting Assets
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Removing duplicate assets...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PageLayout>
|
||||
)
|
||||
{isDeleting && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-sm w-full mx-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader />
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">
|
||||
Deleting Assets
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Removing duplicate assets...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user