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