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

1260 lines
50 KiB
TypeScript

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useState, useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import { useBreadcrumb } from '../hooks/useBreadcrumb'
import { breadcrumbConfig } from '../config/breadcrumbConfig'
import { Play, Trash2, CheckSquare, Square, Download, FolderInput, UserPlus, Eye, GalleryHorizontalEnd, CalendarClock, ImagePlus } from 'lucide-react'
import { FilterBar } from '../components/FilterPopover'
import { api, wsClient, type MediaGalleryItem } from '../lib/api'
import { formatBytes, formatPlatformName, isVideoFile as isVideoFileUtil } from '../lib/utils'
import { notificationManager } from '../lib/notificationManager'
import { invalidateAllFileCaches, optimisticRemoveFromMediaGallery } from '../lib/cacheInvalidation'
import EnhancedLightbox from '../components/EnhancedLightbox'
import { BatchProgressModal, BatchProgressItem } from '../components/BatchProgressModal'
import ThrottledImage from '../components/ThrottledImage'
import { CopyToGalleryModal } from '../components/private-gallery/CopyToGalleryModal'
import { useEnabledFeatures } from '../hooks/useEnabledFeatures'
// Type for navigation state from smart folders and timeline
interface MediaPageState {
platform?: string
source?: string
mediaType?: 'all' | 'image' | 'video'
sizeMin?: number
dateFrom?: string
dateTo?: string
}
export default function Media() {
const queryClient = useQueryClient()
const location = useLocation()
const { isFeatureEnabled } = useEnabledFeatures()
const navState = location.state as MediaPageState | null
const [selectedMedia, setSelectedMedia] = useState<MediaGalleryItem | null>(null)
// Breadcrumb with context for type filter
const { setBreadcrumbs } = useBreadcrumb(breadcrumbConfig['/media'])
// Helper function to check if file is a video (uses centralized utility)
const isVideoFile = (media: MediaGalleryItem): boolean => {
if (media.media_type === 'video') return true
return isVideoFileUtil(media.filename)
}
// Get thumbnail URL - use stored YouTube thumbnail if available
const getMediaThumbnailUrl = (media: MediaGalleryItem): string => {
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 as 'image' | 'video') || 'image')
}
const [, setMediaResolution] = useState<string>('')
const [platformFilter, setPlatformFilter] = useState<string>('')
const [sourceFilter, setSourceFilter] = useState<string>('')
const [typeFilter, setTypeFilter] = useState<'all' | 'image' | 'video'>('all')
const [faceRecognitionFilter, setFaceRecognitionFilter] = useState<string>('')
const [searchQuery, setSearchQuery] = useState<string>('')
// Advanced 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>('post_date')
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc')
// Apply navigation state from smart folders/timeline on mount
useEffect(() => {
if (navState) {
if (navState.platform) setPlatformFilter(navState.platform)
if (navState.source) setSourceFilter(navState.source)
if (navState.mediaType) setTypeFilter(navState.mediaType)
if (navState.sizeMin) setSizeMin(navState.sizeMin.toString())
if (navState.dateFrom) setDateFrom(navState.dateFrom)
if (navState.dateTo) setDateTo(navState.dateTo)
// Clear state after applying
window.history.replaceState({}, '')
}
}, [navState])
// Update breadcrumb when filter changes
useEffect(() => {
const typeLabel = typeFilter === 'all' ? '' : typeFilter === 'image' ? 'Images' : 'Videos'
if (typeLabel) {
setBreadcrumbs([
{ label: 'Home', path: '/' },
{ label: 'Media Library', path: '/media' },
{ label: typeLabel }
])
} else {
setBreadcrumbs(breadcrumbConfig['/media'])
}
}, [typeFilter, setBreadcrumbs])
const [page, setPage] = useState(0)
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set())
const [selectedPaths, setSelectedPaths] = useState<Map<number, string>>(new Map())
const [selectMode, setSelectMode] = useState(false)
const [showMoveModal, setShowMoveModal] = useState(false)
const [moveDestination, setMoveDestination] = useState('')
const [showAddModal, setShowAddModal] = useState(false)
const [addPersonName, setAddPersonName] = useState('')
const [actioningFile, setActioningFile] = useState<string | null>(null)
const [showProgressModal, setShowProgressModal] = useState(false)
const [progressFiles, setProgressFiles] = useState<BatchProgressItem[]>([])
const [progressTitle, setProgressTitle] = useState('Processing Files')
// Date edit modal state
const [showDateModal, setShowDateModal] = useState(false)
const [dateModalMode, setDateModalMode] = useState<'single' | 'batch'>('single')
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)
const limit = 50
const { data: mediaData, isLoading } = useQuery({
queryKey: ['media-gallery', platformFilter, sourceFilter, typeFilter, faceRecognitionFilter, page, sortBy, sortOrder, dateFrom, dateTo, sizeMin, sizeMax, searchQuery],
queryFn: () =>
api.getMediaGallery({
platform: platformFilter || undefined,
source: sourceFilter || undefined,
media_type: typeFilter,
face_recognition: faceRecognitionFilter || undefined,
limit,
offset: page * limit,
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,
}),
staleTime: 30000, // Cache for 30 seconds
gcTime: 5 * 60000, // Keep in memory for 5 minutes
})
const { data: filters } = useQuery({
queryKey: ['download-filters', platformFilter],
queryFn: () => api.getDownloadFilters(platformFilter || undefined),
})
// Clear source filter when platform changes and source is not available
useEffect(() => {
if (filters && sourceFilter && !filters.sources.includes(sourceFilter)) {
setSourceFilter('')
}
}, [filters, sourceFilter])
// Helper function to advance to next/previous item after delete/move
const advanceToNextItem = () => {
if (!selectedMedia) return
const currentIndex = filteredMedia.findIndex(m => m.id === selectedMedia.id)
if (currentIndex === -1) {
setSelectedMedia(null)
return
}
// Try next item first
if (currentIndex + 1 < filteredMedia.length) {
setSelectedMedia(filteredMedia[currentIndex + 1])
}
// If no next item, try previous
else if (currentIndex - 1 >= 0) {
setSelectedMedia(filteredMedia[currentIndex - 1])
}
// If no items left, close lightbox
else {
setSelectedMedia(null)
}
}
// Listen for new downloads and face reference updates via WebSocket
useEffect(() => {
const unsubscribeDownload = wsClient.on('download_completed', () => {
invalidateAllFileCaches(queryClient)
})
const unsubscribeFaceRef = wsClient.on('face_reference_added', (data: { success: boolean; filename?: string; error?: string }) => {
if (data.success) {
notificationManager.faceReferenceAdded('Unknown', data.filename)
invalidateAllFileCaches(queryClient)
} else {
notificationManager.faceReferenceError(data.error || (data.filename ? `Failed to process ${data.filename}` : 'Failed'))
}
})
return () => {
unsubscribeDownload()
unsubscribeFaceRef()
}
}, [queryClient])
const batchDeleteMutation = useMutation({
mutationFn: async (filePaths: string[]) => {
// Show progress for batch operations
if (filePaths.length > 1) {
const progressItems: BatchProgressItem[] = filePaths.map(fp => ({
id: fp,
filename: fp.split('/').pop() || fp,
status: 'processing' as const
}))
setProgressFiles(progressItems)
setProgressTitle('Deleting Files')
setShowProgressModal(true)
}
const result = await api.batchDeleteMedia(filePaths)
return { ...result, total: filePaths.length, isBatch: filePaths.length > 1 }
},
onSuccess: (data, variables) => {
// Update progress to show completion
if (data.isBatch) {
setProgressFiles(prev => prev.map((f, i) =>
i < data.deleted_count
? { ...f, status: 'success' }
: { ...f, status: 'error', error: 'Failed to delete' }
))
setTimeout(() => {
setShowProgressModal(false)
// Notification handled by websocket batch_delete_completed in App.tsx
}, 1500)
}
// If lightbox is open, advance to next item
if (selectedMedia) {
advanceToNextItem()
}
optimisticRemoveFromMediaGallery(queryClient, new Set(variables))
invalidateAllFileCaches(queryClient)
setSelectedItems(new Set())
setSelectedPaths(new Map())
setSelectMode(false)
// Trigger Immich scan (don't await - fire and forget)
api.triggerImmichScan().catch(() => {})
},
onError: (err: unknown, variables) => {
setShowProgressModal(false)
const isBatch = variables.length > 1
if (isBatch) {
notificationManager.batchError('Delete', err)
} else {
notificationManager.deleteError('item', err)
}
},
})
const batchMoveMutation = useMutation({
mutationFn: async ({ filePaths, destination }: { filePaths: string[], destination: string }) => {
// Show progress
const progressItems: BatchProgressItem[] = filePaths.map(fp => ({
id: fp,
filename: fp.split('/').pop() || fp,
status: 'processing' as const
}))
setProgressFiles(progressItems)
setProgressTitle('Moving Files')
setShowProgressModal(true)
setShowMoveModal(false)
const result = await api.batchMoveMedia(filePaths, destination)
return { ...result, total: filePaths.length }
},
onSuccess: (data, variables) => {
// Update progress
setProgressFiles(prev => prev.map((f, i) =>
i < data.moved_count
? { ...f, status: 'success' }
: { ...f, status: 'error', error: 'Failed to move' }
))
optimisticRemoveFromMediaGallery(queryClient, new Set(variables.filePaths))
invalidateAllFileCaches(queryClient)
setSelectedItems(new Set())
setSelectedPaths(new Map())
setSelectMode(false)
setMoveDestination('')
setTimeout(() => {
setShowProgressModal(false)
// Notification handled by websocket batch_move_completed in App.tsx
}, 1500)
// Trigger Immich scan (don't await - fire and forget)
api.triggerImmichScan().catch(() => {})
},
onError: (err: unknown) => {
setShowProgressModal(false)
notificationManager.batchError('Move', err)
},
})
const batchDownloadMutation = useMutation({
mutationFn: async (filePaths: string[]) => {
// Show progress
const progressItems: BatchProgressItem[] = filePaths.map(fp => ({
id: fp,
filename: fp.split('/').pop() || fp,
status: 'processing' as const
}))
setProgressFiles(progressItems)
setProgressTitle('Preparing Download')
setShowProgressModal(true)
const result = await api.batchDownloadMedia(filePaths)
return { ...result, total: filePaths.length }
},
onSuccess: (data) => {
// Mark all as success
setProgressFiles(prev => prev.map(f => ({ ...f, status: 'success' as const })))
setTimeout(() => {
setShowProgressModal(false)
notificationManager.downloadReady(data.total)
}, 1500)
},
onError: (err: unknown) => {
setShowProgressModal(false)
notificationManager.apiError('Download Failed', err, `Failed to download ${selectedItems.size} items`)
},
})
const addFaceReferenceMutation = useMutation({
mutationFn: (file_path: string) => api.addFaceReference(file_path, undefined, true), // background = true
onSuccess: (data) => {
setSelectedMedia(null) // Close lightbox immediately
notificationManager.processing(data.message)
},
onError: (err: unknown) => {
notificationManager.faceReferenceError(err)
},
})
const batchAddFaceReferenceMutation = useMutation({
mutationFn: async ({ files }: { files: Array<{ path: string; filename: string }> }) => {
// Show progress modal
const progressItems: BatchProgressItem[] = files.map(f => ({
id: f.path,
filename: f.filename,
status: 'pending' as const
}))
setProgressFiles(progressItems)
setProgressTitle('Processing Face References')
setShowProgressModal(true)
let succeeded = 0
let failed = 0
// Process files sequentially with progress updates
for (const file of files) {
// Mark current as processing
setProgressFiles(prev => prev.map(f =>
f.id === file.path ? { ...f, status: 'processing' } : f
))
try {
await api.addFaceReference(file.path, undefined, true)
succeeded++
setProgressFiles(prev => prev.map(f =>
f.id === file.path ? { ...f, status: 'success' } : f
))
} catch (err) {
failed++
setProgressFiles(prev => prev.map(f =>
f.id === file.path ? { ...f, status: 'error', error: (err as Error)?.message || 'Failed' } : f
))
}
}
return { total: files.length, succeeded, failed }
},
onSuccess: (data) => {
setTimeout(() => {
setShowProgressModal(false)
notificationManager.batchSuccess('Add Reference', data.succeeded, 'file')
}, 1500)
},
onError: (err: unknown) => {
setShowProgressModal(false)
notificationManager.batchError('Add Reference', err)
},
})
const addReferenceMutation = useMutation({
mutationFn: ({ filePath, personName }: { filePath: string; personName: string }) =>
api.addFaceReference(filePath, personName, false), // background = false for immediate feedback
onSuccess: (data) => {
invalidateAllFileCaches(queryClient)
setShowAddModal(false)
setActioningFile(null)
setSelectedMedia(null) // Close lightbox
notificationManager.faceReferenceAdded(data.person_name)
// Trigger Immich scan (don't await - fire and forget)
api.triggerImmichScan().catch(() => {})
},
onError: (err: unknown) => {
setActioningFile(null)
notificationManager.faceReferenceError(err)
},
})
const moveToReviewMutation = useMutation({
mutationFn: (filePaths: string[]) => api.moveToReview(filePaths),
onSuccess: (data, variables) => {
// If lightbox is open, advance to next item
if (selectedMedia) {
advanceToNextItem()
}
optimisticRemoveFromMediaGallery(queryClient, new Set(variables))
invalidateAllFileCaches(queryClient)
setSelectedItems(new Set())
setSelectedPaths(new Map())
setSelectMode(false)
notificationManager.movedToReview(data.moved_count)
// Trigger Immich scan (don't await - fire and forget)
api.triggerImmichScan().catch(() => {})
},
onError: (err: unknown) => {
notificationManager.moveError('item', 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)
if (dateModalMode === 'batch') {
setSelectedItems(new Set())
setSelectedPaths(new Map())
setSelectMode(false)
}
notificationManager.dateUpdated(`${data.success_count} file${data.success_count !== 1 ? 's' : ''}`)
},
onError: (err: unknown) => {
notificationManager.dateUpdateError(err)
},
})
// All filtering is now server-side
const filteredMedia = mediaData?.media || []
// Server-side sorting is now handled by the backend, no client-side sorting needed
const totalPages = Math.ceil((mediaData?.total || 0) / limit)
const toggleSelect = (id: number) => {
const newSelected = new Set(selectedItems)
const newPaths = new Map(selectedPaths)
if (newSelected.has(id)) {
newSelected.delete(id)
newPaths.delete(id)
} else {
newSelected.add(id)
const item = filteredMedia.find(m => m.id === id)
if (item) newPaths.set(id, item.file_path)
}
setSelectedItems(newSelected)
setSelectedPaths(newPaths)
}
const selectAll = () => {
if (selectedItems.size === filteredMedia.length) {
setSelectedItems(new Set())
setSelectedPaths(new Map())
} else {
setSelectedItems(new Set(filteredMedia.map(m => m.id)))
setSelectedPaths(new Map(filteredMedia.map(m => [m.id, m.file_path])))
}
}
const handleBatchDelete = () => {
if (selectedItems.size === 0) return
const selectedPaths = filteredMedia
.filter(m => selectedItems.has(m.id))
.map(m => m.file_path)
if (confirm(`Delete ${selectedItems.size} selected items?`)) {
batchDeleteMutation.mutate(selectedPaths)
}
}
const handleBatchMove = () => {
if (selectedItems.size === 0) return
setShowMoveModal(true)
}
const confirmBatchMove = () => {
if (!moveDestination.trim()) return
const selectedPaths = filteredMedia
.filter(m => selectedItems.has(m.id))
.map(m => m.file_path)
batchMoveMutation.mutate({ filePaths: selectedPaths, destination: moveDestination })
}
const handleBatchDownload = () => {
if (selectedItems.size === 0) return
const selectedPaths = filteredMedia
.filter(m => selectedItems.has(m.id))
.map(m => m.file_path)
batchDownloadMutation.mutate(selectedPaths)
}
const handleBatchAddFaceReference = () => {
if (selectedItems.size === 0) return
const selectedFiles = filteredMedia
.filter(m => selectedItems.has(m.id))
.map(m => ({ path: m.file_path, filename: m.filename }))
if (confirm(`Add ${selectedItems.size} selected items as face references?`)) {
// Clear selections and show initial toast immediately
const count = selectedFiles.length
setSelectedItems(new Set())
setSelectedPaths(new Map())
setSelectMode(false)
notificationManager.processing(`Processing ${count} file${count !== 1 ? 's' : ''} sequentially`)
// Start processing
batchAddFaceReferenceMutation.mutate({ files: selectedFiles })
}
}
const handleMoveToReview = () => {
if (selectedItems.size === 0) return
const selectedPaths = filteredMedia
.filter(m => selectedItems.has(m.id))
.map(m => m.file_path)
if (confirm(`Move ${selectedItems.size} item${selectedItems.size !== 1 ? 's' : ''} to review queue?`)) {
moveToReviewMutation.mutate(selectedPaths)
}
}
const handleSingleMoveToReview = (filePath: string) => {
if (confirm('Move this item to review queue?')) {
moveToReviewMutation.mutate([filePath])
}
}
// Date editing handlers
const handleBatchChangeDate = () => {
if (selectedItems.size === 0) return
setDateModalMode('batch')
setNewDate('')
setShowDateModal(true)
}
const handleSingleChangeDate = (media: MediaGalleryItem) => {
setDateModalMode('single')
setEditingItemId(media.id)
// Pre-fill with current post_date if available
if (media.post_date) {
// Convert to datetime-local format (YYYY-MM-DDTHH:MM)
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) return
const ids = dateModalMode === 'batch'
? Array.from(selectedItems)
: editingItemId ? [editingItemId] : []
if (ids.length === 0) return
// Convert datetime-local value to ISO string
const isoDate = new Date(newDate).toISOString()
updateDateMutation.mutate({ ids, newDate: isoDate, updateFile: updateFileTimestamps })
}
const handleAddReference = (media: MediaGalleryItem) => {
setActioningFile(media.file_path)
setShowAddModal(true)
}
const handleDelete = (media: MediaGalleryItem) => {
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 openLightbox = (media: MediaGalleryItem) => {
setSelectedMedia(media)
}
// Helper to get current index for lightbox
const getCurrentLightboxIndex = () => {
if (!selectedMedia || !filteredMedia) return -1
return filteredMedia.findIndex((m) => m.id === selectedMedia.id)
}
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">
<GalleryHorizontalEnd className="w-8 h-8 text-purple-500" />
Media Gallery
</h1>
<p className="text-slate-600 dark:text-slate-400 mt-1">
Browse and preview all downloaded media files
</p>
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => {
setSelectMode(!selectMode)
setSelectedItems(new Set())
setSelectedPaths(new Map())
}}
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>
</div>
</div>
{/* Batch Operations Bar */}
{selectMode && (
<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 === filteredMedia.length ? (
<CheckSquare className="w-5 h-5" />
) : (
<Square className="w-5 h-5" />
)}
<span>{selectedItems.size === filteredMedia.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={handleBatchDownload}
disabled={selectedItems.size === 0 || batchDownloadMutation.isPending}
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 as 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-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={handleBatchMove}
disabled={selectedItems.size === 0 || batchMoveMutation.isPending}
className="flex items-center space-x-2 px-4 py-2.5 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"
>
<FolderInput className="w-5 h-5" />
<span className="hidden sm:inline">Move Selected</span>
<span className="sm:hidden">Move</span>
</button>
<button
onClick={handleBatchAddFaceReference}
disabled={selectedItems.size === 0 || batchAddFaceReferenceMutation.isPending}
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"
>
<UserPlus className="w-5 h-5" />
<span className="hidden sm:inline">Add Face References</span>
<span className="sm:hidden">Faces</span>
</button>
<button
onClick={handleMoveToReview}
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={handleBatchChangeDate}
disabled={selectedItems.size === 0 || updateDateMutation.isPending}
className="flex items-center space-x-2 px-4 py-2.5 bg-amber-600 text-white rounded-lg hover:bg-amber-700 disabled:opacity-50 disabled:cursor-not-allowed min-h-[44px] text-sm transition-colors"
>
<CalendarClock className="w-5 h-5" />
<span className="hidden sm:inline">Change Date</span>
<span className="sm:hidden">Date</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>
</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: 'face',
label: 'Face Recognition',
type: 'select',
options: [
{ value: '', label: 'All Files' },
{ value: 'matched', label: 'Matched Only' },
{ value: 'no_match', label: 'No Match Only' },
{ value: 'not_scanned', label: 'Not Scanned' }
],
value: faceRecognitionFilter,
onChange: (v) => { setFaceRecognitionFilter(v as string); setPage(0) }
},
{
id: 'sortBy',
label: 'Sort By',
type: 'select',
options: [
{ value: 'post_date', label: 'Post Date' },
{ value: 'download_date', label: 'Download Date' },
{ value: 'file_size', label: 'File Size' },
{ value: 'filename', label: 'Filename' },
{ value: 'source', label: 'Source' },
{ value: 'platform', label: 'Platform' }
],
value: sortBy,
onChange: (v) => { setSortBy(v as string); setPage(0) }
},
{
id: 'sortOrder',
label: 'Sort Order',
type: 'select',
options: [
{ 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) }
}] : []),
...(faceRecognitionFilter ? [{
id: 'face', label: 'Face', value: faceRecognitionFilter,
displayValue: faceRecognitionFilter === 'matched' ? 'Matched' : faceRecognitionFilter === 'no_match' ? 'No Match' : 'Not Scanned',
onRemove: () => { setFaceRecognitionFilter(''); 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')
setFaceRecognitionFilter('')
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={mediaData?.total}
countLabel="items"
/>
{/* Stats */}
<div className="flex items-center justify-between text-sm text-slate-600 dark:text-slate-400">
<div>
Showing {filteredMedia.length} of {mediaData?.total || 0} items
</div>
<div>
Page {page + 1} of {totalPages || 1}
</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>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{filteredMedia.map((media) => (
<div
key={media.id}
onClick={() => selectMode ? toggleSelect(media.id) : openLightbox(media)}
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(media.id)
? 'ring-2 ring-blue-500'
: 'hover:ring-blue-500'
}`}
>
{/* Thumbnail */}
<ThrottledImage
src={getMediaThumbnailUrl(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 ${
selectedItems.has(media.id)
? 'bg-blue-600 border-blue-600'
: 'bg-white/90 border-slate-300'
}`}>
{selectedItems.has(media.id) && (
<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>
)}
{/* Video indicator */}
{isVideoFile(media) && (
<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>
)}
{/* 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()
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"
>
<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>
</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">{media.filename}</p>
<p className="text-white/70 text-xs">{formatBytes(media.file_size)}</p>
</div>
)}
</div>
))}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-center space-x-2">
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="px-4 py-2.5 border border-slate-300 dark:border-slate-600 rounded-lg disabled:opacity-50 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors min-h-[44px]"
>
Previous
</button>
<span className="px-4 py-2 text-slate-600 dark:text-slate-400">
{page + 1} / {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="px-4 py-2.5 border border-slate-300 dark:border-slate-600 rounded-lg disabled:opacity-50 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors min-h-[44px]"
>
Next
</button>
</div>
)}
{/* Lightbox */}
{selectedMedia && getCurrentLightboxIndex() >= 0 && (
<EnhancedLightbox
items={filteredMedia}
currentIndex={getCurrentLightboxIndex()}
onClose={() => { setSelectedMedia(null); setMediaResolution('') }}
onNavigate={(index) => { setSelectedMedia(filteredMedia[index]); setMediaResolution('') }}
onDelete={handleDelete}
onEditDate={handleSingleChangeDate}
getPreviewUrl={(item) => api.getMediaPreviewUrl(item.file_path)}
getThumbnailUrl={(item: MediaGalleryItem) => getMediaThumbnailUrl(item)}
isVideo={(item) => isVideoFile(item)}
hideFaceRecognition={true}
renderActions={(item) => (
<>
<button
onClick={() => handleSingleMoveToReview(item.file_path)}
disabled={moveToReviewMutation.isPending || actioningFile === item.file_path}
className="px-6 py-3 bg-orange-600 text-white rounded-lg hover:bg-orange-700 disabled:opacity-50 flex items-center space-x-2 transition-colors"
>
<Eye className="w-5 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"
>
<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>
</>
)}
/>
)}
{/* Move Modal */}
{showMoveModal && (
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4">
<div className="bg-card border border-border rounded-xl shadow-2xl max-w-md w-full p-6">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-4">
Move {selectedItems.size} items
</h3>
<div className="mb-4">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
Destination Path
</label>
<input
type="text"
value={moveDestination}
onChange={(e) => setMoveDestination(e.target.value)}
placeholder="/path/to/destination"
className="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground"
autoFocus
/>
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
Enter an absolute path where files should be moved
</p>
</div>
<div className="flex items-center justify-end space-x-3">
<button
onClick={() => {
setShowMoveModal(false)
setMoveDestination('')
}}
className="px-4 py-2.5 border border-slate-300 dark:border-slate-600 rounded-lg text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors min-h-[44px]"
>
Cancel
</button>
<button
onClick={confirmBatchMove}
disabled={!moveDestination.trim() || batchMoveMutation.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 min-h-[44px]"
>
{batchMoveMutation.isPending ? 'Moving...' : 'Move Files'}
</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">
Add as Face Reference
</h3>
<p className="text-sm text-slate-600 dark:text-slate-400 mb-4">
This will add the face in this image as a reference for future matching.
</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="Enter person name"
className="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && addPersonName.trim()) {
confirmAddReference()
}
}}
/>
</div>
<div className="flex justify-end space-x-2 mt-4">
<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 transition-colors min-h-[44px]"
>
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 transition-colors min-h-[44px]"
>
{addReferenceMutation.isPending ? 'Adding...' : 'Add Reference'}
</button>
</div>
</div>
</div>
)}
{/* Progress Modal */}
<BatchProgressModal
isOpen={showProgressModal}
title={progressTitle}
items={progressFiles}
onClose={() => setShowProgressModal(false)}
/>
{/* Date Edit Modal - z-[60] to appear above the lightbox (z-50) */}
{showDateModal && (
<div className="fixed inset-0 bg-black/50 z-[60] 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>
{dateModalMode === 'batch'
? `Change Date for ${selectedItems.size} item${selectedItems.size !== 1 ? 's' : ''}`
: 'Edit Post Date'}
</span>
</h3>
<p className="text-sm text-slate-600 dark:text-slate-400 mb-4">
{dateModalMode === 'batch'
? 'Set a new post date for all selected files. This will update the database and optionally update file timestamps.'
: '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="updateFileTimestamps"
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="updateFileTimestamps" 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={Array.from(selectedPaths.values())}
sourceType="media"
onSuccess={() => {
setShowCopyToGalleryModal(false)
setSelectedItems(new Set())
setSelectedPaths(new Map())
setSelectMode(false)
}}
/>
</div>
)
}