feat: add search and album filters to bulk duplicate finder
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
7c81fb0bc1
commit
5e70cf258b
3 files changed
+221
-23
No files matched your search
@@ -0,0 +1,78 @@
|
||||
import * as React from "react"
|
||||
import { Check, FolderOpen } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
|
||||
interface AlbumFilterDropdownProps {
|
||||
options: { label: string; value: string }[]
|
||||
selectedIds: Set<string>
|
||||
onSelectionChange: (ids: Set<string>) => void
|
||||
}
|
||||
|
||||
export default function AlbumFilterDropdown({
|
||||
options,
|
||||
selectedIds,
|
||||
onSelectionChange,
|
||||
}: AlbumFilterDropdownProps) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
|
||||
const handleSelect = (value: string) => {
|
||||
const next = new Set(selectedIds)
|
||||
if (next.has(value)) {
|
||||
next.delete(value)
|
||||
} else {
|
||||
next.add(value)
|
||||
}
|
||||
onSelectionChange(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-8 text-sm gap-1.5">
|
||||
<FolderOpen size={14} />
|
||||
{selectedIds.size > 0 ? `Albums (${selectedIds.size})` : "Albums"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[220px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search albums..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No albums found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.label}
|
||||
onSelect={() => handleSelect(option.value)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4 min-h-4 min-w-4",
|
||||
selectedIds.has(option.value) ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">{option.label}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import { VariableSizeList as List } from 'react-window'
|
||||
import { IDuplicateAssetRecord } from '@/types/asset'
|
||||
import { IAssetAlbumInfo } from '@/handlers/api/asset.handler'
|
||||
@@ -79,6 +79,13 @@ export default function VirtualizedDuplicateList({
|
||||
selectionMode,
|
||||
assetAlbums
|
||||
}: VirtualizedDuplicateListProps) {
|
||||
const listRef = useRef<List>(null)
|
||||
|
||||
// Reset cached row heights when the duplicates list changes (e.g. after filtering)
|
||||
useEffect(() => {
|
||||
listRef.current?.resetAfterIndex(0)
|
||||
}, [duplicates])
|
||||
|
||||
// Custom item size getter for VariableSizeList
|
||||
const getItemHeight = (index: number) => {
|
||||
if (index >= duplicates.length) return 0
|
||||
@@ -107,6 +114,7 @@ export default function VirtualizedDuplicateList({
|
||||
</div>
|
||||
) : (
|
||||
<List
|
||||
ref={listRef}
|
||||
height={height}
|
||||
width="100%"
|
||||
itemCount={duplicates.length}
|
||||
|
||||
@@ -6,9 +6,11 @@ import PageLayout from '@/components/layouts/PageLayout'
|
||||
import Header from '@/components/shared/Header'
|
||||
import VirtualizedDuplicateList from '@/components/assets/duplicate-assets/VirtualizedDuplicateList'
|
||||
import AlbumTransferDialog from '@/components/assets/duplicate-assets/AlbumTransferDialog'
|
||||
import AlbumFilterDropdown from '@/components/assets/duplicate-assets/AlbumFilterDropdown'
|
||||
import Loader from '@/components/ui/loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FolderSync, RefreshCw, Search, Shield, Trash2, Zap } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { FolderSync, RefreshCw, Search, Shield, Trash2, X, Zap } from 'lucide-react'
|
||||
import FloatingBar from '@/components/shared/FloatingBar'
|
||||
import { AlertDialog } from '@/components/ui/alert-dialog'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
@@ -36,6 +38,9 @@ export default function BulkDuplicatePage() {
|
||||
const [assetAlbums, setAssetAlbums] = useState<Record<string, IAssetAlbumInfo[]>>({});
|
||||
const [albumTransferMode, setAlbumTransferMode] = useState<AlbumTransferMode>('always');
|
||||
const [pendingDedup, setPendingDedup] = useState<PendingDedup | null>(null);
|
||||
const [searchInputText, setSearchInputText] = useState('');
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [selectedAlbumIds, setSelectedAlbumIds] = useState<Set<string>>(new Set());
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Load album move mode from localStorage
|
||||
@@ -46,6 +51,12 @@ export default function BulkDuplicatePage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Debounce search text
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setSearchText(searchInputText), 200);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchInputText]);
|
||||
|
||||
const handleAlbumTransferModeChange = (mode: AlbumTransferMode) => {
|
||||
setAlbumTransferMode(mode);
|
||||
localStorage.setItem('duplicateFinderAlbumTransfer', mode);
|
||||
@@ -58,6 +69,9 @@ export default function BulkDuplicatePage() {
|
||||
const duplicates = await listDuplicates();
|
||||
setDuplicates(duplicates);
|
||||
setSelectedAssets(new Set()); // Clear selection when refetching
|
||||
setSearchInputText('');
|
||||
setSearchText('');
|
||||
setSelectedAlbumIds(new Set());
|
||||
|
||||
// Fetch album data for all assets
|
||||
const allIds = duplicates.flatMap((r: IDuplicateAssetRecord) => r.assets.map(a => a.id));
|
||||
@@ -86,10 +100,11 @@ export default function BulkDuplicatePage() {
|
||||
// Handle container height calculation and resize
|
||||
useEffect(() => {
|
||||
const updateHeight = () => {
|
||||
// Calculate available height: full viewport minus header (48px)
|
||||
// Calculate available height: full viewport minus header (48px) and filter bar (48px)
|
||||
const viewportHeight = window.innerHeight;
|
||||
const headerHeight = 48; // h-12 = 48px
|
||||
const availableHeight = viewportHeight - headerHeight;
|
||||
const filterBarHeight = duplicates.length > 0 ? 48 : 0;
|
||||
const availableHeight = viewportHeight - headerHeight - filterBarHeight;
|
||||
setContainerHeight(availableHeight);
|
||||
};
|
||||
|
||||
@@ -121,10 +136,67 @@ export default function BulkDuplicatePage() {
|
||||
};
|
||||
}, [selectedAssets.size]);
|
||||
|
||||
// Flatten all asset IDs for selection operations
|
||||
// Derive unique album options from assetAlbums
|
||||
const allAlbumOptions = useMemo(() => {
|
||||
const seen = new Map<string, string>();
|
||||
Object.values(assetAlbums).forEach(albums => {
|
||||
albums.forEach(a => {
|
||||
if (!seen.has(a.albumId)) {
|
||||
seen.set(a.albumId, a.albumName);
|
||||
}
|
||||
});
|
||||
});
|
||||
return Array.from(seen, ([value, label]) => ({ label, value }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
}, [assetAlbums]);
|
||||
|
||||
const hasActiveFilters = searchText.length > 0 || selectedAlbumIds.size > 0;
|
||||
|
||||
// Filter duplicates based on search text and album selection
|
||||
const filteredDuplicates = useMemo(() => {
|
||||
if (!hasActiveFilters) return duplicates;
|
||||
|
||||
const lowerSearch = searchText.toLowerCase();
|
||||
|
||||
return duplicates.filter(record =>
|
||||
record.assets.some(asset => {
|
||||
// Text filter
|
||||
if (searchText.length > 0) {
|
||||
const fields = [
|
||||
asset.originalFileName,
|
||||
asset.originalPath,
|
||||
asset.exifInfo?.city,
|
||||
asset.exifInfo?.state,
|
||||
asset.exifInfo?.country,
|
||||
asset.exifInfo?.description,
|
||||
];
|
||||
const textMatch = fields.some(
|
||||
f => f && f.toLowerCase().includes(lowerSearch)
|
||||
);
|
||||
if (!textMatch) return false;
|
||||
}
|
||||
|
||||
// Album filter
|
||||
if (selectedAlbumIds.size > 0) {
|
||||
const assetAlbumList = assetAlbums[asset.id] || [];
|
||||
const albumMatch = assetAlbumList.some(a => selectedAlbumIds.has(a.albumId));
|
||||
if (!albumMatch) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
);
|
||||
}, [duplicates, searchText, selectedAlbumIds, assetAlbums]);
|
||||
|
||||
// Reset lastSelectedIndex when filter inputs change
|
||||
useEffect(() => {
|
||||
setLastSelectedIndex(-1);
|
||||
}, [searchText, selectedAlbumIds]);
|
||||
|
||||
// Flatten all asset IDs for selection operations (from filtered view)
|
||||
const allAssetIds = useMemo(() => {
|
||||
return duplicates.flatMap(record => record.assets.map(asset => asset.id));
|
||||
}, [duplicates]);
|
||||
return filteredDuplicates.flatMap(record => record.assets.map(asset => asset.id));
|
||||
}, [filteredDuplicates]);
|
||||
|
||||
// Calculate selected assets info for discard mode
|
||||
const selectedAssetsInfo = useMemo(() => {
|
||||
@@ -252,8 +324,8 @@ export default function BulkDuplicatePage() {
|
||||
|
||||
const handleAutoSelect = (criteria: 'lowest-quality' | 'smallest-size') => {
|
||||
const newSelection = new Set<string>();
|
||||
|
||||
duplicates.forEach(record => {
|
||||
|
||||
filteredDuplicates.forEach(record => {
|
||||
if (record.assets.length <= 1) return; // Skip if only one asset
|
||||
|
||||
let selectedAsset = record.assets[0];
|
||||
@@ -413,7 +485,7 @@ export default function BulkDuplicatePage() {
|
||||
|
||||
// In discard mode, the selected assets are discarded, the rest are kept
|
||||
const keptIds: string[] = [];
|
||||
duplicates.forEach(record => {
|
||||
filteredDuplicates.forEach(record => {
|
||||
record.assets.forEach(asset => {
|
||||
if (!selectedAssets.has(asset.id)) {
|
||||
keptIds.push(asset.id);
|
||||
@@ -570,19 +642,59 @@ export default function BulkDuplicatePage() {
|
||||
)}
|
||||
|
||||
{!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}
|
||||
assetAlbums={assetAlbums}
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
{/* Filter bar */}
|
||||
<div className="flex items-center gap-3 px-6 py-2 border-b">
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search filename, path, location..."
|
||||
value={searchInputText}
|
||||
onChange={e => setSearchInputText(e.target.value)}
|
||||
className="w-64 pl-8 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<AlbumFilterDropdown
|
||||
options={allAlbumOptions}
|
||||
selectedIds={selectedAlbumIds}
|
||||
onSelectionChange={setSelectedAlbumIds}
|
||||
/>
|
||||
<div className="flex-1" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{hasActiveFilters
|
||||
? `Showing ${filteredDuplicates.length} of ${duplicates.length} groups`
|
||||
: `${duplicates.length} groups`}
|
||||
</span>
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2"
|
||||
onClick={() => {
|
||||
setSearchInputText('');
|
||||
setSearchText('');
|
||||
setSelectedAlbumIds(new Set());
|
||||
}}
|
||||
>
|
||||
<X size={14} className="mr-1" />
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div ref={containerRef} style={{ height: containerHeight }} className="overflow-hidden">
|
||||
<VirtualizedDuplicateList
|
||||
duplicates={filteredDuplicates}
|
||||
selectedAssets={selectedAssets}
|
||||
onAssetSelect={handleAssetSelect}
|
||||
onDeleteRecord={handleDeleteRecord}
|
||||
onKeepSelected={handleKeepSelected}
|
||||
onKeepAllInRecord={handleKeepAllInRecord}
|
||||
height={containerHeight}
|
||||
selectionMode={selectionMode}
|
||||
assetAlbums={assetAlbums}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
Reference in new issue
Block a user