Files
mule-image/frontend/src/components/upload/UploadModal.tsx
dtoro a073ee7fb9 perf+style: grid subscription hygiene, a11y, shadcn-style consistency
Perf / a11y (high-impact review items)
- Timeline arrow-key handler binds once per (viewMode, currentSection)
  and reads fresh state via navStateRef instead of an 8-element dep
  array of new-each-render values.
- usePhotosQuery collapses 14 individual Zustand selectors into one
  useShallow selector returning the params object.
- PhotoThumbnail no longer subscribes to the search query directly;
  Timeline subscribes once and passes it down as a prop.
- PhotoThumbnail gains role="button", tabIndex, aria-label, aria-pressed,
  Enter/Space key handlers and a focus-visible ring. Timeline marked
  role="grid"; RightSidebar marked role="region".

Style consistency
- Swap clsx for cn (tailwind-merge aware) across 17 files so
  conflicting utility classes collapse correctly.
- New Badge primitive (ui/badge.tsx) with default/neutral/overlay/
  outline variants; adopted in ColorsView, RatedView, TagsView for
  the repeated count overlay pill.
- Fix palette drift: text-amber-400 -> text-star, text-green-*
  -> text-pick, text-red-* -> text-reject (5 files).
- Button gains an xs size (h-6 px-1.5 text-[11px]) for the repeated
  compact-button pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 10:30:16 +02:00

