1364 lines
54 KiB
TypeScript
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>
|
|
)
|
|
}
|