import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useState, useEffect } from 'react' import { Trash2, Check, UserPlus, CheckSquare, Square, Play, ChevronLeft, ChevronRight, RefreshCw, ClipboardList, CalendarClock, ImagePlus } from 'lucide-react' import { useBreadcrumb } from '../hooks/useBreadcrumb' import { breadcrumbConfig } from '../config/breadcrumbConfig' import { api, wsClient, type ReviewFile } from '../lib/api' import { formatBytes, formatPlatformName, isVideoFile } from '../lib/utils' import { notificationManager } from '../lib/notificationManager' import { invalidateAllFileCaches, optimisticRemoveFromReviewQueue } from '../lib/cacheInvalidation' import EnhancedLightbox from '../components/EnhancedLightbox' import { BatchProgressModal, BatchProgressItem } from '../components/BatchProgressModal' import ThrottledImage from '../components/ThrottledImage' import { FilterBar } from '../components/FilterPopover' import { CopyToGalleryModal } from '../components/private-gallery/CopyToGalleryModal' import { useEnabledFeatures } from '../hooks/useEnabledFeatures' // Get thumbnail URL - use stored YouTube thumbnail if available function getReviewThumbnailUrl(file: ReviewFile): string { if (file.platform === 'youtube' && file.video_id) { return `/api/video/thumbnail/${file.platform}/${file.video_id}?source=downloads` } return api.getReviewThumbnailUrl(file.file_path) } export default function Review() { const queryClient = useQueryClient() const { setBreadcrumbs } = useBreadcrumb(breadcrumbConfig['/review']) const { isFeatureEnabled } = useEnabledFeatures() const [selectedImage, setSelectedImage] = useState(null) const [page, setPage] = useState(0) const [showKeepModal, setShowKeepModal] = useState(false) const [showAddModal, setShowAddModal] = useState(false) const [addPersonName, setAddPersonName] = useState('') const [actioningFile, setActioningFile] = useState(null) const [selectedItems, setSelectedItems] = useState>(new Set()) const [selectedPaths, setSelectedPaths] = useState>(new Map()) const [selectMode, setSelectMode] = useState(false) const [platformFilter, setPlatformFilter] = useState('') const [sourceFilter, setSourceFilter] = useState('') const [typeFilter, setTypeFilter] = useState<'all' | 'image' | 'video'>('all') const [searchQuery, setSearchQuery] = useState('') const [showProgressModal, setShowProgressModal] = useState(false) const [progressFiles, setProgressFiles] = useState([]) const [progressTitle, setProgressTitle] = useState('Processing Files') const [isRescanning, setIsRescanning] = useState(false) const [rescanProgress, setRescanProgress] = useState<{ current: number; total: number } | null>(null) // Date edit modal state const [showDateModal, setShowDateModal] = useState(false) const [editingItemId, setEditingItemId] = useState(null) const [newDate, setNewDate] = useState('') const [updateFileTimestamps, setUpdateFileTimestamps] = useState(true) // Copy to Private Gallery modal state const [showCopyToGalleryModal, setShowCopyToGalleryModal] = useState(false) // Advanced search filters const [dateFrom, setDateFrom] = useState('') const [dateTo, setDateTo] = useState('') const [sizeMin, setSizeMin] = useState('') const [sizeMax, setSizeMax] = useState('') const [sortBy, setSortBy] = useState('added_date') const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc') const limit = 50 const { data: reviewData, isLoading } = useQuery({ queryKey: ['review-queue', page, platformFilter, sourceFilter, typeFilter, sortBy, sortOrder, dateFrom, dateTo, sizeMin, sizeMax, searchQuery], queryFn: () => api.getReviewQueue({ limit, offset: page * limit, platform: platformFilter || undefined, source: sourceFilter || undefined, media_type: typeFilter, sort_by: sortBy, sort_order: sortOrder, date_from: dateFrom || undefined, date_to: dateTo || undefined, size_min: sizeMin ? parseInt(sizeMin) : undefined, size_max: sizeMax ? parseInt(sizeMax) : undefined, search: searchQuery || undefined, }), }) const { data: filters } = useQuery({ queryKey: ['review-filters', platformFilter], queryFn: () => api.getReviewFilters(platformFilter || undefined), }) // Clear source filter when platform changes and source is not available useEffect(() => { if (filters && sourceFilter && !filters.sources.includes(sourceFilter)) { setSourceFilter('') } }, [filters, sourceFilter]) // Update breadcrumb when filter changes useEffect(() => { const typeLabel = typeFilter === 'all' ? '' : typeFilter === 'image' ? 'Images' : 'Videos' if (typeLabel) { setBreadcrumbs([ { label: 'Home', path: '/' }, { label: 'Review', path: '/review' }, { label: typeLabel } ]) } else { setBreadcrumbs(breadcrumbConfig['/review']) } }, [typeFilter, setBreadcrumbs]) // Helper function to advance to next/previous item after delete/keep/add reference const advanceToNextItem = () => { if (!selectedImage) return const currentIndex = filteredFiles.findIndex(f => f.file_path === selectedImage.file_path) if (currentIndex === -1) { setSelectedImage(null) return } // Try next item first if (currentIndex + 1 < filteredFiles.length) { setSelectedImage(filteredFiles[currentIndex + 1]) } // If no next item, try previous else if (currentIndex - 1 >= 0) { setSelectedImage(filteredFiles[currentIndex - 1]) } // If no items left, close lightbox else { setSelectedImage(null) } } // Listen for face recognition completion via websocket useEffect(() => { const unsubscribe = wsClient.on('face_recognition_completed', (data: { success: boolean; filename: string; person_name?: string; error?: string; file_path?: string }) => { // Show toast notification if (data.success) { notificationManager.faceReferenceAdded(data.person_name || 'Unknown', data.filename) } else { notificationManager.faceReferenceError(data.error || `Failed to process ${data.filename}`) } // Update progress for this file (for progress modal if open) setProgressFiles(prev => prev.map(f => f.filename === data.filename || f.id === data.file_path ? { ...f, status: data.success ? 'success' : 'error', error: data.error } : f )) // Refresh all caches to reflect the file move if (data.success) { invalidateAllFileCaches(queryClient) } }) return unsubscribe }, [queryClient]) // Listen for batch move progress via websocket useEffect(() => { const unsubscribe = wsClient.on('batch_move_progress', (data: { success: boolean; filename: string; file_path?: string; error?: string }) => { // Update progress for this file setProgressFiles(prev => prev.map(f => f.filename === data.filename || f.id === data.file_path ? { ...f, status: data.success ? 'success' : 'error', error: data.error } : f )) }) return unsubscribe }, []) // Listen for rescan progress via websocket useEffect(() => { const unsubscribeStarted = wsClient.on('review_rescan_started', (data: { total_files: number }) => { setIsRescanning(true) setRescanProgress({ current: 0, total: data.total_files }) notificationManager.rescanStarted(data.total_files) }) const unsubscribeProgress = wsClient.on('review_rescan_progress', (data: { current: number; total: number }) => { setRescanProgress({ current: data.current, total: data.total }) }) const unsubscribeComplete = wsClient.on('review_rescan_complete', (data: { stats: { updated: number; errors: number } }) => { setIsRescanning(false) setRescanProgress(null) invalidateAllFileCaches(queryClient) notificationManager.rescanCompleted(data.stats.updated) }) const unsubscribeError = wsClient.on('review_rescan_error', (data: { error: string }) => { setIsRescanning(false) setRescanProgress(null) notificationManager.apiError('Rescan Failed', data.error) }) return () => { unsubscribeStarted() unsubscribeProgress() unsubscribeComplete() unsubscribeError() } }, [queryClient]) // Poll for rescan status as fallback when WebSocket isn't working useEffect(() => { if (!isRescanning) return const pollInterval = setInterval(async () => { try { const status = await api.reviewRescanStatus() if (status.running) { setRescanProgress({ current: status.progress.current, total: status.progress.total }) } else { // Scan finished setIsRescanning(false) setRescanProgress(null) if (status.progress.complete) { invalidateAllFileCaches(queryClient) const count = typeof status.progress.stats?.updated === 'number' ? status.progress.stats.updated : 0 notificationManager.rescanCompleted(count) } else if (status.progress.error) { notificationManager.apiError('Rescan Failed', status.progress.error, 'Rescan failed') } clearInterval(pollInterval) } } catch (error) { // Ignore polling errors } }, 1000) return () => clearInterval(pollInterval) }, [isRescanning, queryClient]) // Check for ongoing rescan on page load useEffect(() => { const checkRescanStatus = async () => { try { const status = await api.reviewRescanStatus() if (status.running) { setIsRescanning(true) setRescanProgress({ current: status.progress.current, total: status.progress.total }) } } catch (error) { // Ignore } } checkRescanStatus() }, []) const keepMutation = useMutation({ mutationFn: ({ filePath, destination }: { filePath: string; destination: string }) => api.reviewKeep(filePath, destination), onSuccess: (_data, variables) => { // Advance to next item before invalidating queries advanceToNextItem() optimisticRemoveFromReviewQueue(queryClient, new Set([variables.filePath])) invalidateAllFileCaches(queryClient) setShowKeepModal(false) setActioningFile(null) notificationManager.kept('Image') // Trigger Immich scan (don't await - fire and forget) api.triggerImmichScan().catch(() => {}) }, onError: (err: unknown) => { setActioningFile(null) notificationManager.moveError('image', err) }, }) const deleteMutation = useMutation({ mutationFn: (filePath: string) => api.reviewDelete(filePath), onSuccess: (_data, variables) => { // Advance to next item before invalidating queries advanceToNextItem() optimisticRemoveFromReviewQueue(queryClient, new Set([variables])) invalidateAllFileCaches(queryClient) setActioningFile(null) notificationManager.deleted('Image', 'Image removed from review queue') // Trigger Immich scan (don't await - fire and forget) api.triggerImmichScan().catch(() => {}) }, onError: (err: unknown) => { setActioningFile(null) notificationManager.deleteError('image', err) }, }) const addReferenceMutation = useMutation({ mutationFn: ({ filePath, personName, destination }: { filePath: string; personName: string; destination: string }) => api.reviewAddReference(filePath, personName, destination), onSuccess: (data, variables) => { // Advance to next item before invalidating queries advanceToNextItem() optimisticRemoveFromReviewQueue(queryClient, new Set([variables.filePath])) queryClient.invalidateQueries({ queryKey: ['review-queue'] }) setShowAddModal(false) setActioningFile(null) // File moved successfully, face recognition processing in background notificationManager.processing(`File moved to destination. Analyzing face for ${data.person_name}...`) // Trigger Immich scan (don't await - fire and forget) api.triggerImmichScan().catch(() => {}) // WebSocket will notify when face recognition completes }, onError: (err: unknown) => { setActioningFile(null) notificationManager.faceReferenceError(err) }, }) // Listen for face reference completion via WebSocket useEffect(() => { const unsubscribe = wsClient.on('face_reference_added', (data: { success: boolean; person_name?: string; error?: string }) => { if (data.success) { notificationManager.faceReferenceAdded(data.person_name || 'Unknown') invalidateAllFileCaches(queryClient) } else { notificationManager.faceReferenceError(data.error) } }) return unsubscribe }, [queryClient]) const addFaceReferenceMutation = useMutation({ mutationFn: (file_path: string) => api.addFaceReference(file_path, undefined, true), // background = true onSuccess: (data) => { setSelectedImage(null) // Close lightbox immediately notificationManager.processing(data.message) }, onError: (err: unknown) => { notificationManager.faceReferenceError(err) }, }) const batchDeleteMutation = useMutation({ mutationFn: async (filePaths: string[]) => { // Initialize progress with all files as pending const progressItems: BatchProgressItem[] = filePaths.map(fp => ({ id: fp, filename: fp.split('/').pop() || fp, status: 'pending' as const })) setProgressFiles(progressItems) setProgressTitle('Deleting Files') setShowProgressModal(true) let succeeded = 0 let failed = 0 // Process files one by one for real-time progress for (let i = 0; i < filePaths.length; i++) { const filePath = filePaths[i] // Update progress to 'processing' setProgressFiles(prev => prev.map(f => f.id === filePath ? { ...f, status: 'processing' } : f )) try { await api.reviewDelete(filePath) succeeded++ setProgressFiles(prev => prev.map(f => f.id === filePath ? { ...f, status: 'success' } : f )) } catch (error) { failed++ const errorMsg = (error as Error)?.message || 'Failed to delete' setProgressFiles(prev => prev.map(f => f.id === filePath ? { ...f, status: 'error', error: errorMsg } : f )) } } return { succeeded, failed, total: filePaths.length } }, onSuccess: (data, variables) => { optimisticRemoveFromReviewQueue(queryClient, new Set(variables)) invalidateAllFileCaches(queryClient) setSelectedItems(new Set()) setSelectedPaths(new Map()) setSelectMode(false) // Keep modal open briefly to show completion setTimeout(() => { setShowProgressModal(false) notificationManager.batchSuccess('Delete', data.succeeded, 'item') }, 1500) // Trigger Immich scan (don't await - fire and forget) api.triggerImmichScan().catch(() => {}) }, onError: (err: unknown) => { setShowProgressModal(false) notificationManager.batchError('Delete', err) }, }) const batchKeepMutation = useMutation({ mutationFn: async ({ filePaths }: { filePaths: string[] }) => { let succeeded = 0 let failed = 0 // Process files one by one for (let i = 0; i < filePaths.length; i++) { const filePath = filePaths[i] // Update progress to 'processing' setProgressFiles(prev => prev.map(f => f.id === filePath ? { ...f, status: 'processing' } : f )) try { await api.reviewKeep(filePath, '') succeeded++ // Update to success immediately since we're processing sequentially setProgressFiles(prev => prev.map(f => f.id === filePath ? { ...f, status: 'success' } : f )) } catch (error) { failed++ // Update progress to 'error' immediately if API call fails const errorMsg = (error as Error)?.message || 'Failed to process' setProgressFiles(prev => prev.map(f => f.id === filePath ? { ...f, status: 'error', error: errorMsg } : f )) } } return { succeeded, failed } }, onSuccess: (data, variables) => { optimisticRemoveFromReviewQueue(queryClient, new Set(variables.filePaths)) invalidateAllFileCaches(queryClient) setSelectedItems(new Set()) setSelectedPaths(new Map()) setSelectMode(false) setShowKeepModal(false) // Keep progress modal open for 2 seconds before closing setTimeout(() => { setShowProgressModal(false) notificationManager.batchSuccess('Keep', data.succeeded, 'file') }, 2000) // Trigger Immich scan (don't await - fire and forget) api.triggerImmichScan().catch(() => {}) }, onError: (err: unknown) => { setShowProgressModal(false) notificationManager.batchError('Keep', err) }, }) const batchAddReferenceMutation = useMutation({ mutationFn: async ({ filePaths, personName }: { filePaths: string[]; personName: string }) => { let succeeded = 0 let failed = 0 // Process files one by one for (let i = 0; i < filePaths.length; i++) { const filePath = filePaths[i] // Update progress to 'processing' setProgressFiles(prev => prev.map(f => f.id === filePath ? { ...f, status: 'processing' } : f )) try { await api.reviewAddReference(filePath, personName, '') succeeded++ // Update to success immediately setProgressFiles(prev => prev.map(f => f.id === filePath ? { ...f, status: 'success' } : f )) } catch (error) { failed++ // Update progress to 'error' const errorMsg = (error as Error)?.message || 'Failed to process' setProgressFiles(prev => prev.map(f => f.id === filePath ? { ...f, status: 'error', error: errorMsg } : f )) } } return { succeeded, failed } }, onSuccess: (data, variables) => { optimisticRemoveFromReviewQueue(queryClient, new Set(variables.filePaths)) invalidateAllFileCaches(queryClient) setSelectedItems(new Set()) setSelectedPaths(new Map()) setSelectMode(false) // Keep progress modal open for 2 seconds before closing setTimeout(() => { setShowProgressModal(false) notificationManager.batchSuccess('Add Reference', data.succeeded, 'file') }, 2000) // Trigger Immich scan (don't await - fire and forget) api.triggerImmichScan().catch(() => {}) }, onError: (err: unknown) => { setShowProgressModal(false) notificationManager.batchError('Add Reference', err) }, }) const updateDateMutation = useMutation({ mutationFn: ({ ids, newDate, updateFile }: { ids: number[]; newDate: string; updateFile: boolean }) => api.updateMediaDate(ids, newDate, updateFile, 'post_date'), onSuccess: (data) => { invalidateAllFileCaches(queryClient) setShowDateModal(false) setNewDate('') setEditingItemId(null) notificationManager.dateUpdated(`${data.success_count} file${data.success_count !== 1 ? 's' : ''}`) }, onError: (err: unknown) => { notificationManager.dateUpdateError(err) }, }) // All filtering and sorting is now server-side const filteredFiles = reviewData?.files || [] const totalPages = Math.ceil((reviewData?.total || 0) / limit) const toggleSelect = (filePath: string) => { const newSelected = new Set(selectedItems) const newPaths = new Map(selectedPaths) if (newSelected.has(filePath)) { newSelected.delete(filePath) newPaths.delete(filePath) } else { newSelected.add(filePath) newPaths.set(filePath, filePath) } setSelectedItems(newSelected) setSelectedPaths(newPaths) } const selectAll = () => { if (selectedItems.size === filteredFiles.length) { setSelectedItems(new Set()) setSelectedPaths(new Map()) } else { setSelectedItems(new Set(filteredFiles.map(f => f.file_path))) setSelectedPaths(new Map(filteredFiles.map(f => [f.file_path, f.file_path]))) } } const handleBatchDelete = () => { if (selectedItems.size === 0) return if (confirm(`Delete ${selectedItems.size} selected items?`)) { batchDeleteMutation.mutate(Array.from(selectedItems)) } } const handleBatchKeep = () => { if (selectedItems.size === 0) return setShowKeepModal(true) } const handleDeleteAll = async () => { const total = reviewData?.total || 0 if (total === 0) return if (confirm(`Delete ALL ${total} items in the review queue? This cannot be undone.`)) { try { // Fetch all file paths from the review queue const response = await api.getAllReviewFilePaths() batchDeleteMutation.mutate(response.file_paths) } catch (err) { console.error('Failed to fetch all review file paths:', err) notificationManager.apiError('Error', err, 'Failed to fetch all review files') } } } const handleKeepAll = async () => { const total = reviewData?.total || 0 if (total === 0) return try { // Fetch all file paths from the review queue const response = await api.getAllReviewFilePaths() setSelectedItems(new Set(response.file_paths)) setShowKeepModal(true) } catch (err) { console.error('Failed to fetch all review file paths:', err) notificationManager.apiError('Error', err, 'Failed to fetch all review files') } } const confirmBatchKeep = () => { if (selectedItems.size === 0) return // Close the keep modal setShowKeepModal(false) // Get all file paths const filePaths = Array.from(selectedItems) // Initialize progress tracking const progressItems: BatchProgressItem[] = filePaths.map(fp => ({ id: fp, filename: fp.split('/').pop() || fp, status: 'pending' as const })) setProgressFiles(progressItems) setProgressTitle('Moving Files to Media Library') setShowProgressModal(true) // Start the batch operation batchKeepMutation.mutate({ filePaths }) } const handleBatchAddReference = () => { if (selectedItems.size === 0) return setShowAddModal(true) } const confirmBatchAddReference = () => { if (!addPersonName.trim() || selectedItems.size === 0) return // Close the add modal setShowAddModal(false) // Get all file paths const filePaths = Array.from(selectedItems) // Initialize progress tracking const progressItems: BatchProgressItem[] = filePaths.map(fp => ({ id: fp, filename: fp.split('/').pop() || fp, status: 'pending' as const })) setProgressFiles(progressItems) setProgressTitle(`Adding Face References for "${addPersonName}"`) setShowProgressModal(true) // Start processing batchAddReferenceMutation.mutate({ filePaths, personName: addPersonName }) } const openLightbox = (file: ReviewFile) => { setSelectedImage(file) } // Helper to get current index for lightbox const getCurrentLightboxIndex = () => { if (!selectedImage) return -1 return filteredFiles.findIndex((f) => f.file_path === selectedImage.file_path) } const handleKeep = (file: ReviewFile) => { setActioningFile(file.file_path) setShowKeepModal(true) } const confirmKeep = () => { // Batch operation if (selectedItems.size > 0) { confirmBatchKeep() } // Single file operation else if (actioningFile) { keepMutation.mutate({ filePath: actioningFile, destination: '' }) } } const handleDelete = (file: ReviewFile) => { if (confirm(`Delete ${file.filename}?`)) { setActioningFile(file.file_path) deleteMutation.mutate(file.file_path) } } const handleAddReference = (file: ReviewFile) => { setActioningFile(file.file_path) setShowAddModal(true) } // Date editing handlers const handleSingleChangeDate = (media: ReviewFile) => { setEditingItemId(media.id ?? null) // Pre-fill with current post_date if available if (media.post_date) { const date = new Date(media.post_date) const localDateTime = new Date(date.getTime() - date.getTimezoneOffset() * 60000) .toISOString() .slice(0, 16) setNewDate(localDateTime) } else { setNewDate('') } setShowDateModal(true) } const confirmDateChange = () => { if (!newDate || !editingItemId) return const isoDate = new Date(newDate).toISOString() updateDateMutation.mutate({ ids: [editingItemId], newDate: isoDate, updateFile: updateFileTimestamps }) } const confirmAddReference = () => { if (!addPersonName.trim()) return // Batch operation if (selectedItems.size > 0) { confirmBatchAddReference() } // Single file operation else if (actioningFile) { addReferenceMutation.mutate({ filePath: actioningFile, personName: addPersonName, destination: '', }) } } return (
{/* Header */}

Review Queue

Images that didn't match face recognition - review and take action

{/* Selection Bar */} {selectMode && filteredFiles.length > 0 && (
{selectedItems.size} item{selectedItems.size !== 1 ? 's' : ''} selected
{isFeatureEnabled('/private-gallery') && ( )}
)} {/* Filters */} { setSearchQuery(v); setPage(0) }} searchPlaceholder="Search filenames..." filterSections={[ { id: 'platform', label: 'Platform', type: 'select', options: [{ value: '', label: 'All Platforms' }, ...(filters?.platforms || []).map(p => ({ value: p, label: formatPlatformName(p) }))], value: platformFilter, onChange: (v) => { setPlatformFilter(v as string); setPage(0) } }, { id: 'source', label: 'Source', type: 'select', options: [{ value: '', label: 'All Sources' }, ...(filters?.sources || []).map(s => ({ value: s, label: s }))], value: sourceFilter, onChange: (v) => { setSourceFilter(v as string); setPage(0) } }, { id: 'type', label: 'Media Type', type: 'select', options: [ { value: 'all', label: 'All Media' }, { value: 'image', label: 'Images Only' }, { value: 'video', label: 'Videos Only' } ], value: typeFilter, onChange: (v) => { setTypeFilter(v as 'all' | 'image' | 'video'); setPage(0) } }, { id: 'sortBy', label: 'Sort By', type: 'select', options: [ { value: 'post_date', label: 'Post Date' }, { value: 'added_date', label: 'Added Date' }, { value: 'file_size', label: 'File Size' }, { value: 'filename', label: 'Filename' }, { value: 'source', label: 'Source' }, { value: 'platform', label: 'Platform' }, { value: 'confidence', label: 'Face Confidence' } ], value: sortBy, onChange: (v) => { setSortBy(v as string); setPage(0) } }, { id: 'sortOrder', label: 'Sort Order', type: 'select', options: sortBy === 'confidence' ? [ { value: 'desc', label: 'Highest First' }, { value: 'asc', label: 'Lowest First' } ] : [ { value: 'desc', label: 'Newest First' }, { value: 'asc', label: 'Oldest First' } ], value: sortOrder, onChange: (v) => { setSortOrder(v as 'asc' | 'desc'); setPage(0) } } ]} activeFilters={[ ...(platformFilter ? [{ id: 'platform', label: 'Platform', value: platformFilter, displayValue: formatPlatformName(platformFilter), onRemove: () => { setPlatformFilter(''); setPage(0) } }] : []), ...(sourceFilter ? [{ id: 'source', label: 'Source', value: sourceFilter, displayValue: sourceFilter, onRemove: () => { setSourceFilter(''); setPage(0) } }] : []), ...(typeFilter !== 'all' ? [{ id: 'type', label: 'Type', value: typeFilter, displayValue: typeFilter === 'image' ? 'Images' : 'Videos', onRemove: () => { setTypeFilter('all'); setPage(0) } }] : []), ...(dateFrom ? [{ id: 'dateFrom', label: 'From', value: dateFrom, displayValue: dateFrom, onRemove: () => { setDateFrom(''); setPage(0) } }] : []), ...(dateTo ? [{ id: 'dateTo', label: 'To', value: dateTo, displayValue: dateTo, onRemove: () => { setDateTo(''); setPage(0) } }] : []), ...(sizeMin ? [{ id: 'sizeMin', label: 'Min Size', value: sizeMin, displayValue: `${sizeMin} bytes`, onRemove: () => { setSizeMin(''); setPage(0) } }] : []), ...(sizeMax ? [{ id: 'sizeMax', label: 'Max Size', value: sizeMax, displayValue: `${sizeMax} bytes`, onRemove: () => { setSizeMax(''); setPage(0) } }] : []) ]} onClearAll={() => { setPlatformFilter('') setSourceFilter('') setTypeFilter('all') setSearchQuery('') setDateFrom('') setDateTo('') setSizeMin('') setSizeMax('') setPage(0) }} advancedFilters={{ dateFrom: { value: dateFrom, onChange: (v) => { setDateFrom(v); setPage(0) } }, dateTo: { value: dateTo, onChange: (v) => { setDateTo(v); setPage(0) } }, sizeMin: { value: sizeMin, onChange: (v) => { setSizeMin(v); setPage(0) } }, sizeMax: { value: sizeMax, onChange: (v) => { setSizeMax(v); setPage(0) } } }} totalCount={reviewData?.total} countLabel="items" /> {/* Stats */}
Showing {filteredFiles.length} of {reviewData?.total || 0} items
{totalPages > 1 && (
Page {page + 1} of {totalPages}
)}
{/* Gallery Grid */} {isLoading ? (
{[...Array(20)].map((_, i) => (
))}
) : filteredFiles.length === 0 ? (

{reviewData?.total === 0 ? 'No images in review queue' : 'No items match the current filter'}

{reviewData?.total === 0 ? "Images that don't match face recognition will appear here" : 'Try changing the filter to see more items'}

) : (
{filteredFiles.map((file) => (
selectMode ? toggleSelect(file.file_path) : openLightbox(file)} className={`group relative aspect-square bg-slate-100 dark:bg-slate-800 rounded-lg overflow-hidden cursor-pointer hover:ring-2 card-lift thumbnail-zoom ${ selectedItems.has(file.file_path) ? 'ring-2 ring-blue-500' : 'hover:ring-blue-500' }`} > {/* Thumbnail - use img for both images and videos (thumbnail endpoint returns JPEG) */} {/* Face recognition badge - show confidence for both matches and non-matches */} {file.face_recognition?.scanned && (
= 0.8 ? 'bg-green-600' : (file.face_recognition.confidence ?? 0) >= 0.5 ? 'bg-yellow-600' : 'bg-orange-600' : file.face_recognition.confidence && file.face_recognition.confidence > 0 ? 'bg-red-600' // Below threshold - show in red : 'bg-gray-500' // No confidence data }`}> {file.face_recognition.confidence && file.face_recognition.confidence > 0 ? ( <>{Math.round(file.face_recognition.confidence * 100)}% ) : ( 'No Match' )}
)} {/* Video Icon Indicator */} {isVideoFile(file.filename) && (
)} {/* Select Checkbox */} {selectMode && (
{selectedItems.has(file.file_path) && ( )}
)} {/* Action buttons overlay */} {!selectMode && (
)} {/* File info */} {!selectMode && (

{file.filename}

{formatBytes(file.file_size)}

)}
))}
)} {/* Pagination */} {totalPages > 1 && (
Page {page + 1} of {totalPages}
)} {/* Lightbox */} {selectedImage && getCurrentLightboxIndex() >= 0 && ( setSelectedImage(null)} onNavigate={(index) => setSelectedImage(filteredFiles[index])} onDelete={handleDelete} onEditDate={handleSingleChangeDate} getPreviewUrl={(item) => api.getReviewPreviewUrl(item.file_path)} getThumbnailUrl={(item: ReviewFile) => getReviewThumbnailUrl(item)} isVideo={(item) => isVideoFile(item.filename)} renderActions={(item) => ( <> )} /> )} {/* Keep Modal */} {showKeepModal && (

{selectedItems.size > 0 ? `Move ${selectedItems.size} Items` : 'Move to Destination'}

{selectedItems.size > 0 ? 'Each file will be moved to its intended destination from the database.' : 'This file will be moved to its intended destination from the database.'}

)} {/* Add Reference Modal */} {showAddModal && (

{selectedItems.size > 0 ? `Add ${selectedItems.size} as Face References` : 'Add as Face Reference'}

{selectedItems.size > 0 ? `This will add faces from ${selectedItems.size} image${selectedItems.size !== 1 ? 's' : ''} as references for future matching. Each file will be moved to its intended destination from the database.` : 'This will add the face in this image as a reference for future matching and move it to its intended destination from the database.'}

setAddPersonName(e.target.value)} placeholder="e.g. Eva Longoria" className="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground" autoFocus />
)} {/* Progress Modal */} setShowProgressModal(false)} /> {/* Date Edit Modal */} {showDateModal && (

Edit Post Date

Change the post date for this file. This will update the database and optionally update file timestamps.

setNewDate(e.target.value)} className="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground" autoFocus />
setUpdateFileTimestamps(e.target.checked)} className="w-4 h-4 text-amber-600 border-slate-300 rounded focus:ring-amber-500" />
)} {/* Copy to Private Gallery Modal */} setShowCopyToGalleryModal(false)} sourcePaths={Array.from(selectedPaths.values())} sourceType="review" onSuccess={() => { setShowCopyToGalleryModal(false) setSelectedItems(new Set()) setSelectedPaths(new Map()) setSelectMode(false) }} />
) }