596 lines
22 KiB
TypeScript

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<QueuedFile[]>([])
const [destFolderId, setDestFolderId] = useState<string | null>(null)
const [destHeapId, setDestHeapId] = useState<string | null>(null)
const [isUploading, setIsUploading] = useState(false)
const [dragOver, setDragOver] = useState(false)
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set())
const fileInputRef = useRef<HTMLInputElement>(null)
const dirInputRef = useRef<HTMLInputElement>(null)
const abortRef = useRef<AbortController | null>(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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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<void> => {
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<File, string>(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<void>[] = []
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 (
<Dialog
open={isOpen}
onOpenChange={(o) => {
if (!o && !isUploading) onClose()
}}
>
<DialogContent className="flex max-h-[85vh] w-[760px] max-w-[760px] flex-col p-0">
<DialogHeader className="border-b border-border px-5 py-3">
<DialogTitle className="flex items-center gap-2">
<UploadIcon className="h-4 w-4 text-text-muted" />
Upload photos
</DialogTitle>
</DialogHeader>
{/* Body */}
<div className="flex min-h-0 flex-1 gap-4 overflow-hidden p-5">
{/* Left: destination picker */}
<div className="flex w-64 flex-col gap-4 overflow-hidden">
<div className="flex flex-col gap-2 overflow-hidden">
<label className="text-xs font-medium uppercase tracking-wide text-text-muted">
Destination folder
</label>
<div className="flex-1 overflow-auto rounded border border-border bg-bg p-1 text-sm">
{folderTree && folderTree.length > 0 ? (
folderTree.map((n) => (
<FolderTreeRow
key={n.id}
node={n}
depth={0}
selectedId={destFolderId}
onSelect={setDestFolderId}
expanded={expandedFolders}
onToggle={(id) =>
setExpandedFolders((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
/>
))
) : (
<div className="p-3 text-xs text-text-muted">No folders yet.</div>
)}
</div>
</div>
<div className="flex flex-col gap-2">
<label className="text-xs font-medium uppercase tracking-wide text-text-muted">
Also add to heap (optional)
</label>
<select
value={destHeapId ?? ''}
onChange={(e) => setDestHeapId(e.target.value || null)}
className="rounded border border-border bg-bg px-2 py-1.5 text-sm text-text"
>
<option value=""> none </option>
{allHeaps.map((h) => (
<option key={h.id} value={h.id}>
{h.name}
</option>
))}
</select>
</div>
</div>
{/* Right: drop zone + queue */}
<div className="flex min-w-0 flex-1 flex-col gap-3 overflow-hidden">
<div
onDragOver={(e) => {
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'
)}
>
<UploadIcon className="mb-2 h-6 w-6 text-text-muted" />
<div className="text-sm text-text">
Drop files or folders here
</div>
<div className="mt-1 text-xs text-text-muted">
Folder structure is preserved under the destination.
</div>
<div className="mt-3 flex gap-2">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="rounded border border-border px-3 py-1 text-xs text-text hover:bg-surface-2"
>
Select files
</button>
<button
type="button"
onClick={() => dirInputRef.current?.click()}
className="rounded border border-border px-3 py-1 text-xs text-text hover:bg-surface-2"
>
Select folder
</button>
</div>
<input
ref={fileInputRef}
type="file"
multiple
accept="image/*,video/*,.heic,.heif,.cr2,.cr3,.nef,.arw,.raf,.dng,.orf,.rw2,.pef,.srw"
className="hidden"
onChange={handleFilePick}
/>
<input
ref={dirInputRef}
type="file"
multiple
// @ts-expect-error — non-standard but supported in Chromium/WebKit
webkitdirectory=""
directory=""
className="hidden"
onChange={handleDirPick}
/>
</div>
{/* Queue */}
<div className="min-h-0 flex-1 overflow-auto rounded border border-border bg-bg">
{queue.length === 0 ? (
<div className="flex h-full items-center justify-center text-xs text-text-muted">
No files added yet.
</div>
) : (
<ul className="divide-y divide-border">
{queue.map((item) => (
<li key={item.key} className="flex items-center gap-2 px-3 py-2 text-sm">
<FileImage className="h-4 w-4 shrink-0 text-text-muted" />
<div className="min-w-0 flex-1">
<div className="truncate text-text">
{item.relativePath || item.file.name}
</div>
{item.status === 'uploading' && (
<div className="mt-1 h-1 w-full overflow-hidden rounded bg-surface-2">
<div
className="h-full bg-primary transition-all"
style={{ width: `${Math.round(item.progress * 100)}%` }}
/>
</div>
)}
{item.status === 'error' && item.error && (
<div className="mt-0.5 truncate text-xs text-reject">{item.error}</div>
)}
</div>
<div className="shrink-0">
{item.status === 'done' && <CheckCircle2 className="h-4 w-4 text-pick" />}
{item.status === 'error' && <AlertCircle className="h-4 w-4 text-reject" />}
{item.status !== 'done' && !isUploading && (
<button
onClick={() => removeFromQueue(item.key)}
className="rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
aria-label="Remove"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
</li>
))}
</ul>
)}
</div>
{queue.length > 0 && (
<div className="text-xs text-text-muted">
{queue.length} file{queue.length === 1 ? '' : 's'} {' '}
{(totalBytes / 1024 / 1024).toFixed(1)} MB
{isUploading && `${overallPct}% uploaded`}
</div>
)}
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 border-t border-border px-5 py-3">
<button
onClick={isUploading ? cancelUpload : onClose}
className="rounded border border-border px-3 py-1.5 text-sm text-text hover:bg-surface-2"
>
{isUploading ? 'Cancel' : 'Close'}
</button>
<button
onClick={startUpload}
disabled={isUploading || queue.length === 0 || !destFolderId}
className="flex items-center gap-1.5 rounded bg-primary px-3 py-1.5 text-sm font-medium text-white hover:bg-primary/80 disabled:cursor-not-allowed disabled:opacity-50"
>
<UploadIcon className="h-3.5 w-3.5" />
{isUploading ? 'Uploading…' : `Upload ${queue.length || ''}`.trim()}
</button>
</div>
</DialogContent>
</Dialog>
)
}
interface FolderTreeRowProps {
node: FolderTreeNode
depth: number
selectedId: string | null
onSelect: (id: string) => void
expanded: Set<string>
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 (
<>
<div
className={cn(
'flex cursor-pointer items-center gap-1 rounded px-1 py-1 text-sm',
isSelected ? 'bg-primary/20 text-text' : 'text-text-muted hover:bg-surface-2 hover:text-text'
)}
style={{ paddingLeft: `${depth * 12 + 4}px` }}
onClick={() => onSelect(node.id)}
>
<button
onClick={(e) => {
e.stopPropagation()
if (hasChildren) onToggle(node.id)
}}
className="flex h-4 w-4 items-center justify-center"
aria-label={isExpanded ? 'Collapse' : 'Expand'}
>
{hasChildren ? (
isExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)
) : null}
</button>
<FolderIcon className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{node.name}</span>
</div>
{isExpanded &&
node.children?.map((c) => (
<FolderTreeRow
key={c.id}
node={c}
depth={depth + 1}
selectedId={selectedId}
onSelect={onSelect}
expanded={expanded}
onToggle={onToggle}
/>
))}
</>
)
}