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