diff --git a/bun.lockb b/bun.lockb index c4de24e..158441f 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 4aacb22..cc00a8b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/assets/duplicate-assets/BulkActionBar.tsx b/src/components/assets/duplicate-assets/BulkActionBar.tsx new file mode 100644 index 0000000..4b51463 --- /dev/null +++ b/src/components/assets/duplicate-assets/BulkActionBar.tsx @@ -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 ( +
+
+
+ {humanizeNumber(selectedCount)} of {humanizeNumber(totalAssets)} selected +
+ +
+ + + + + + +
+ + +
+
+
+ ) +} diff --git a/src/components/assets/duplicate-assets/DuplicateAssetRecord.tsx b/src/components/assets/duplicate-assets/DuplicateAssetRecord.tsx new file mode 100644 index 0000000..1248450 --- /dev/null +++ b/src/components/assets/duplicate-assets/DuplicateAssetRecord.tsx @@ -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 + 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 ( +
+ + + {/* Asset details */} +
+

+ {asset.originalFileName} +

+ +
+
+ + {humanizeBytes(asset.exifInfo.fileSizeInByte)} +
+ +
+ + {asset.exifInfo.exifImageWidth} × {asset.exifInfo.exifImageHeight} +
+ + {asset.exifInfo.city && ( +
+ + {asset.exifInfo.city}, {asset.exifInfo.country} +
+ )} + + {asset.exifInfo.make && asset.exifInfo.model && ( +
+ {asset.exifInfo.make} {asset.exifInfo.model} +
+ )} +
+
+
+ ) +} + +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 ( +
+
+
+
+
+ + {humanizeNumber(record.assets.length)} duplicate{record.assets.length !== 1 ? 's' : ''} +
+
+ + {humanizeBytes(totalSize)} total +
+ {selectedInRecord > 0 && ( +
+
+ + {selectedInRecord} to keep ({humanizeBytes(selectedSize)}) +
+
+ + {unselectedInRecord} to delete ({humanizeBytes(unselectedSize)} savings) +
+
+ )} +
+
+ + + + + {selectedInRecord > 0 && ( + + + + )} + + + + +
+
+
+ +
+ {record.assets.map((asset) => ( + + ))} +
+
+ ) +} diff --git a/src/components/assets/duplicate-assets/VirtualizedDuplicateList.tsx b/src/components/assets/duplicate-assets/VirtualizedDuplicateList.tsx new file mode 100644 index 0000000..8e580c3 --- /dev/null +++ b/src/components/assets/duplicate-assets/VirtualizedDuplicateList.tsx @@ -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 + 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 + 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 = ({ index, style, data }) => { + const { duplicates, selectedAssets, onAssetSelect, onDeleteRecord, onKeepSelected, onKeepAllInRecord } = data + const record = duplicates[index] + + return ( +
+
+ +
+
+ ) +} + +// 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 ( +
+ {duplicates.length === 0 ? ( +
+

No duplicate records to display

+
+ ) : ( + + {ListItem} + + )} +
+ ) +} diff --git a/src/config/constants/sidebarNavs.tsx b/src/config/constants/sidebarNavs.tsx index b4de6fc..6b95734 100644 --- a/src/config/constants/sidebarNavs.tsx +++ b/src/config/constants/sidebarNavs.tsx @@ -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: