import { useEffect, useMemo, useRef, useState } from 'react' import { ChevronDown, ChevronRight, Folder as FolderIcon, Upload as UploadIcon, X, FileImage, CheckCircle2, AlertCircle, } from 'lucide-react' import { cn } from '@/lib/utils' import { useQueryClient } from '@tanstack/react-query' import { uploads, type FolderTreeNode } from '../../services/api' import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery' import { useHeapsQuery, HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery' import { FOLDER_TREE_QUERY_KEY } from '../../hooks/useFolderTreeQuery' import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery' import { toast } from '../ToastContainer' import { Dialog, DialogContent, DialogHeader, DialogTitle, } from '@/components/ui/dialog' interface UploadModalProps { isOpen: boolean onClose: () => void /** Optional pre-selected destination. Accepts a Folder id or SourceRoot * id — the backend resolves source roots to their root Folder row. */ initialFolderId?: string | null } interface QueuedFile { /** Stable key; the browser may give us multiple files with the same * name from different subfolders, so we key on index + path. */ key: string file: File relativePath: string status: 'pending' | 'uploading' | 'done' | 'error' progress: number error?: string } const SUPPORTED_EXTENSIONS = new Set([ '.jpg', '.jpeg', '.png', '.tiff', '.tif', '.webp', '.bmp', '.cr2', '.cr3', '.nef', '.arw', '.raf', '.dng', '.orf', '.rw2', '.pef', '.srw', '.heic', '.heif', '.mp4', '.mov', '.avi', '.mkv', '.mts', '.m2ts', '.3gp', '.wmv', '.flv', ]) function extOf(name: string): string { const i = name.lastIndexOf('.') return i === -1 ? '' : name.slice(i).toLowerCase() } function isSupported(name: string): boolean { return SUPPORTED_EXTENSIONS.has(extOf(name)) } const MAX_PARALLEL = 4 /** Walk the tree to collect the ids on the path from root to `targetId`, * excluding the target itself — used to expand ancestor rows so a * pre-selected destination is visible. */ function collectAncestors(tree: FolderTreeNode[], targetId: string): string[] { const path: string[] = [] const walk = (nodes: FolderTreeNode[], chain: string[]): boolean => { for (const n of nodes) { if (n.id === targetId) { path.push(...chain) return true } if (n.children && walk(n.children, [...chain, n.id])) return true } return false } walk(tree, []) return path } /** * Upload from desktop. Supports: * - Dropping files or folders onto the drop zone * - Picking files with "Select files" * - Picking a whole folder with "Select folder" (webkitdirectory); * every file's webkitRelativePath is sent to the backend so sub- * folder structure is preserved under the chosen destination. * * Destination is a Folder row (or a source root, which the backend * resolves to its root folder). An optional heap can also be chosen — * uploaded photos are added to that heap in the same request. */ export function UploadModal({ isOpen, onClose, initialFolderId }: UploadModalProps) { const queryClient = useQueryClient() const { data: folderTree } = useFolderTreeQuery() const { data: allHeaps = [] } = useHeapsQuery() const [queue, setQueue] = useState([]) const [destFolderId, setDestFolderId] = useState(null) const [destHeapId, setDestHeapId] = useState(null) const [isUploading, setIsUploading] = useState(false) const [dragOver, setDragOver] = useState(false) const [expandedFolders, setExpandedFolders] = useState>(new Set()) const fileInputRef = useRef(null) const dirInputRef = useRef(null) const abortRef = useRef(null) // Default destination: caller-provided initialFolderId wins; otherwise // first source root in the tree. Re-runs when the modal is re-opened // with a different initial target so the right row is highlighted. useEffect(() => { if (!isOpen) return if (initialFolderId) { setDestFolderId(initialFolderId) // Expand every ancestor of the pre-selected folder so the row is // actually visible in the tree. if (folderTree) { const ancestors = collectAncestors(folderTree, initialFolderId) setExpandedFolders((prev) => new Set([...prev, ...ancestors])) } return } if (!destFolderId && folderTree && folderTree.length > 0) { setDestFolderId(folderTree[0].id) setExpandedFolders(new Set([folderTree[0].id])) } }, [isOpen, initialFolderId, folderTree, destFolderId]) // Esc / overlay-click dismissal lives on the Dialog primitive below. // We only need to guard against closing while an upload is in flight. // Reset transient state on open so a previous session's queue doesn't // bleed into a fresh one. useEffect(() => { if (isOpen) { setQueue([]) setIsUploading(false) } }, [isOpen]) const addFiles = (incoming: File[], relPathFn?: (f: File) => string) => { const next: QueuedFile[] = [] let skipped = 0 for (const file of incoming) { const relPath = (relPathFn?.(file) ?? '').replace(/\\/g, '/').replace(/^\/+/, '') const filename = relPath || file.name if (!isSupported(filename)) { skipped++ continue } next.push({ key: `${relPath || file.name}::${file.size}::${file.lastModified}::${next.length}`, file, relativePath: relPath, status: 'pending', progress: 0, }) } if (skipped > 0) { toast.info?.(`Skipped ${skipped} unsupported file${skipped === 1 ? '' : 's'}`) } setQueue((prev) => [...prev, ...next]) } const handleFilePick = (e: React.ChangeEvent) => { const files = Array.from(e.target.files ?? []) addFiles(files, (f) => f.name) // no relative path for single files e.target.value = '' // allow re-picking the same file } const handleDirPick = (e: React.ChangeEvent) => { const files = Array.from(e.target.files ?? []) addFiles(files, (f) => (f as File & { webkitRelativePath?: string }).webkitRelativePath || f.name) e.target.value = '' } // Drag-and-drop handler. We walk the DataTransferItemList recursively // with webkitGetAsEntry so dropped folders contribute every nested // file, with relative paths reconstructed from the entry chain. const handleDrop = async (e: React.DragEvent) => { e.preventDefault() setDragOver(false) const items = Array.from(e.dataTransfer.items) const collected: { file: File; relativePath: string }[] = [] const walkEntry = (entry: any, pathPrefix: string): Promise => { return new Promise((resolve) => { if (!entry) return resolve() if (entry.isFile) { entry.file((f: File) => { collected.push({ file: f, relativePath: pathPrefix ? `${pathPrefix}/${entry.name}` : '', }) resolve() }, () => resolve()) } else if (entry.isDirectory) { const reader = entry.createReader() const readBatch = () => { reader.readEntries(async (entries: any[]) => { if (!entries.length) return resolve() const childPrefix = pathPrefix ? `${pathPrefix}/${entry.name}` : entry.name await Promise.all(entries.map((c) => walkEntry(c, childPrefix))) // readEntries only returns a batch at a time; loop until empty. readBatch() }, () => resolve()) } readBatch() } else { resolve() } }) } await Promise.all( items.map((it) => { const entry = (it as DataTransferItem & { webkitGetAsEntry?: () => any }).webkitGetAsEntry?.() return walkEntry(entry, '') }) ) if (collected.length === 0) { // Fallback for browsers without webkitGetAsEntry — use plain files. const files = Array.from(e.dataTransfer.files) addFiles(files, (f) => f.name) return } const files = collected.map((c) => c.file) const pathMap = new Map(collected.map((c) => [c.file, c.relativePath])) addFiles(files, (f) => pathMap.get(f) || f.name) } const removeFromQueue = (key: string) => { setQueue((prev) => prev.filter((q) => q.key !== key)) } const startUpload = async () => { if (!destFolderId || queue.length === 0) return setIsUploading(true) const ctrl = new AbortController() abortRef.current = ctrl // Simple worker-pool: up to MAX_PARALLEL concurrent uploads. const pending = queue.filter((q) => q.status === 'pending' || q.status === 'error') let cursor = 0 let successCount = 0 let failCount = 0 const uploadOne = async (item: QueuedFile) => { setQueue((prev) => prev.map((q) => (q.key === item.key ? { ...q, status: 'uploading', progress: 0, error: undefined } : q)) ) try { await uploads.uploadFile(item.file, destFolderId, { relativePath: item.relativePath || undefined, heapId: destHeapId, signal: ctrl.signal, onProgress: (loaded, total) => { const pct = total > 0 ? loaded / total : 0 setQueue((prev) => prev.map((q) => (q.key === item.key ? { ...q, progress: pct } : q)) ) }, }) successCount++ setQueue((prev) => prev.map((q) => (q.key === item.key ? { ...q, status: 'done', progress: 1 } : q)) ) } catch (err: any) { failCount++ const msg = err?.response?.data?.detail || err?.message || 'Upload failed' setQueue((prev) => prev.map((q) => (q.key === item.key ? { ...q, status: 'error', error: msg } : q)) ) } } const workers: Promise[] = [] for (let i = 0; i < Math.min(MAX_PARALLEL, pending.length); i++) { workers.push( (async () => { while (cursor < pending.length && !ctrl.signal.aborted) { const idx = cursor++ await uploadOne(pending[idx]) } })() ) } await Promise.all(workers) setIsUploading(false) abortRef.current = null // Refresh everything affected by new photos. queryClient.invalidateQueries({ queryKey: FOLDER_TREE_QUERY_KEY }) queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY }) queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY }) queryClient.invalidateQueries({ queryKey: ['photos'] }) if (successCount > 0) { toast.success( `Uploaded ${successCount} file${successCount === 1 ? '' : 's'}`, failCount > 0 ? `${failCount} failed — see list` : undefined ) } if (successCount === 0 && failCount > 0) { toast.error('Upload failed', `${failCount} file${failCount === 1 ? '' : 's'} errored`) } } const cancelUpload = () => { abortRef.current?.abort() } const totalBytes = useMemo(() => queue.reduce((s, q) => s + q.file.size, 0), [queue]) const uploadedBytes = useMemo( () => queue.reduce((s, q) => s + q.file.size * (q.status === 'done' ? 1 : q.progress), 0), [queue] ) const overallPct = totalBytes > 0 ? Math.round((uploadedBytes / totalBytes) * 100) : 0 return ( { if (!o && !isUploading) onClose() }} > Upload photos {/* Body */}
{/* Left: destination picker */}
{folderTree && folderTree.length > 0 ? ( folderTree.map((n) => ( setExpandedFolders((prev) => { const next = new Set(prev) if (next.has(id)) next.delete(id) else next.add(id) return next }) } /> )) ) : (
No folders yet.
)}
{/* Right: drop zone + queue */}
{ e.preventDefault() setDragOver(true) }} onDragLeave={() => setDragOver(false)} onDrop={handleDrop} className={cn( 'flex flex-col items-center justify-center rounded border-2 border-dashed px-4 py-6 text-center transition-colors', dragOver ? 'border-primary bg-primary/10' : 'border-border bg-bg' )} >
Drop files or folders here
Folder structure is preserved under the destination.
{/* Queue */}
{queue.length === 0 ? (
No files added yet.
) : (
    {queue.map((item) => (
  • {item.relativePath || item.file.name}
    {item.status === 'uploading' && (
    )} {item.status === 'error' && item.error && (
    {item.error}
    )}
    {item.status === 'done' && } {item.status === 'error' && } {item.status !== 'done' && !isUploading && ( )}
  • ))}
)}
{queue.length > 0 && (
{queue.length} file{queue.length === 1 ? '' : 's'} •{' '} {(totalBytes / 1024 / 1024).toFixed(1)} MB {isUploading && ` • ${overallPct}% uploaded`}
)}
{/* Footer */}
) } interface FolderTreeRowProps { node: FolderTreeNode depth: number selectedId: string | null onSelect: (id: string) => void expanded: Set onToggle: (id: string) => void } function FolderTreeRow({ node, depth, selectedId, onSelect, expanded, onToggle, }: FolderTreeRowProps) { const isExpanded = expanded.has(node.id) const hasChildren = node.children && node.children.length > 0 const isSelected = selectedId === node.id return ( <>
onSelect(node.id)} > {node.name}
{isExpanded && node.children?.map((c) => ( ))} ) }