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(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('') const [platformFilter, setPlatformFilter] = useState('') const [sourceFilter, setSourceFilter] = useState('') const [typeFilter, setTypeFilter] = useState<'all' | 'image' | 'video'>('all') const [faceRecognitionFilter, setFaceRecognitionFilter] = useState('') const [searchQuery, setSearchQuery] = useState('') // Advanced filters const [dateFrom, setDateFrom] = useState('') const [dateTo, setDateTo] = useState('') const [sizeMin, setSizeMin] = useState('') const [sizeMax, setSizeMax] = useState('') const [sortBy, setSortBy] = useState('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>(new Set()) const [selectedPaths, setSelectedPaths] = useState>(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(null) const [showProgressModal, setShowProgressModal] = useState(false) const [progressFiles, setProgressFiles] = useState([]) 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(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 (
{/* Header */}

Media Gallery

Browse and preview all downloaded media files

{/* Batch Operations Bar */} {selectMode && (
{selectedItems.size} item{selectedItems.size !== 1 ? 's' : ''} selected
{isFeatureEnabled('/private-gallery') && ( )}
)} {/* Filters */} { setSearchQuery(v); setPage(0) }} searchPlaceholder="Search filenames..." filterSections={[ { id: 'platform', label: 'Platform', type: 'select', options: [{ value: '', label: 'All Platforms' }, ...(filters?.platforms || []).map(p => ({ value: p, label: formatPlatformName(p) }))], value: platformFilter, onChange: (v) => { setPlatformFilter(v as string); setPage(0) } }, { id: 'source', label: 'Source', type: 'select', options: [{ value: '', label: 'All Sources' }, ...(filters?.sources || []).map(s => ({ value: s, label: s }))], value: sourceFilter, onChange: (v) => { setSourceFilter(v as string); setPage(0) } }, { id: 'type', label: 'Media Type', type: 'select', options: [ { value: 'all', label: 'All Media' }, { value: 'image', label: 'Images Only' }, { value: 'video', label: 'Videos Only' } ], value: typeFilter, onChange: (v) => { setTypeFilter(v as 'all' | 'image' | 'video'); setPage(0) } }, { id: '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 */}
Showing {filteredMedia.length} of {mediaData?.total || 0} items
Page {page + 1} of {totalPages || 1}
{/* Gallery Grid */} {isLoading ? (
{[...Array(20)].map((_, i) => (
))}
) : (
{filteredMedia.map((media) => (
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 */} {/* Select Checkbox */} {selectMode && (
{selectedItems.has(media.id) && ( )}
)} {/* Video indicator */} {isVideoFile(media) && (
)} {/* Action buttons overlay */} {!selectMode && (
)} {/* File info */} {!selectMode && (

{media.filename}

{formatBytes(media.file_size)}

)}
))}
)} {/* Pagination */} {totalPages > 1 && (
{page + 1} / {totalPages}
)} {/* Lightbox */} {selectedMedia && getCurrentLightboxIndex() >= 0 && ( { 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) => ( <> )} /> )} {/* Move Modal */} {showMoveModal && (

Move {selectedItems.size} items

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 />

Enter an absolute path where files should be moved

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

Add as Face Reference

This will add the face in this image as a reference for future matching.

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() } }} />
)} {/* Progress Modal */} setShowProgressModal(false)} /> {/* Date Edit Modal - z-[60] to appear above the lightbox (z-50) */} {showDateModal && (

{dateModalMode === 'batch' ? `Change Date for ${selectedItems.size} item${selectedItems.size !== 1 ? 's' : ''}` : 'Edit Post Date'}

{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.'}

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