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

1507 lines
63 KiB
TypeScript

import { useState, useEffect, useRef, useCallback } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useBreadcrumb } from '../hooks/useBreadcrumb'
import { breadcrumbConfig } from '../config/breadcrumbConfig'
import { FolderDown, Loader, AlertCircle, Play, Eye, UserPlus, Trash2, X, RotateCcw, CheckCircle, Trash, CalendarClock, RefreshCw, ChevronDown, CheckSquare, Square, Download, ImagePlus } from 'lucide-react'
import { api, wsClient } from '../lib/api'
import { formatPlatformName } from '../lib/utils'
import { notificationManager } from '../lib/notificationManager'
import { invalidateAllFileCaches } from '../lib/cacheInvalidation'
import EnhancedLightbox from '../components/EnhancedLightbox'
import { FilterBar } from '../components/FilterPopover'
import { CopyToGalleryModal } from '../components/private-gallery/CopyToGalleryModal'
import { useEnabledFeatures } from '../hooks/useEnabledFeatures'
interface MediaFile {
id: number | string
file_path: string
filename: string
platform: string
source: string
content_type: string
media_type: 'image' | 'video'
file_size: number
width: number | null
height: number | null
download_date: string
post_date?: string | null
deleted_from: string | null
location_type?: 'media' | 'review' | 'recycle'
video_id?: string | null
}
interface DayGroup {
date: string
count: number
items: MediaFile[]
summary: {
by_location: { media: number; review: number; recycle: number }
by_platform: Record<string, number>
}
}
type LocationFilter = 'all' | 'media' | 'review' | 'recycle'
import LazyThumbnail from '../components/LazyThumbnail'
export default function Downloads() {
useBreadcrumb(breadcrumbConfig['/downloads'])
const { isFeatureEnabled } = useEnabledFeatures()
const queryClient = useQueryClient()
const [days, setDays] = useState<DayGroup[]>([])
const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [error, setError] = useState<string | null>(null)
const [hasMore, setHasMore] = useState(true)
// Filters
const [filterLocation, setFilterLocation] = useState<LocationFilter>('media')
const [filterPlatform, setFilterPlatform] = useState<string>('')
const [filterSource, setFilterSource] = useState<string>('')
const [searchQuery, setSearchQuery] = useState<string>('')
const [platforms, setPlatforms] = useState<string[]>([])
const [sources, setSources] = useState<string[]>([])
// Advanced filters
const [dateFrom, setDateFrom] = useState<string>('')
const [dateTo, setDateTo] = useState<string>('')
const [sizeMin, setSizeMin] = useState<string>('')
const [sizeMax, setSizeMax] = useState<string>('')
// Lightbox
const [lightboxItems, setLightboxItems] = useState<MediaFile[]>([])
const [lightboxIndex, setLightboxIndex] = useState<number>(0)
// Batch selection
const [selectMode, setSelectMode] = useState(false)
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set())
// Actions
const [actioningFile, setActioningFile] = useState<string | null>(null)
const [showAddModal, setShowAddModal] = useState(false)
const [addPersonName, setAddPersonName] = useState('')
// Date edit modal state
const [showDateModal, setShowDateModal] = useState(false)
const [editingItemId, setEditingItemId] = useState<number | string | null>(null)
const [newDate, setNewDate] = useState('')
const [updateFileTimestamps, setUpdateFileTimestamps] = useState(true)
// Copy to Private Gallery modal state
const [showCopyToGalleryModal, setShowCopyToGalleryModal] = useState(false)
// Infinite scroll ref
const loadMoreRef = useRef<HTMLDivElement>(null)
// Mutations
const batchDeleteMutation = useMutation({
mutationFn: (filePaths: string[]) => api.batchDeleteMedia(filePaths),
onSuccess: (_data, variables) => {
optimisticRemoveFromDays(new Set(variables))
invalidateAllFileCaches(queryClient)
setActioningFile(null)
setLightboxItems([])
loadData(true)
api.triggerImmichScan().catch(() => {})
},
onError: () => {
setActioningFile(null)
notificationManager.error('Delete Failed', 'Failed to delete file', '')
},
})
const moveToReviewMutation = useMutation({
mutationFn: (filePaths: string[]) => api.moveToReview(filePaths),
onSuccess: (data, variables) => {
optimisticRemoveFromDays(new Set(variables))
invalidateAllFileCaches(queryClient)
setActioningFile(null)
setLightboxItems([])
loadData(true)
notificationManager.success('Moved to Review', `${data.moved_count} item${data.moved_count !== 1 ? 's' : ''} moved to review queue`, '')
api.triggerImmichScan().catch(() => {})
},
onError: () => {
setActioningFile(null)
notificationManager.error('Move Failed', 'Failed to move to review', '')
},
})
const addFaceReferenceMutation = useMutation({
mutationFn: (file_path: string) => api.addFaceReference(file_path, undefined, true),
onSuccess: (data) => {
setLightboxItems([])
notificationManager.info('Processing', data.message, '')
},
onError: (error: Error & { response?: { data?: { detail?: string } } }) => {
const errorMessage = error?.response?.data?.detail || error?.message || 'Failed to add face reference'
notificationManager.error('Add Reference Failed', errorMessage, '')
},
})
const addReferenceMutation = useMutation({
mutationFn: ({ filePath, personName }: { filePath: string; personName: string }) =>
api.addFaceReference(filePath, personName, false),
onSuccess: (data) => {
invalidateAllFileCaches(queryClient)
setShowAddModal(false)
setActioningFile(null)
setLightboxItems([])
setAddPersonName('')
notificationManager.success('Face Reference Added', `Added as reference for ${data.person_name}`, '')
api.triggerImmichScan().catch(() => {})
},
onError: (error: Error & { response?: { data?: { detail?: string } } }) => {
setActioningFile(null)
const errorMessage = error?.response?.data?.detail || error?.message || 'Failed to add reference face'
notificationManager.error('Add Reference Failed', errorMessage, '')
},
})
const restoreMutation = useMutation({
mutationFn: (recycleId: string) => api.post('/recycle/restore', { recycle_id: recycleId }),
onSuccess: () => {
if (actioningFile) optimisticRemoveFromDays(new Set([actioningFile]))
invalidateAllFileCaches(queryClient)
setActioningFile(null)
setLightboxItems([])
loadData(true)
notificationManager.success('Restored', 'File restored from recycle bin', '')
api.triggerImmichScan().catch(() => {})
},
onError: () => {
setActioningFile(null)
notificationManager.error('Restore Failed', 'Failed to restore file', '')
},
})
const permanentDeleteMutation = useMutation({
mutationFn: (recycleId: string) => api.delete(`/recycle/delete/${recycleId}`),
onSuccess: () => {
if (actioningFile) optimisticRemoveFromDays(new Set([actioningFile]))
invalidateAllFileCaches(queryClient)
setActioningFile(null)
setLightboxItems([])
loadData(true)
notificationManager.success('Deleted', 'File permanently deleted', '')
},
onError: () => {
setActioningFile(null)
notificationManager.error('Delete Failed', 'Failed to permanently delete file', '')
},
})
const reviewKeepMutation = useMutation({
mutationFn: (filePath: string) => api.reviewKeep(filePath, ''),
onSuccess: (_data, variables) => {
optimisticRemoveFromDays(new Set([variables]))
invalidateAllFileCaches(queryClient)
setActioningFile(null)
setLightboxItems([])
loadData(true)
notificationManager.success('Kept', 'File moved to media library', '')
api.triggerImmichScan().catch(() => {})
},
onError: () => {
setActioningFile(null)
notificationManager.error('Keep Failed', 'Failed to keep file', '')
},
})
const reviewDeleteMutation = useMutation({
mutationFn: (filePath: string) => api.reviewDelete(filePath),
onSuccess: (_data, variables) => {
optimisticRemoveFromDays(new Set([variables]))
invalidateAllFileCaches(queryClient)
setActioningFile(null)
setLightboxItems([])
loadData(true)
notificationManager.success('Deleted', 'File moved to recycle bin', '')
},
onError: () => {
setActioningFile(null)
notificationManager.error('Delete Failed', 'Failed to delete file', '')
},
})
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.success(
'Date Updated',
`${data.success_count} file${data.success_count !== 1 ? 's' : ''} updated successfully`,
''
)
},
onError: () => {
notificationManager.error('Date Update Failed', 'Failed to update file date', '')
},
})
// Optimistic removal helper - removes items from days state immediately
const optimisticRemoveFromDays = (filePaths: Set<string>) => {
setDays(prev => prev
.map(day => {
const filtered = day.items.filter(item => !filePaths.has(item.file_path))
return {
...day,
items: filtered,
count: filtered.length,
}
})
.filter(day => day.items.length > 0)
)
}
// Action handlers
const handleSingleMoveToReview = (filePath: string) => {
if (confirm('Move this item to review queue?')) {
setActioningFile(filePath)
moveToReviewMutation.mutate([filePath])
}
}
const handleAddReference = (media: MediaFile) => {
setActioningFile(media.file_path)
setShowAddModal(true)
}
const handleDelete = (media: MediaFile) => {
if (confirm(`Delete "${media.filename}"?`)) {
setActioningFile(media.file_path)
batchDeleteMutation.mutate([media.file_path])
}
}
const confirmAddReference = () => {
if (!addPersonName.trim() || !actioningFile) return
addReferenceMutation.mutate({ filePath: actioningFile, personName: addPersonName })
}
const handleSingleChangeDate = (media: MediaFile) => {
setEditingItemId(media.id)
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()
// Extract numeric ID from composite ID like "media_123"
const idStr = String(editingItemId)
const numericId = idStr.includes('_') ? parseInt(idStr.split('_')[1], 10) : (typeof editingItemId === 'number' ? editingItemId : parseInt(idStr, 10))
updateDateMutation.mutate({ ids: [numericId], newDate: isoDate, updateFile: updateFileTimestamps })
}
const handleRestore = (media: MediaFile) => {
if (confirm('Restore this file from recycle bin?')) {
setActioningFile(media.file_path)
// Extract numeric ID from composite ID like "recycle_123"
const id = String(media.id)
const numericId = id.includes('_') ? id.split('_')[1] : id
restoreMutation.mutate(numericId)
}
}
const handlePermanentDelete = (media: MediaFile) => {
if (confirm('Permanently delete this file? This cannot be undone.')) {
setActioningFile(media.file_path)
// Extract numeric ID from composite ID like "recycle_123"
const id = String(media.id)
const numericId = id.includes('_') ? id.split('_')[1] : id
permanentDeleteMutation.mutate(numericId)
}
}
const handleReviewKeep = (media: MediaFile) => {
if (confirm('Keep this file and move to media library?')) {
setActioningFile(media.file_path)
reviewKeepMutation.mutate(media.file_path)
}
}
const handleReviewDelete = (media: MediaFile) => {
if (confirm('Delete this file (move to recycle bin)?')) {
setActioningFile(media.file_path)
reviewDeleteMutation.mutate(media.file_path)
}
}
// Batch selection helpers
const toggleSelect = (mediaId: string) => {
const newSelected = new Set(selectedItems)
if (newSelected.has(mediaId)) {
newSelected.delete(mediaId)
} else {
newSelected.add(mediaId)
}
setSelectedItems(newSelected)
}
const getAllItemIds = (): string[] => {
return days.flatMap(day => day.items.map(item => String(item.id)))
}
const selectAll = () => {
const allIds = getAllItemIds()
if (selectedItems.size === allIds.length) {
setSelectedItems(new Set())
} else {
setSelectedItems(new Set(allIds))
}
}
const getSelectedMedia = (): MediaFile[] => {
return days.flatMap(day => day.items.filter(item => selectedItems.has(String(item.id))))
}
// Batch action handlers
const handleBatchDelete = () => {
const selected = getSelectedMedia()
if (selected.length === 0) return
if (confirm(`Delete ${selected.length} selected item${selected.length !== 1 ? 's' : ''}?`)) {
const paths = selected.map(m => m.file_path)
batchDeleteMutation.mutate(paths)
setSelectedItems(new Set())
setSelectMode(false)
}
}
const handleBatchMoveToReview = () => {
const selected = getSelectedMedia()
if (selected.length === 0) return
if (confirm(`Move ${selected.length} item${selected.length !== 1 ? 's' : ''} to review queue?`)) {
const paths = selected.map(m => m.file_path)
moveToReviewMutation.mutate(paths)
setSelectedItems(new Set())
setSelectMode(false)
}
}
const handleBatchReviewKeep = async () => {
const selected = getSelectedMedia()
if (selected.length === 0) return
if (confirm(`Keep ${selected.length} item${selected.length !== 1 ? 's' : ''} and move to media library?`)) {
for (const media of selected) {
await api.reviewKeep(media.file_path, '')
}
optimisticRemoveFromDays(new Set(selected.map(m => m.file_path)))
invalidateAllFileCaches(queryClient)
loadData(true)
notificationManager.success('Batch Keep', `${selected.length} item${selected.length !== 1 ? 's' : ''} moved to media library`, '')
setSelectedItems(new Set())
setSelectMode(false)
api.triggerImmichScan().catch(() => {})
}
}
const handleBatchReviewDelete = async () => {
const selected = getSelectedMedia()
if (selected.length === 0) return
if (confirm(`Delete ${selected.length} item${selected.length !== 1 ? 's' : ''} (move to recycle bin)?`)) {
for (const media of selected) {
await api.reviewDelete(media.file_path)
}
optimisticRemoveFromDays(new Set(selected.map(m => m.file_path)))
invalidateAllFileCaches(queryClient)
loadData(true)
notificationManager.success('Batch Delete', `${selected.length} item${selected.length !== 1 ? 's' : ''} moved to recycle bin`, '')
setSelectedItems(new Set())
setSelectMode(false)
}
}
const handleBatchRestore = async () => {
const selected = getSelectedMedia()
if (selected.length === 0) return
if (confirm(`Restore ${selected.length} item${selected.length !== 1 ? 's' : ''} from recycle bin?`)) {
for (const media of selected) {
const numericId = getNumericId(media)
await api.post('/recycle/restore', { recycle_id: numericId })
}
optimisticRemoveFromDays(new Set(selected.map(m => m.file_path)))
invalidateAllFileCaches(queryClient)
loadData(true)
notificationManager.success('Batch Restore', `${selected.length} item${selected.length !== 1 ? 's' : ''} restored`, '')
setSelectedItems(new Set())
setSelectMode(false)
api.triggerImmichScan().catch(() => {})
}
}
const handleBatchPermanentDelete = async () => {
const selected = getSelectedMedia()
if (selected.length === 0) return
if (confirm(`Permanently delete ${selected.length} item${selected.length !== 1 ? 's' : ''}? This cannot be undone.`)) {
for (const media of selected) {
const numericId = getNumericId(media)
await api.delete(`/recycle/delete/${numericId}`)
}
optimisticRemoveFromDays(new Set(selected.map(m => m.file_path)))
invalidateAllFileCaches(queryClient)
loadData(true)
notificationManager.success('Batch Delete', `${selected.length} item${selected.length !== 1 ? 's' : ''} permanently deleted`, '')
setSelectedItems(new Set())
setSelectMode(false)
}
}
const handleBatchDownload = async () => {
const selected = getSelectedMedia()
if (selected.length === 0) return
const paths = selected.map(m => m.file_path)
try {
await api.batchDownloadMedia(paths)
notificationManager.success('Download Ready', `ZIP file with ${selected.length} items is ready`, '')
} catch {
notificationManager.error('Download Failed', 'Failed to create download', '')
}
}
// Get effective location for an item (uses location_type when in 'all' mode)
const getItemLocation = (media: MediaFile): 'media' | 'review' | 'recycle' => {
if (filterLocation === 'all' && media.location_type) {
return media.location_type
}
return filterLocation === 'all' ? 'media' : filterLocation
}
// Get numeric ID from composite ID (e.g., "recycle_123" -> 123)
const getNumericId = (media: MediaFile): string => {
const id = String(media.id)
if (id.includes('_')) {
return id.split('_')[1]
}
return id
}
// Get thumbnail URL based on location
const getThumbnailUrl = (media: MediaFile) => {
const itemLoc = getItemLocation(media)
if (itemLoc === 'recycle') {
// Security: Auth via httpOnly cookie only - no token in URL
const mediaType = media.media_type === 'video' ? 'video' : 'image'
return `/api/recycle/file/${getNumericId(media)}?thumbnail=true&type=${mediaType}`
}
// For YouTube videos with video_id, use stored thumbnail from database
if (media.platform === 'youtube' && media.video_id) {
return `/api/video/thumbnail/${media.platform}/${media.video_id}?source=downloads`
}
return api.getMediaThumbnailUrl(media.file_path, media.media_type)
}
// Get preview URL based on location
const getPreviewUrl = (media: MediaFile) => {
const itemLoc = getItemLocation(media)
if (itemLoc === 'recycle') {
// Security: Auth via httpOnly cookie only - no token in URL
return `/api/recycle/file/${getNumericId(media)}`
}
return api.getMediaPreviewUrl(media.file_path)
}
const openLightbox = async (mediaFiles: MediaFile[], index: number) => {
// Enrich media files with metadata
const enrichedFiles = await Promise.all(
mediaFiles.map(async (file) => {
try {
const itemLoc = getItemLocation(file)
if (itemLoc === 'recycle') {
const response = await api.get(`/recycle/metadata/${getNumericId(file)}`) as { width?: number; height?: number; file_size?: number; duration?: number; platform?: string; source?: string }
if (response) {
return {
...file,
width: response.width ?? file.width,
height: response.height ?? file.height,
file_size: response.file_size || file.file_size,
}
}
} else {
const response = await api.get(`/media/metadata?file_path=${encodeURIComponent(file.file_path)}`) as { width?: number; height?: number; file_size?: number; duration?: number }
if (response) {
return {
...file,
width: response.width ?? file.width,
height: response.height ?? file.height,
file_size: response.file_size || file.file_size,
}
}
}
} catch (error) {
console.log('Could not fetch full metadata for', file.filename)
}
return file
})
)
setLightboxItems(enrichedFiles)
setLightboxIndex(index)
}
// Load data function
const loadData = useCallback(async (reset: boolean = false) => {
try {
if (reset) {
setLoading(true)
setDays([])
} else {
setLoadingMore(true)
}
const offsetDate = reset ? undefined : (days.length > 0 ? days[days.length - 1].date : undefined)
const response = await api.getDownloadsByDay({
location: filterLocation,
platform: filterPlatform || undefined,
source: filterSource || undefined,
limit_days: 7,
offset_date: offsetDate,
items_per_day: 100,
date_from: dateFrom || undefined,
date_to: dateTo || undefined,
size_min: sizeMin ? parseInt(sizeMin) : undefined,
size_max: sizeMax ? parseInt(sizeMax) : undefined,
search: searchQuery || undefined,
})
if (reset) {
setDays(response.days)
} else {
setDays(prev => [...prev, ...response.days])
}
setHasMore(response.has_more)
setError(null)
} catch (err) {
console.error('Failed to load downloads:', err)
setError('Failed to load downloads')
} finally {
setLoading(false)
setLoadingMore(false)
}
}, [filterLocation, filterPlatform, filterSource, days, dateFrom, dateTo, sizeMin, sizeMax, searchQuery])
// Load filters
const loadFilters = useCallback(async () => {
try {
const response = await api.getDownloadsByDayFilters(filterLocation, filterPlatform || undefined)
setPlatforms(response.platforms)
setSources(response.sources)
} catch (err) {
console.error('Failed to load filters:', err)
}
}, [filterLocation, filterPlatform])
// Initial load and reload when filters change
useEffect(() => {
loadData(true)
loadFilters()
}, [filterLocation, filterPlatform, filterSource, dateFrom, dateTo, sizeMin, sizeMax, searchQuery])
// Reset source filter when platform changes
useEffect(() => {
if (filterSource && sources.length > 0 && !sources.includes(filterSource)) {
setFilterSource('')
}
}, [sources, filterSource])
// Infinite scroll observer
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading && !loadingMore) {
loadData(false)
}
},
{ rootMargin: '200px' }
)
if (loadMoreRef.current) {
observer.observe(loadMoreRef.current)
}
return () => observer.disconnect()
}, [hasMore, loading, loadingMore, loadData])
// Listen for new downloads via WebSocket
useEffect(() => {
const unsubscribe = wsClient.on('download_completed', () => {
if (filterLocation === 'media') {
loadData(true)
}
})
return unsubscribe
}, [filterLocation, loadData])
// Listen for face reference completion
useEffect(() => {
const unsubscribe = wsClient.on('face_reference_added', (data: { success: boolean; person_name?: string; error?: string }) => {
if (data.success) {
notificationManager.success('Face Reference Added', `Added as reference for ${data.person_name}`, '')
invalidateAllFileCaches(queryClient)
} else {
notificationManager.error('Add Reference Failed', data.error || 'Failed to add face reference', '')
}
})
return unsubscribe
}, [queryClient])
// Format date for display
const formatDayHeader = (dateStr: string) => {
const date = new Date(dateStr + 'T00:00:00')
const today = new Date()
today.setHours(0, 0, 0, 0)
const yesterday = new Date(today)
yesterday.setDate(yesterday.getDate() - 1)
if (date.getTime() === today.getTime()) {
return 'Today'
} else if (date.getTime() === yesterday.getTime()) {
return 'Yesterday'
} else {
return date.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
}
// All filtering is now server-side
const filteredDays = days
// Get location-specific label
const getLocationLabel = (loc: LocationFilter | 'media' | 'review' | 'recycle') => {
switch (loc) {
case 'all': return 'All Locations'
case 'media': return 'Media Library'
case 'review': return 'Review Queue'
case 'recycle': return 'Recycle Bin'
}
}
// Get border color for thumbnail based on item location
const getItemBorderColor = (media: MediaFile) => {
const loc = getItemLocation(media)
switch (loc) {
case 'review': return 'border-orange-400 dark:border-orange-500'
case 'recycle': return 'border-red-400 dark:border-red-500'
default: return 'border-slate-200 dark:border-slate-700 hover:border-blue-500'
}
}
// Calculate total items across all filtered days
const totalItems = filteredDays.reduce((sum, day) => sum + day.count, 0)
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2 sm:gap-3">
<FolderDown className="w-8 h-8 text-green-500" />
Downloads
</h1>
<p className="text-slate-600 dark:text-slate-400 mt-1">
Browse and manage your downloaded media
</p>
</div>
<div className="flex items-center gap-3">
{filterLocation !== 'all' && (
<button
onClick={() => {
setSelectMode(!selectMode)
setSelectedItems(new Set())
}}
className={`px-4 py-2.5 rounded-lg text-sm font-medium transition-colors min-h-[44px] ${
selectMode
? 'bg-blue-600 text-white'
: 'bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300 hover:bg-slate-300 dark:hover:bg-slate-600'
}`}
>
{selectMode ? 'Cancel Selection' : 'Select Items'}
</button>
)}
<button
onClick={() => loadData(true)}
disabled={loading}
className="px-4 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors btn-hover-lift flex items-center gap-2 min-h-[44px]"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
</div>
{/* Filters */}
<FilterBar
searchValue={searchQuery}
onSearchChange={setSearchQuery}
searchPlaceholder="Search files..."
filterSections={[
{
id: 'location',
label: 'Location',
type: 'select',
options: [
{ value: 'all', label: 'All Locations' },
{ value: 'media', label: 'Media Library' },
{ value: 'review', label: 'Review Queue' },
{ value: 'recycle', label: 'Recycle Bin' }
],
value: filterLocation,
onChange: (v) => {
setFilterLocation(v as LocationFilter)
setFilterPlatform('')
setFilterSource('')
}
},
{
id: 'platform',
label: 'Platform',
type: 'select',
options: [{ value: '', label: 'All Platforms' }, ...platforms.map(p => ({ value: p, label: formatPlatformName(p) }))],
value: filterPlatform,
onChange: (v) => {
setFilterPlatform(v as string)
setFilterSource('')
}
},
{
id: 'source',
label: 'Source',
type: 'select',
options: [{ value: '', label: 'All Sources' }, ...sources.map(s => ({ value: s, label: s }))],
value: filterSource,
onChange: (v) => setFilterSource(v as string)
}
]}
activeFilters={[
...(filterLocation !== 'all' ? [{
id: 'location',
label: 'Location',
value: filterLocation,
displayValue: filterLocation === 'media' ? 'Media Library' : filterLocation === 'review' ? 'Review Queue' : 'Recycle Bin',
onRemove: () => setFilterLocation('all')
}] : []),
...(filterPlatform ? [{
id: 'platform',
label: 'Platform',
value: filterPlatform,
displayValue: formatPlatformName(filterPlatform),
onRemove: () => setFilterPlatform('')
}] : []),
...(filterSource ? [{
id: 'source',
label: 'Source',
value: filterSource,
displayValue: filterSource,
onRemove: () => setFilterSource('')
}] : []),
...(dateFrom ? [{
id: 'dateFrom',
label: 'From',
value: dateFrom,
displayValue: dateFrom,
onRemove: () => setDateFrom('')
}] : []),
...(dateTo ? [{
id: 'dateTo',
label: 'To',
value: dateTo,
displayValue: dateTo,
onRemove: () => setDateTo('')
}] : []),
...(sizeMin ? [{
id: 'sizeMin',
label: 'Min Size',
value: sizeMin,
displayValue: `${sizeMin} bytes`,
onRemove: () => setSizeMin('')
}] : []),
...(sizeMax ? [{
id: 'sizeMax',
label: 'Max Size',
value: sizeMax,
displayValue: `${sizeMax} bytes`,
onRemove: () => setSizeMax('')
}] : [])
]}
onClearAll={() => {
setFilterLocation('media')
setFilterPlatform('')
setFilterSource('')
setSearchQuery('')
setDateFrom('')
setDateTo('')
setSizeMin('')
setSizeMax('')
}}
advancedFilters={{
dateFrom: { value: dateFrom, onChange: setDateFrom },
dateTo: { value: dateTo, onChange: setDateTo },
sizeMin: { value: sizeMin, onChange: setSizeMin },
sizeMax: { value: sizeMax, onChange: setSizeMax }
}}
/>
{/* Batch Operations Bar */}
{selectMode && filterLocation !== 'all' && (
<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 text-sm font-medium text-blue-700 dark:text-blue-400 hover:text-blue-900 dark:hover:text-blue-200 min-h-[44px]"
>
{selectedItems.size === getAllItemIds().length ? (
<CheckSquare className="w-5 h-5" />
) : (
<Square className="w-5 h-5" />
)}
<span>{selectedItems.size === getAllItemIds().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">
{/* Media location actions */}
{filterLocation === 'media' && (
<>
<button
onClick={handleBatchDownload}
disabled={selectedItems.size === 0}
className="flex items-center space-x-2 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] text-sm transition-colors"
>
<Download className="w-5 h-5" />
<span className="hidden sm:inline">Download ZIP</span>
<span className="sm:hidden">ZIP</span>
</button>
{isFeatureEnabled('/private-gallery') && (
<button
onClick={() => setShowCopyToGalleryModal(true)}
disabled={selectedItems.size === 0}
className="flex items-center space-x-2 px-4 py-2.5 bg-purple-600 text-white rounded-lg hover:bg-purple-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 Gallery</span>
<span className="sm:hidden">Private</span>
</button>
)}
<button
onClick={handleBatchMoveToReview}
disabled={selectedItems.size === 0 || moveToReviewMutation.isPending}
className="flex items-center space-x-2 px-4 py-2.5 bg-orange-600 text-white rounded-lg hover:bg-orange-700 disabled:opacity-50 disabled:cursor-not-allowed min-h-[44px] text-sm transition-colors"
>
<Eye className="w-5 h-5" />
<span className="hidden sm:inline">Move to Review</span>
<span className="sm:hidden">Review</span>
</button>
<button
onClick={handleBatchDelete}
disabled={selectedItems.size === 0 || batchDeleteMutation.isPending}
className="flex items-center space-x-2 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] 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>
</>
)}
{/* Review location actions */}
{filterLocation === 'review' && (
<>
<button
onClick={handleBatchReviewKeep}
disabled={selectedItems.size === 0}
className="flex items-center space-x-2 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] text-sm transition-colors"
>
<CheckCircle className="w-5 h-5" />
<span className="hidden sm:inline">Keep Selected</span>
<span className="sm:hidden">Keep</span>
</button>
<button
onClick={handleBatchReviewDelete}
disabled={selectedItems.size === 0}
className="flex items-center space-x-2 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] 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>
</>
)}
{/* Recycle location actions */}
{filterLocation === 'recycle' && (
<>
<button
onClick={handleBatchRestore}
disabled={selectedItems.size === 0}
className="flex items-center space-x-2 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] text-sm transition-colors"
>
<RotateCcw className="w-5 h-5" />
<span className="hidden sm:inline">Restore Selected</span>
<span className="sm:hidden">Restore</span>
</button>
<button
onClick={handleBatchPermanentDelete}
disabled={selectedItems.size === 0}
className="flex items-center space-x-2 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] text-sm transition-colors"
>
<Trash className="w-5 h-5" />
<span className="hidden sm:inline">Delete Forever</span>
<span className="sm:hidden">Delete</span>
</button>
</>
)}
</div>
</div>
</div>
)}
{/* Stats */}
<div className="flex items-center justify-between text-sm text-slate-600 dark:text-slate-400">
<div>
{loading ? 'Loading...' : searchQuery
? `${totalItems.toLocaleString()} matches across ${filteredDays.length} day${filteredDays.length !== 1 ? 's' : ''}`
: `${totalItems.toLocaleString()} items across ${filteredDays.length} day${filteredDays.length !== 1 ? 's' : ''}`
}
</div>
<div>
{getLocationLabel(filterLocation)}
</div>
</div>
{/* Error */}
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div className="flex items-center space-x-2">
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400" />
<p className="text-red-700 dark:text-red-400">{error}</p>
</div>
</div>
)}
{/* Days List */}
<div className="space-y-4">
{/* Loading Skeleton */}
{loading && filteredDays.length === 0 ? (
<div className="card-glass rounded-xl overflow-hidden">
<div className="px-4 py-3 bg-secondary/50 border-b border-border">
<div className="h-6 w-32 bg-slate-200 dark:bg-slate-700 rounded animate-pulse" />
</div>
<div className="p-4">
<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>
</div>
</div>
) : filteredDays.length === 0 ? (
<div className="card-glass rounded-xl p-12 text-center">
<FolderDown className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground">
{searchQuery ? `No matches for "${searchQuery}"` : `No items found in ${getLocationLabel(filterLocation)}`}
</p>
</div>
) : (
filteredDays.map((day) => (
<div key={day.date} className="card-glass rounded-xl overflow-hidden">
{/* Day Header */}
<div className="px-4 py-3 bg-secondary/50 border-b border-border flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
{formatDayHeader(day.date)}
</h2>
<span className="text-sm text-slate-500 dark:text-slate-400">
{day.count} item{day.count !== 1 ? 's' : ''}
</span>
</div>
{/* Per-Day Summary */}
{day.summary && (
<div className="px-4 py-2 bg-slate-50 dark:bg-slate-800/50 border-b border-border">
<div className="flex flex-wrap items-center gap-2 text-xs">
{/* Location breakdown - only show if in 'all' mode or if there are items in other locations */}
{(filterLocation === 'all' || day.summary.by_location.review > 0 || day.summary.by_location.recycle > 0) && (
<>
{day.summary.by_location.media > 0 && (
<span className="px-1.5 py-0.5 bg-green-500/20 text-green-600 dark:text-green-400 rounded">
{day.summary.by_location.media} media
</span>
)}
{day.summary.by_location.review > 0 && (
<span className="px-1.5 py-0.5 bg-orange-500/20 text-orange-600 dark:text-orange-400 rounded">
{day.summary.by_location.review} review
</span>
)}
{day.summary.by_location.recycle > 0 && (
<span className="px-1.5 py-0.5 bg-red-500/20 text-red-600 dark:text-red-400 rounded">
{day.summary.by_location.recycle} recycle
</span>
)}
<span className="text-slate-400 dark:text-slate-500">|</span>
</>
)}
{/* Platform breakdown */}
{Object.entries(day.summary.by_platform).slice(0, 5).map(([platform, count]) => (
<span
key={platform}
className="px-1.5 py-0.5 bg-slate-200 dark:bg-slate-700 text-slate-600 dark:text-slate-300 rounded"
>
{formatPlatformName(platform)}: {count}
</span>
))}
{Object.keys(day.summary.by_platform).length > 5 && (
<span className="text-slate-400 dark:text-slate-500">
+{Object.keys(day.summary.by_platform).length - 5} more
</span>
)}
</div>
</div>
)}
{/* Thumbnail Grid */}
<div className="p-4">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{day.items.map((media, idx) => {
const itemLoc = getItemLocation(media)
const mediaId = String(media.id)
const isSelected = selectedItems.has(mediaId)
return (
<div
key={media.id}
className={`relative aspect-square cursor-pointer group rounded-lg overflow-hidden border-2 transition-all card-lift thumbnail-zoom ${
isSelected
? 'ring-2 ring-blue-500'
: 'hover:ring-2 hover:ring-blue-500'
} ${getItemBorderColor(media)}`}
onClick={() => selectMode ? toggleSelect(mediaId) : openLightbox(day.items, idx)}
>
<LazyThumbnail
src={getThumbnailUrl(media)}
alt={media.filename}
className="w-full h-full object-cover"
/>
{/* Select Checkbox */}
{selectMode && (
<div className="absolute top-2 left-2 z-10">
<div className={`w-6 h-6 rounded border-2 flex items-center justify-center transition-colors ${
isSelected
? 'bg-blue-600 border-blue-600'
: 'bg-white/90 border-slate-300'
}`}>
{isSelected && (
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
</div>
</div>
)}
{/* Status Badge */}
{!selectMode && itemLoc === 'review' && (
<div className="absolute top-2 left-2 bg-orange-500 text-white px-2 py-0.5 rounded text-xs font-medium">
In Review
</div>
)}
{!selectMode && itemLoc === 'recycle' && (
<div className="absolute top-2 left-2 bg-red-500 text-white px-2 py-0.5 rounded text-xs font-medium">
Recycle Bin
</div>
)}
{/* Video indicator */}
{media.media_type === 'video' && (
<div className={`absolute ${itemLoc !== 'media' ? 'top-8' : 'top-2'} right-2 bg-black/70 rounded-full p-1.5`}>
<Play className="w-4 h-4 text-white fill-white" />
</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">
{itemLoc === 'media' && (
<>
<button
onClick={(e) => { e.stopPropagation(); handleSingleMoveToReview(media.file_path) }}
disabled={actioningFile === media.file_path}
className="w-full px-3 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 disabled:opacity-50 flex items-center justify-center space-x-2 text-sm transition-colors"
>
<Eye className="w-4 h-4" />
<span>Review</span>
</button>
<button
onClick={(e) => { e.stopPropagation(); handleAddReference(media) }}
disabled={actioningFile === media.file_path}
className="w-full px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center justify-center space-x-2 text-sm transition-colors btn-hover-lift"
>
<UserPlus className="w-4 h-4" />
<span>Add Reference</span>
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDelete(media) }}
disabled={actioningFile === media.file_path}
className="w-full px-3 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 flex items-center justify-center space-x-2 text-sm transition-colors"
>
<Trash2 className="w-4 h-4" />
<span>Delete</span>
</button>
</>
)}
{itemLoc === 'review' && (
<>
<button
onClick={(e) => { e.stopPropagation(); handleReviewKeep(media) }}
disabled={actioningFile === media.file_path}
className="w-full px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 flex items-center justify-center space-x-2 text-sm transition-colors btn-hover-lift"
>
<CheckCircle className="w-4 h-4" />
<span>Keep</span>
</button>
<button
onClick={(e) => { e.stopPropagation(); handleReviewDelete(media) }}
disabled={actioningFile === media.file_path}
className="w-full px-3 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 flex items-center justify-center space-x-2 text-sm transition-colors"
>
<Trash2 className="w-4 h-4" />
<span>Delete</span>
</button>
</>
)}
{itemLoc === 'recycle' && (
<>
<button
onClick={(e) => { e.stopPropagation(); handleRestore(media) }}
disabled={actioningFile === media.file_path}
className="w-full px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 flex items-center justify-center space-x-2 text-sm transition-colors btn-hover-lift"
>
<RotateCcw className="w-4 h-4" />
<span>Restore</span>
</button>
<button
onClick={(e) => { e.stopPropagation(); handlePermanentDelete(media) }}
disabled={actioningFile === media.file_path}
className="w-full px-3 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 flex items-center justify-center space-x-2 text-sm transition-colors"
>
<Trash className="w-4 h-4" />
<span>Delete Forever</span>
</button>
</>
)}
</div>
)}
{/* File info overlay */}
<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">{media.filename}</p>
</div>
</div>
)})} {/* Close the return and map */}
</div>
</div>
</div>
))
)}
{/* Load More / Loading Indicator */}
<div ref={loadMoreRef} className="flex justify-center py-4">
{loadingMore && (
<div className="flex items-center gap-2 text-slate-500 dark:text-slate-400">
<Loader className="w-5 h-5 animate-spin" />
<span>Loading more...</span>
</div>
)}
{!loadingMore && hasMore && days.length > 0 && (
<button
onClick={() => loadData(false)}
className="flex items-center gap-2 px-4 py-2 text-sm text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100"
>
<ChevronDown className="w-4 h-4" />
Load more
</button>
)}
{!hasMore && days.length > 0 && (
<p className="text-sm text-slate-500 dark:text-slate-400">No more items to load</p>
)}
</div>
</div>
{/* Enhanced Lightbox */}
{lightboxItems.length > 0 && (
<EnhancedLightbox
items={lightboxItems}
currentIndex={lightboxIndex}
onNavigate={setLightboxIndex}
onClose={() => setLightboxItems([])}
onDelete={(item: MediaFile) => getItemLocation(item) === 'media' ? handleDelete(item) : undefined}
onEditDate={(item: MediaFile) => getItemLocation(item) === 'media' ? handleSingleChangeDate(item) : undefined}
getPreviewUrl={(item: MediaFile) => getPreviewUrl(item)}
getThumbnailUrl={(item: MediaFile) => getThumbnailUrl(item)}
isVideo={(item: MediaFile) => item.media_type === 'video'}
renderActions={(item: MediaFile) => {
const itemLoc = getItemLocation(item)
if (itemLoc === 'media') {
return (
<>
<button
onClick={() => handleSingleMoveToReview(item.file_path)}
disabled={moveToReviewMutation.isPending || actioningFile === item.file_path}
className="px-3 py-1.5 md:px-6 md:py-3 bg-orange-600 text-white text-sm md:text-base rounded-lg hover:bg-orange-700 disabled:opacity-50 flex items-center space-x-1.5 md:space-x-2 transition-colors"
>
<Eye className="w-4 h-4 md:w-5 md:h-5" />
<span>{moveToReviewMutation.isPending ? 'Moving...' : 'Move to Review'}</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 btn-hover-lift"
>
<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>
</>
)
}
if (itemLoc === 'review') {
return (
<>
<div className="px-3 py-1.5 bg-orange-500/80 text-white text-sm rounded-lg">
In Review Queue
</div>
<button
onClick={() => handleReviewKeep(item)}
disabled={reviewKeepMutation.isPending || 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 btn-hover-lift"
>
<CheckCircle className="w-4 h-4 md:w-5 md:h-5" />
<span>{reviewKeepMutation.isPending ? 'Keeping...' : 'Keep'}</span>
</button>
<button
onClick={() => handleReviewDelete(item)}
disabled={reviewDeleteMutation.isPending || 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>{reviewDeleteMutation.isPending ? 'Deleting...' : 'Delete'}</span>
</button>
</>
)
}
if (itemLoc === 'recycle') {
return (
<>
<div className="px-3 py-1.5 bg-red-500/80 text-white text-sm rounded-lg">
In Recycle Bin
</div>
<button
onClick={() => handleRestore(item)}
disabled={restoreMutation.isPending || 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 btn-hover-lift"
>
<RotateCcw className="w-4 h-4 md:w-5 md:h-5" />
<span>{restoreMutation.isPending ? 'Restoring...' : 'Restore'}</span>
</button>
<button
onClick={() => handlePermanentDelete(item)}
disabled={permanentDeleteMutation.isPending || 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"
>
<Trash className="w-4 h-4 md:w-5 md:h-5" />
<span>{permanentDeleteMutation.isPending ? 'Deleting...' : 'Delete Forever'}</span>
</button>
</>
)
}
return null
}}
/>
)}
{/* Add Reference Modal */}
{showAddModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card border border-border rounded-xl p-6 max-w-md w-full shadow-2xl">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold text-slate-900 dark:text-slate-100">Add Face Reference</h2>
<button
onClick={() => {
setShowAddModal(false)
setAddPersonName('')
setActioningFile(null)
}}
className="text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
>
<X className="w-6 h-6" />
</button>
</div>
<div className="space-y-4">
<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)}
onKeyDown={(e) => {
if (e.key === 'Enter') confirmAddReference()
}}
placeholder="Enter person name..."
className="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground placeholder-muted-foreground"
autoFocus
/>
</div>
<div className="flex justify-end space-x-2">
<button
onClick={() => {
setShowAddModal(false)
setAddPersonName('')
setActioningFile(null)
}}
className="px-4 py-2 text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100"
>
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 disabled:cursor-not-allowed transition-colors btn-hover-lift min-h-[44px]"
>
{addReferenceMutation.isPending ? 'Adding...' : 'Add Reference'}
</button>
</div>
</div>
</div>
</div>
)}
{/* 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 p-6 max-w-md w-full shadow-2xl">
<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="updateFileTimestampsDownloads"
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="updateFileTimestampsDownloads" 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 transition-colors min-h-[44px]"
>
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 transition-colors min-h-[44px]"
>
{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={getSelectedMedia().map(m => m.file_path)}
sourceType="downloads"
onSuccess={() => {
setShowCopyToGalleryModal(false)
setSelectedItems(new Set())
setSelectMode(false)
}}
/>
</div>
)
}