feat: manage duplicates in bulk

Signed-off-by: Varun Raj <varun@skcript.com>
This commit is contained in:
Varun Raj committed 2025-08-23 16:34:44 +05:30
1 parent 2746fe0938
commit d443da7df2
11 files changed
+904 -13

No files matched your search

BIN
View File
Binary file not shown.
+2
View File
@@ -40,6 +40,7 @@
"@types/qs": "^6.9.15",
"@types/react-autosuggest": "^10.1.11",
"@types/react-calendar-heatmap": "^1.6.7",
"@types/react-window": "^1.8.8",
"@vis.gl/react-google-maps": "^1.4.2",
"axios": "^1.7.4",
"chrono-node": "^1.4.9",
@@ -66,6 +67,7 @@
"react-hot-toast": "^2.5.1",
"react-leaflet": "^4.2.1",
"react-mentions": "^4.4.10",
"react-window": "^1.8.11",
"recharts": "^2.12.7",
"remotion": "4.0.232",
"sass": "^1.77.8",
@@ -0,0 +1,81 @@
import React from 'react'
import { Button } from '@/components/ui/button'
import { Trash2, Check, X, RotateCcw } from 'lucide-react'
import { humanizeNumber } from '@/helpers/string.helper'
interface BulkActionBarProps {
selectedCount: number
totalAssets: number
onDeleteSelected: () => void
onSelectAll: () => void
onDeselectAll: () => void
onInvertSelection: () => void
isVisible: boolean
}
export default function BulkActionBar({
selectedCount,
totalAssets,
onDeleteSelected,
onSelectAll,
onDeselectAll,
onInvertSelection,
isVisible
}: BulkActionBarProps) {
if (!isVisible) return null
return (
<div className="fixed bottom-4 left-1/2 transform -translate-x-1/2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg p-4 z-50">
<div className="flex items-center gap-4">
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">
{humanizeNumber(selectedCount)} of {humanizeNumber(totalAssets)} selected
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={onSelectAll}
className="flex items-center gap-1"
>
<Check size={16} />
Select All
</Button>
<Button
variant="outline"
size="sm"
onClick={onDeselectAll}
className="flex items-center gap-1"
>
<X size={16} />
Deselect All
</Button>
<Button
variant="outline"
size="sm"
onClick={onInvertSelection}
className="flex items-center gap-1"
>
<RotateCcw size={16} />
Invert
</Button>
<div className="h-6 w-px bg-gray-300 dark:bg-gray-600" />
<Button
variant="destructive"
size="sm"
onClick={onDeleteSelected}
disabled={selectedCount === 0}
className="flex items-center gap-1"
>
<Trash2 size={16} />
Delete Selected ({selectedCount})
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,265 @@
import React, { useMemo } from 'react'
import { IDuplicateAssetRecord, IDuplicateAsset } from '@/types/asset'
import { ASSET_THUMBNAIL_PATH } from '@/config/routes'
import LazyImage from '@/components/ui/lazy-image'
import { Checkbox } from '@/components/ui/checkbox'
import { Button } from '@/components/ui/button'
import { AlertDialog } from '@/components/ui/alert-dialog'
import { humanizeBytes, humanizeNumber } from '@/helpers/string.helper'
import { formatDate } from '@/helpers/date.helper'
import { Camera, Calendar, HardDrive, MapPin, Trash2, Check, X, Shield } from 'lucide-react'
interface DuplicateAssetRecordProps {
record: IDuplicateAssetRecord
selectedAssets: Set<string>
onAssetSelect: (assetId: string, isShiftClick?: boolean) => void
onDeleteRecord: (record: IDuplicateAssetRecord) => void
onKeepSelected: (record: IDuplicateAssetRecord, selectedIds: string[], unselectedIds: string[]) => void
onKeepAllInRecord: (record: IDuplicateAssetRecord) => void
}
interface DuplicateAssetItemProps {
asset: IDuplicateAsset
isSelected: boolean
onSelect: (assetId: string, isShiftClick?: boolean) => void
}
function DuplicateAssetItem({ asset, isSelected, onSelect }: DuplicateAssetItemProps) {
const handleCheckboxChange = (event: React.MouseEvent) => {
const isShiftClick = event.shiftKey
onSelect(asset.id, isShiftClick)
}
return (
<div className="border rounded-lg overflow-hidden shadow-lg relative group">
<label className="block relative cursor-pointer">
<LazyImage
src={ASSET_THUMBNAIL_PATH(asset.id)}
alt={asset.originalFileName}
title={asset.originalFileName}
style={{
width: '100%',
height: '200px',
objectFit: 'cover',
}}
/>
{/* Overlay info */}
<div className="absolute bottom-0 w-full bg-gray-800/70 text-white text-center text-xs font-bold py-1 group-hover:hidden">
{formatDate(asset.exifInfo.dateTimeOriginal?.toString(), 'MMM d, yyyy')}
</div>
{/* 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>
) : (
<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>
)}
</div>
{/* Checkbox */}
<Checkbox
checked={isSelected}
onClick={handleCheckboxChange}
className="absolute top-2 left-2 w-6 h-6 rounded-full border-gray-300 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
/>
{/* Asset type indicator */}
{asset.type === 'VIDEO' && (
<div className="absolute top-12 right-2 bg-gray-800 text-white text-xs py-1 px-2 rounded">
VIDEO
</div>
)}
</label>
{/* Asset details */}
<div className={`p-3 ${isSelected ? 'bg-blue-500' : ''}`}>
<h3 className={`text-sm font-semibold truncate ${isSelected ? 'text-white' : ''}`}>
{asset.originalFileName}
</h3>
<div className="mt-2 space-y-1">
<div className={`flex items-center gap-1 text-xs ${isSelected ? 'text-white' : 'text-gray-600 dark:text-gray-400'}`}>
<HardDrive size={12} />
{humanizeBytes(asset.exifInfo.fileSizeInByte)}
</div>
<div className={`flex items-center gap-1 text-xs ${isSelected ? 'text-white' : 'text-gray-600 dark:text-gray-400'}`}>
<Camera size={12} />
{asset.exifInfo.exifImageWidth} × {asset.exifInfo.exifImageHeight}
</div>
{asset.exifInfo.city && (
<div className={`flex items-center gap-1 text-xs ${isSelected ? 'text-white' : 'text-gray-600 dark:text-gray-400'}`}>
<MapPin size={12} />
{asset.exifInfo.city}, {asset.exifInfo.country}
</div>
)}
{asset.exifInfo.make && asset.exifInfo.model && (
<div className={`text-xs ${isSelected ? 'text-white' : 'text-gray-600 dark:text-gray-400'}`}>
{asset.exifInfo.make} {asset.exifInfo.model}
</div>
)}
</div>
</div>
</div>
)
}
export default function DuplicateAssetRecord({
record,
selectedAssets,
onAssetSelect,
onDeleteRecord,
onKeepSelected,
onKeepAllInRecord
}: DuplicateAssetRecordProps) {
const recordAssetIds = record.assets.map(asset => asset.id)
const selectedInRecord = recordAssetIds.filter(id => selectedAssets.has(id)).length
const unselectedInRecord = record.assets.length - selectedInRecord
// Calculate total size and selected/unselected sizes
const totalSize = useMemo(() => {
return record.assets.reduce((sum, asset) => sum + asset.exifInfo.fileSizeInByte, 0)
}, [record.assets])
const selectedSize = useMemo(() => {
return record.assets
.filter(asset => selectedAssets.has(asset.id))
.reduce((sum, asset) => sum + asset.exifInfo.fileSizeInByte, 0)
}, [record.assets, selectedAssets])
const unselectedSize = useMemo(() => {
return record.assets
.filter(asset => !selectedAssets.has(asset.id))
.reduce((sum, asset) => sum + asset.exifInfo.fileSizeInByte, 0)
}, [record.assets, selectedAssets])
const handleKeepAll = () => {
onKeepAllInRecord(record)
}
const handleDeleteRecord = () => {
onDeleteRecord(record)
}
const handleKeepSelected = () => {
const selectedAssetIds = record.assets
.filter(asset => selectedAssets.has(asset.id))
.map(asset => asset.id)
const unselectedAssetIds = record.assets
.filter(asset => !selectedAssets.has(asset.id))
.map(asset => asset.id)
if (selectedAssetIds.length === 0) return
onKeepSelected(record, selectedAssetIds, unselectedAssetIds)
}
return (
<div className="mb-8">
<div className="mb-4 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400">
<div className="flex items-center gap-1">
<Camera size={16} />
{humanizeNumber(record.assets.length)} duplicate{record.assets.length !== 1 ? 's' : ''}
</div>
<div className="flex items-center gap-1">
<HardDrive size={16} />
{humanizeBytes(totalSize)} total
</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>
</div>
)}
</div>
<div className="flex items-center gap-2">
<AlertDialog
title="Keep All Assets"
description={`Are you sure you want to keep all ${record.assets.length} assets in this group? This will remove them from the duplicate detection and they won't appear as duplicates anymore.`}
onConfirm={handleKeepAll}
asChild
>
<Button
variant="outline"
size="sm"
className="flex items-center gap-1"
title="Remove from duplicate detection"
>
<Shield size={16} />
Keep All
</Button>
</AlertDialog>
{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.`}
onConfirm={handleKeepSelected}
variant="destructive"
asChild
>
<Button
variant="default"
size="sm"
className="flex items-center gap-1"
title={`Keep selected, delete others (${humanizeBytes(unselectedSize)} savings)`}
>
<Shield size={16} />
Keep Selected ({selectedInRecord})
</Button>
</AlertDialog>
)}
<AlertDialog
title="Delete All Assets"
description={`Are you sure you want to delete all ${record.assets.length} assets in this duplicate group? Storage savings: ${humanizeBytes(totalSize)}. This action cannot be undone.`}
onConfirm={handleDeleteRecord}
variant="destructive"
asChild
>
<Button
variant="destructive"
size="sm"
className="flex items-center gap-1"
title={`Delete group (${humanizeBytes(totalSize)} savings)`}
>
<Trash2 size={16} />
Delete All ({humanizeBytes(totalSize)})
</Button>
</AlertDialog>
</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{record.assets.map((asset) => (
<DuplicateAssetItem
key={asset.id}
asset={asset}
isSelected={selectedAssets.has(asset.id)}
onSelect={onAssetSelect}
/>
))}
</div>
</div>
)
}
@@ -0,0 +1,115 @@
import React, { useMemo } from 'react'
import { VariableSizeList as List } from 'react-window'
import { IDuplicateAssetRecord } from '@/types/asset'
import DuplicateAssetRecord from './DuplicateAssetRecord'
interface VirtualizedDuplicateListProps {
duplicates: IDuplicateAssetRecord[]
selectedAssets: Set<string>
onAssetSelect: (assetId: string, isShiftClick?: boolean) => void
onDeleteRecord: (record: IDuplicateAssetRecord) => void
onKeepSelected: (record: IDuplicateAssetRecord, selectedIds: string[], unselectedIds: string[]) => void
onKeepAllInRecord: (record: IDuplicateAssetRecord) => void
height: number
}
interface ListItemProps {
index: number
style: React.CSSProperties
data: {
duplicates: IDuplicateAssetRecord[]
selectedAssets: Set<string>
onAssetSelect: (assetId: string, isShiftClick?: boolean) => void
onDeleteRecord: (record: IDuplicateAssetRecord) => void
onKeepSelected: (record: IDuplicateAssetRecord, selectedIds: string[], unselectedIds: string[]) => void
onKeepAllInRecord: (record: IDuplicateAssetRecord) => void
}
}
const ListItem: React.FC<ListItemProps> = ({ index, style, data }) => {
const { duplicates, selectedAssets, onAssetSelect, onDeleteRecord, onKeepSelected, onKeepAllInRecord } = data
const record = duplicates[index]
return (
<div style={style}>
<div style={{ padding: '0 24px' }}>
<DuplicateAssetRecord
record={record}
selectedAssets={selectedAssets}
onAssetSelect={onAssetSelect}
onDeleteRecord={onDeleteRecord}
onKeepSelected={onKeepSelected}
onKeepAllInRecord={onKeepAllInRecord}
/>
</div>
</div>
)
}
// Calculate dynamic height for each duplicate record
const getItemSize = (record: IDuplicateAssetRecord): number => {
// Base height for the header section
let height = 120 // Header + margin
// Calculate grid rows needed
const assetsPerRow = 5 // xl:grid-cols-5 is the max
const rows = Math.ceil(record.assets.length / assetsPerRow)
// Each asset item is approximately 320px tall (200px image + 120px details)
const assetRowHeight = 320
return height + (rows * assetRowHeight) + 32 // Add some margin
}
export default function VirtualizedDuplicateList({
duplicates,
selectedAssets,
onAssetSelect,
onDeleteRecord,
onKeepSelected,
onKeepAllInRecord,
height
}: VirtualizedDuplicateListProps) {
// Custom item size getter for VariableSizeList
const getItemHeight = (index: number) => {
if (index >= duplicates.length) return 0
return getItemSize(duplicates[index])
}
// Data to pass to each list item
const itemData = {
duplicates,
selectedAssets,
onAssetSelect,
onDeleteRecord,
onKeepSelected,
onKeepAllInRecord
}
// For better performance with many items, we'll use a custom implementation
// that handles variable heights properly
return (
<div style={{ height }}>
{duplicates.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-500">No duplicate records to display</p>
</div>
) : (
<List
height={height}
width="100%"
itemCount={duplicates.length}
itemSize={getItemHeight}
itemData={itemData}
overscanCount={2}
style={{
scrollbarWidth: 'thin',
scrollbarColor: '#cbd5e1 transparent'
}}
>
{ListItem}
</List>
)}
</div>
)
}
+8 -2
View File
@@ -1,4 +1,4 @@
import { GalleryHorizontal, GalleryVerticalEnd, Image as ImageIcon, MapPin, MapPinX, Rewind, Search, User, Video } from "lucide-react";
import { Copy, GalleryHorizontal, GalleryVerticalEnd, Image as ImageIcon, MapPin, MapPinX, Rewind, Search, User, Video } from "lucide-react";
export const sidebarNavs = [
// {
@@ -47,6 +47,12 @@ export const sidebarNavs = [
title: "Empty Videos",
link: "/assets/empty-videos",
icon: <Video className="h-4 w-4" />,
}
},
// Bulk Duplicate
{
title: "Bulk Duplicate Finder",
link: "/assets/bulk-duplicate-finder",
icon: <Copy className="h-4 w-4" />,
},
];
+3 -1
View File
@@ -73,4 +73,6 @@ export const SHARE_LINK_GENERATE_PATH = BASE_API_ENDPOINT + "/share-link/generat
export const SHARE_LINK_ASSETS_PATH = (token: string) => BASE_API_ENDPOINT + "/share-link/" + token + "/assets";
export const SHARE_LINK_PEOPLE_PATH = (token: string) => BASE_API_ENDPOINT + "/share-link/" + token + "/people";
export const SHARE_LINK_DOWNLOAD_PATH = (token: string) => BASE_API_ENDPOINT + "/share-link/" + token + "/download";
export const DOWNLOAD_ASSETS_PATH = BASE_PROXY_ENDPOINT + "/download/archive";
export const DOWNLOAD_ASSETS_PATH = BASE_PROXY_ENDPOINT + "/download/archive";
export const LIST_DUPLICATES_PATH = BASE_PROXY_ENDPOINT + "/duplicates";
+7 -1
View File
@@ -6,6 +6,7 @@ import {
LIST_MISSING_LOCATION_ASSETS_PATH,
LIST_MISSING_LOCATION_DATES_PATH,
UPDATE_ASSETS_PATH,
LIST_DUPLICATES_PATH,
} from "@/config/routes";
import { cleanUpAsset } from "@/helpers/asset.helper";
import API from "@/lib/api";
@@ -51,6 +52,7 @@ export interface IUpdateAssetsParams {
latitude?: number;
longitude?: number;
dateTimeOriginal?: string;
duplicateId?: string | null;
}
export const updateAssets = async (params: IUpdateAssetsParams) => {
@@ -84,4 +86,8 @@ export interface IEmptyVideosParams {
export const listEmptyVideos = async (filters: IEmptyVideosParams) => {
return API.get(LIST_EMPTY_VIDEOS_PATH, filters).then((assets) => assets.map(cleanUpAsset));
}
}
export const listDuplicates = async () => {
return API.get(LIST_DUPLICATES_PATH);
}
+34 -9
View File
@@ -22,24 +22,49 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
// Forward the status code
res.status(response.status)
// Forward the headers
// Forward the headers, but filter out problematic ones
const responseHeaders = response.headers
responseHeaders.forEach((value, key) => {
res.setHeader(key, value)
// Skip headers that can cause decoding issues
const skipHeaders = [
'content-encoding',
'content-length',
'transfer-encoding',
'connection',
'keep-alive'
]
if (!skipHeaders.includes(key.toLowerCase())) {
res.setHeader(key, value)
}
})
// Only stream the response body if it exists and has content
// Handle the response body properly
if (response.body) {
const reader = response.body.getReader()
while (true) {
const { done, value } = await reader.read()
if (done) break
res.write(value)
// For binary content or when we need to preserve encoding
if (response.headers.get('content-type')?.includes('image/') ||
response.headers.get('content-type')?.includes('video/') ||
response.headers.get('content-type')?.includes('audio/') ||
response.headers.get('content-type')?.includes('application/octet-stream')) {
const arrayBuffer = await response.arrayBuffer()
res.send(Buffer.from(arrayBuffer))
} else {
// For text/JSON content, let Next.js handle the encoding
const text = await response.text()
res.send(text)
}
} else {
res.end()
}
res.end()
} catch (error: any) {
console.error('Proxy error:', error)
res.status(500).json({ error: error?.message })
}
}
export const config = {
api: {
responseLimit: false,
},
}
+328
View File
@@ -0,0 +1,328 @@
import { listDuplicates, deleteAssets, updateAssets } from "@/handlers/api/asset.handler";
import { IDuplicateAssetRecord } from "@/types/asset";
import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react'
import PageLayout from '@/components/layouts/PageLayout'
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 { 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 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);
};
// Initial calculation
updateHeight();
// 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]);
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;
});
}, [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
});
}
// 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>
)}
</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>
)
}
+61
View File
@@ -24,3 +24,64 @@ export interface IAssetThumbhash {
type: string;
data: number[];
}
export interface IDuplicateAssetRecord {
duplicateId: string;
assets: IDuplicateAsset[];
}
export interface IDuplicateAsset {
id: string;
deviceAssetId: string;
ownerId: string;
deviceId: string;
libraryId: null;
type: string;
originalPath: string;
originalFileName: string;
originalMimeType: string;
thumbhash: string;
fileCreatedAt: Date;
fileModifiedAt: Date;
localDateTime: Date;
updatedAt: Date;
isFavorite: boolean;
isArchived: boolean;
isTrashed: boolean;
visibility: string;
duration: string;
exifInfo: IDuplicateAssetExifInfo;
livePhotoVideoId: null;
people: any[];
checksum: string;
isOffline: boolean;
hasMetadata: boolean;
duplicateId: string;
resized: boolean;
}
export interface IDuplicateAssetExifInfo {
make: string;
model: string;
exifImageWidth: number;
exifImageHeight: number;
fileSizeInByte: number;
orientation: string;
dateTimeOriginal: Date;
modifyDate: Date;
timeZone: string;
lensModel: string;
fNumber: number;
focalLength: number;
iso: number;
exposureTime: string;
latitude: number;
longitude: number;
city: string;
state: string;
country: string;
description: string;
projectionType: null;
rating: null;
}