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:
Varun Raj committed 2025-10-29 22:52:18 +05:30
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
+338 -298
View File
@@ -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>
)
}