Files
media-downloader/web/frontend/src/pages/Review.tsx
Todd 0d7b2b1aab Initial commit
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 22:42:55 -04:00

1364 lines
54 KiB
TypeScript

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<ReviewFile | null>(null)
const [page, setPage] = useState(0)
const [showKeepModal, setShowKeepModal] = useState(false)
const [showAddModal, setShowAddModal] = useState(false)
const [addPersonName, setAddPersonName] = useState('')
const [actioningFile, setActioningFile] = useState<string | null>(null)
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set())
const [selectedPaths, setSelectedPaths] = useState<Map<string, string>>(new Map())
const [selectMode, setSelectMode] = useState(false)
const [platformFilter, setPlatformFilter] = useState<string>('')
const [sourceFilter, setSourceFilter] = useState<string>('')
const [typeFilter, setTypeFilter] = useState<'all' | 'image' | 'video'>('all')
const [searchQuery, setSearchQuery] = useState<string>('')
const [showProgressModal, setShowProgressModal] = useState(false)
const [progressFiles, setProgressFiles] = useState<BatchProgressItem[]>([])
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<number | null>(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<string>('')
const [dateTo, setDateTo] = useState<string>('')
const [sizeMin, setSizeMin] = useState<string>('')
const [sizeMax, setSizeMax] = useState<string>('')
const [sortBy, setSortBy] = useState<string>('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 (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2 sm:gap-3">
<ClipboardList className="w-8 h-8 text-yellow-500" />
Review Queue
</h1>
<p className="text-slate-600 dark:text-slate-400 mt-1">
Images that didn't match face recognition - review and take action
</p>
</div>
<div className="flex flex-wrap gap-3">
<button
onClick={() => setSelectMode(!selectMode)}
className="px-4 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 min-h-[44px] transition-colors"
>
{selectMode ? 'Cancel Selection' : 'Select Multiple'}
</button>
<button
onClick={async () => {
try {
await api.reviewRescan()
} catch (err) {
notificationManager.apiError('Rescan Failed', err, 'Failed to start rescan')
}
}}
disabled={(reviewData?.total || 0) === 0 || isRescanning}
className="px-4 py-2.5 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 min-h-[44px] transition-colors"
>
<RefreshCw className={`w-4 h-4 ${isRescanning ? 'animate-spin' : ''}`} />
{isRescanning && rescanProgress
? `Scanning ${rescanProgress.current}/${rescanProgress.total}`
: 'Rescan Faces'}
</button>
<button
onClick={handleKeepAll}
disabled={(reviewData?.total || 0) === 0 || batchKeepMutation.isPending}
className="px-4 py-2.5 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed min-h-[44px] transition-colors"
>
Keep All ({reviewData?.total || 0})
</button>
<button
onClick={handleDeleteAll}
disabled={(reviewData?.total || 0) === 0 || batchDeleteMutation.isPending}
className="px-4 py-2.5 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed min-h-[44px] transition-colors"
>
Delete All ({reviewData?.total || 0})
</button>
</div>
</div>
{/* Selection Bar */}
{selectMode && filteredFiles.length > 0 && (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex flex-wrap items-center gap-3">
<button
onClick={selectAll}
className="flex items-center space-x-2 px-3 py-2 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-700 text-sm min-h-[44px] transition-colors"
>
{selectedItems.size === filteredFiles.length ? (
<CheckSquare className="w-5 h-5" />
) : (
<Square className="w-5 h-5" />
)}
<span>{selectedItems.size === filteredFiles.length ? 'Deselect All' : 'Select All'}</span>
</button>
<span className="text-sm text-blue-700 dark:text-blue-400">
{selectedItems.size} item{selectedItems.size !== 1 ? 's' : ''} selected
</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
onClick={handleBatchKeep}
disabled={selectedItems.size === 0 || batchKeepMutation.isPending}
className="flex items-center space-x-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed min-h-[44px] text-sm transition-colors"
>
<Check className="w-5 h-5" />
<span className="hidden sm:inline">Keep Selected</span>
<span className="sm:hidden">Keep</span>
</button>
{isFeatureEnabled('/private-gallery') && (
<button
onClick={() => setShowCopyToGalleryModal(true)}
disabled={selectedItems.size === 0}
className="flex items-center space-x-2 px-3 py-2 bg-violet-600 text-white rounded-lg hover:bg-violet-700 disabled:opacity-50 disabled:cursor-not-allowed min-h-[44px] text-sm transition-colors"
>
<ImagePlus className="w-5 h-5" />
<span className="hidden sm:inline">Copy to Private</span>
<span className="sm:hidden">Private</span>
</button>
)}
<button
onClick={handleBatchAddReference}
disabled={selectedItems.size === 0 || batchAddReferenceMutation.isPending}
className="flex items-center space-x-2 px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed min-h-[44px] text-sm transition-colors"
>
<UserPlus className="w-5 h-5" />
<span className="hidden sm:inline">Add as Reference</span>
<span className="sm:hidden">Reference</span>
</button>
<button
onClick={handleBatchDelete}
disabled={selectedItems.size === 0 || batchDeleteMutation.isPending}
className="flex items-center space-x-2 px-3 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed min-h-[44px] text-sm transition-colors"
>
<Trash2 className="w-5 h-5" />
<span className="hidden sm:inline">Delete Selected</span>
<span className="sm:hidden">Delete</span>
</button>
</div>
</div>
</div>
)}
{/* Filters */}
<FilterBar
searchValue={searchQuery}
onSearchChange={(v) => { 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 */}
<div className="flex items-center justify-between text-sm text-slate-600 dark:text-slate-400">
<div>
Showing {filteredFiles.length} of {reviewData?.total || 0} items
</div>
{totalPages > 1 && (
<div>
Page {page + 1} of {totalPages}
</div>
)}
</div>
{/* Gallery Grid */}
{isLoading ? (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{[...Array(20)].map((_, i) => (
<div key={i} className="aspect-square bg-slate-200 dark:bg-slate-800 rounded-lg animate-pulse" />
))}
</div>
) : filteredFiles.length === 0 ? (
<div className="text-center py-20">
<p className="text-slate-500 dark:text-slate-400 text-lg">
{reviewData?.total === 0 ? 'No images in review queue' : 'No items match the current filter'}
</p>
<p className="text-slate-400 dark:text-slate-500 text-sm mt-2">
{reviewData?.total === 0
? "Images that don't match face recognition will appear here"
: 'Try changing the filter to see more items'}
</p>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{filteredFiles.map((file) => (
<div
key={file.file_path}
onClick={() => 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) */}
<ThrottledImage
src={getReviewThumbnailUrl(file)}
alt={file.filename}
className="w-full h-full object-cover"
/>
{/* Face recognition badge - show confidence for both matches and non-matches */}
{file.face_recognition?.scanned && (
<div className={`absolute ${selectMode ? 'top-10' : 'top-2'} left-2 px-1.5 py-0.5 text-white text-xs font-medium rounded z-10 ${
file.face_recognition.matched
? (file.face_recognition.confidence ?? 0) >= 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'
)}
</div>
)}
{/* Video Icon Indicator */}
{isVideoFile(file.filename) && (
<div className="absolute top-2 right-2 bg-black/70 rounded-full p-1.5">
<Play className="w-4 h-4 text-white fill-white" />
</div>
)}
{/* Select Checkbox */}
{selectMode && (
<div className="absolute top-2 left-2 z-20">
<div className={`w-6 h-6 rounded border-2 flex items-center justify-center transition-colors ${
selectedItems.has(file.file_path)
? 'bg-blue-600 border-blue-600'
: 'bg-white/90 border-slate-300'
}`}>
{selectedItems.has(file.file_path) && (
<Check className="w-4 h-4 text-white" />
)}
</div>
</div>
)}
{/* Action buttons overlay */}
{!selectMode && (
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center space-y-2 p-2">
<button
onClick={(e) => {
e.stopPropagation()
handleKeep(file)
}}
disabled={actioningFile === file.file_path}
className="w-full px-3 py-2.5 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 flex items-center justify-center space-x-2 text-sm min-h-[44px] transition-colors"
>
<Check className="w-4 h-4" />
<span>Keep</span>
</button>
<button
onClick={(e) => {
e.stopPropagation()
handleAddReference(file)
}}
disabled={actioningFile === file.file_path}
className="w-full px-3 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center justify-center space-x-2 text-sm min-h-[44px] transition-colors"
>
<UserPlus className="w-4 h-4" />
<span>Add Reference</span>
</button>
<button
onClick={(e) => {
e.stopPropagation()
handleDelete(file)
}}
disabled={actioningFile === file.file_path}
className="w-full px-3 py-2.5 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 flex items-center justify-center space-x-2 text-sm min-h-[44px] transition-colors"
>
<Trash2 className="w-4 h-4" />
<span>Delete</span>
</button>
</div>
)}
{/* File info */}
{!selectMode && (
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-2 opacity-0 group-hover:opacity-100 transition-opacity">
<p className="text-white text-xs truncate">{file.filename}</p>
<p className="text-white/70 text-xs">{formatBytes(file.file_size)}</p>
</div>
)}
</div>
))}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center space-x-2">
<button
onClick={() => setPage(Math.max(0, page - 1))}
disabled={page === 0}
className="p-2 rounded-lg bg-slate-200 dark:bg-slate-800 text-slate-700 dark:text-slate-300 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-slate-300 dark:hover:bg-slate-700 min-w-[44px] min-h-[44px] transition-colors"
>
<ChevronLeft className="w-5 h-5" />
</button>
<span className="text-sm text-slate-600 dark:text-slate-400">
Page {page + 1} of {totalPages}
</span>
<button
onClick={() => setPage(Math.min(totalPages - 1, page + 1))}
disabled={page >= totalPages - 1}
className="p-2 rounded-lg bg-slate-200 dark:bg-slate-800 text-slate-700 dark:text-slate-300 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-slate-300 dark:hover:bg-slate-700 min-w-[44px] min-h-[44px] transition-colors"
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
)}
{/* Lightbox */}
{selectedImage && getCurrentLightboxIndex() >= 0 && (
<EnhancedLightbox
items={filteredFiles}
currentIndex={getCurrentLightboxIndex()}
onClose={() => 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) => (
<>
<button
onClick={() => handleKeep(item)}
disabled={actioningFile === item.file_path}
className="px-3 py-1.5 md:px-6 md:py-3 bg-green-600 text-white text-sm md:text-base rounded-lg hover:bg-green-700 disabled:opacity-50 flex items-center space-x-1.5 md:space-x-2 transition-colors"
>
<Check className="w-4 h-4 md:w-5 md:h-5" />
<span>Keep</span>
</button>
<button
onClick={() => handleAddReference(item)}
disabled={actioningFile === item.file_path}
className="px-3 py-1.5 md:px-6 md:py-3 bg-blue-600 text-white text-sm md:text-base rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center space-x-1.5 md:space-x-2 transition-colors"
>
<UserPlus className="w-4 h-4 md:w-5 md:h-5" />
<span>Add Reference</span>
</button>
<button
onClick={() => addFaceReferenceMutation.mutate(item.file_path)}
disabled={addFaceReferenceMutation.isPending}
className="px-3 py-1.5 md:px-6 md:py-3 bg-purple-600 text-white text-sm md:text-base rounded-lg hover:bg-purple-700 disabled:opacity-50 flex items-center space-x-1.5 md:space-x-2 transition-colors"
>
<UserPlus className="w-4 h-4 md:w-5 md:h-5" />
<span>{addFaceReferenceMutation.isPending ? 'Adding...' : 'Quick Add Face'}</span>
</button>
<button
onClick={() => handleDelete(item)}
disabled={actioningFile === item.file_path}
className="px-3 py-1.5 md:px-6 md:py-3 bg-red-600 text-white text-sm md:text-base rounded-lg hover:bg-red-700 disabled:opacity-50 flex items-center space-x-1.5 md:space-x-2 transition-colors"
>
<Trash2 className="w-4 h-4 md:w-5 md:h-5" />
<span>Delete</span>
</button>
</>
)}
/>
)}
{/* Keep Modal */}
{showKeepModal && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-card border border-border rounded-xl shadow-2xl p-6 max-w-md w-full">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-4">
{selectedItems.size > 0 ? `Move ${selectedItems.size} Items` : 'Move to Destination'}
</h3>
<p className="text-sm text-slate-600 dark:text-slate-400 mb-4">
{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.'}
</p>
<div className="flex justify-end space-x-2">
<button
onClick={() => {
setShowKeepModal(false)
setActioningFile(null)
}}
className="px-4 py-2.5 text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg min-h-[44px] transition-colors"
>
Cancel
</button>
<button
onClick={confirmKeep}
disabled={keepMutation.isPending}
className="px-4 py-2.5 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 min-h-[44px] transition-colors"
>
{keepMutation.isPending ? 'Moving...' : 'Move'}
</button>
</div>
</div>
</div>
)}
{/* Add Reference Modal */}
{showAddModal && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-card border border-border rounded-xl shadow-2xl p-6 max-w-md w-full">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-4">
{selectedItems.size > 0 ? `Add ${selectedItems.size} as Face References` : 'Add as Face Reference'}
</h3>
<p className="text-sm text-slate-600 dark:text-slate-400 mb-4">
{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.'}
</p>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
Person Name
</label>
<input
type="text"
value={addPersonName}
onChange={(e) => 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
/>
</div>
<div className="flex justify-end space-x-2 mt-6">
<button
onClick={() => {
setShowAddModal(false)
setActioningFile(null)
}}
className="px-4 py-2.5 text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg min-h-[44px] transition-colors"
>
Cancel
</button>
<button
onClick={confirmAddReference}
disabled={!addPersonName.trim() || addReferenceMutation.isPending}
className="px-4 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 min-h-[44px] transition-colors"
>
{addReferenceMutation.isPending ? 'Adding...' : 'Add Reference'}
</button>
</div>
</div>
</div>
)}
{/* Progress Modal */}
<BatchProgressModal
isOpen={showProgressModal}
title={progressTitle}
items={progressFiles}
onClose={() => setShowProgressModal(false)}
/>
{/* Date Edit Modal */}
{showDateModal && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-card border border-border rounded-xl shadow-2xl p-6 max-w-md w-full">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-4 flex items-center space-x-2">
<CalendarClock className="w-5 h-5 text-amber-500" />
<span>Edit Post Date</span>
</h3>
<p className="text-sm text-slate-600 dark:text-slate-400 mb-4">
Change the post date for this file. This will update the database and optionally update file timestamps.
</p>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
New Date and Time
</label>
<input
type="datetime-local"
value={newDate}
onChange={(e) => setNewDate(e.target.value)}
className="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground"
autoFocus
/>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="updateFileTimestampsReview"
checked={updateFileTimestamps}
onChange={(e) => setUpdateFileTimestamps(e.target.checked)}
className="w-4 h-4 text-amber-600 border-slate-300 rounded focus:ring-amber-500"
/>
<label htmlFor="updateFileTimestampsReview" className="text-sm text-slate-700 dark:text-slate-300">
Update file timestamps (EXIF/metadata and filesystem)
</label>
</div>
</div>
<div className="flex justify-end space-x-2 mt-6">
<button
onClick={() => {
setShowDateModal(false)
setNewDate('')
setEditingItemId(null)
}}
className="px-4 py-2.5 text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg min-h-[44px] transition-colors"
>
Cancel
</button>
<button
onClick={confirmDateChange}
disabled={!newDate || updateDateMutation.isPending}
className="px-4 py-2.5 bg-amber-600 text-white rounded-lg hover:bg-amber-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2 min-h-[44px] transition-colors"
>
{updateDateMutation.isPending ? (
<>
<span className="animate-spin">⏳</span>
<span>Updating...</span>
</>
) : (
<span>Apply</span>
)}
</button>
</div>
</div>
</div>
)}
{/* Copy to Private Gallery Modal */}
<CopyToGalleryModal
open={showCopyToGalleryModal}
onClose={() => setShowCopyToGalleryModal(false)}
sourcePaths={Array.from(selectedPaths.values())}
sourceType="review"
onSuccess={() => {
setShowCopyToGalleryModal(false)
setSelectedItems(new Set())
setSelectedPaths(new Map())
setSelectMode(false)
}}
/>
</div>
)
}