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 } } 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([]) const [loading, setLoading] = useState(true) const [loadingMore, setLoadingMore] = useState(false) const [error, setError] = useState(null) const [hasMore, setHasMore] = useState(true) // Filters const [filterLocation, setFilterLocation] = useState('media') const [filterPlatform, setFilterPlatform] = useState('') const [filterSource, setFilterSource] = useState('') const [searchQuery, setSearchQuery] = useState('') const [platforms, setPlatforms] = useState([]) const [sources, setSources] = useState([]) // Advanced filters const [dateFrom, setDateFrom] = useState('') const [dateTo, setDateTo] = useState('') const [sizeMin, setSizeMin] = useState('') const [sizeMax, setSizeMax] = useState('') // Lightbox const [lightboxItems, setLightboxItems] = useState([]) const [lightboxIndex, setLightboxIndex] = useState(0) // Batch selection const [selectMode, setSelectMode] = useState(false) const [selectedItems, setSelectedItems] = useState>(new Set()) // Actions const [actioningFile, setActioningFile] = useState(null) const [showAddModal, setShowAddModal] = useState(false) const [addPersonName, setAddPersonName] = useState('') // Date edit modal state const [showDateModal, setShowDateModal] = useState(false) 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) // Infinite scroll ref const loadMoreRef = useRef(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) => { 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 (
{/* Header */}

Downloads

Browse and manage your downloaded media

{filterLocation !== 'all' && ( )}
{/* Filters */} { 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' && (
{selectedItems.size} item{selectedItems.size !== 1 ? 's' : ''} selected
{/* Media location actions */} {filterLocation === 'media' && ( <> {isFeatureEnabled('/private-gallery') && ( )} )} {/* Review location actions */} {filterLocation === 'review' && ( <> )} {/* Recycle location actions */} {filterLocation === 'recycle' && ( <> )}
)} {/* Stats */}
{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' : ''}` }
{getLocationLabel(filterLocation)}
{/* Error */} {error && (

{error}

)} {/* Days List */}
{/* Loading Skeleton */} {loading && filteredDays.length === 0 ? (
{[...Array(20)].map((_, i) => (
))}
) : filteredDays.length === 0 ? (

{searchQuery ? `No matches for "${searchQuery}"` : `No items found in ${getLocationLabel(filterLocation)}`}

) : ( filteredDays.map((day) => (
{/* Day Header */}

{formatDayHeader(day.date)}

{day.count} item{day.count !== 1 ? 's' : ''}
{/* Per-Day Summary */} {day.summary && (
{/* 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 && ( {day.summary.by_location.media} media )} {day.summary.by_location.review > 0 && ( {day.summary.by_location.review} review )} {day.summary.by_location.recycle > 0 && ( {day.summary.by_location.recycle} recycle )} | )} {/* Platform breakdown */} {Object.entries(day.summary.by_platform).slice(0, 5).map(([platform, count]) => ( {formatPlatformName(platform)}: {count} ))} {Object.keys(day.summary.by_platform).length > 5 && ( +{Object.keys(day.summary.by_platform).length - 5} more )}
)} {/* Thumbnail Grid */}
{day.items.map((media, idx) => { const itemLoc = getItemLocation(media) const mediaId = String(media.id) const isSelected = selectedItems.has(mediaId) return (
selectMode ? toggleSelect(mediaId) : openLightbox(day.items, idx)} > {/* Select Checkbox */} {selectMode && (
{isSelected && ( )}
)} {/* Status Badge */} {!selectMode && itemLoc === 'review' && (
In Review
)} {!selectMode && itemLoc === 'recycle' && (
Recycle Bin
)} {/* Video indicator */} {media.media_type === 'video' && (
)} {/* Action buttons overlay */} {!selectMode && (
{itemLoc === 'media' && ( <> )} {itemLoc === 'review' && ( <> )} {itemLoc === 'recycle' && ( <> )}
)} {/* File info overlay */}

{media.filename}

)})} {/* Close the return and map */}
)) )} {/* Load More / Loading Indicator */}
{loadingMore && (
Loading more...
)} {!loadingMore && hasMore && days.length > 0 && ( )} {!hasMore && days.length > 0 && (

No more items to load

)}
{/* Enhanced Lightbox */} {lightboxItems.length > 0 && ( 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 ( <> ) } if (itemLoc === 'review') { return ( <>
In Review Queue
) } if (itemLoc === 'recycle') { return ( <>
In Recycle Bin
) } return null }} /> )} {/* Add Reference Modal */} {showAddModal && (

Add Face Reference

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 />
)} {/* Date Edit Modal */} {showDateModal && (

Edit Post Date

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={getSelectedMedia().map(m => m.file_path)} sourceType="downloads" onSuccess={() => { setShowCopyToGalleryModal(false) setSelectedItems(new Set()) setSelectMode(false) }} />
) }