feat: runtime feature flags, upload/download, RAW decoding
Adds Redis-backed feature flags for vision stages with admin UI toggles and manual backfill trigger, photo upload and download routers with frontend upload modal, and rawpy-based RAW decoding with JPEG fallback for misnamed DNGs. Fixes pgvector serialization, is_trashed filter, and naive-datetime bind in incremental duplicate regrouping; bumps Celery time limits on regroup tasks beyond the 5-minute default. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
608
frontend/src/components/upload/UploadModal.tsx
Normal file
608
frontend/src/components/upload/UploadModal.tsx
Normal file
@@ -0,0 +1,608 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Folder as FolderIcon,
|
||||
Upload as UploadIcon,
|
||||
X,
|
||||
FileImage,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
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'
|
||||
|
||||
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 closes (unless mid-upload — don't orphan in-flight requests).
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && !isUploading) onClose()
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [isOpen, isUploading, onClose])
|
||||
|
||||
// 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
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={!isUploading ? onClose : undefined}
|
||||
/>
|
||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
<div className="relative z-10 flex max-h-[85vh] w-[760px] flex-col rounded-lg border border-border bg-surface shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<UploadIcon className="h-4 w-4 text-text-muted" />
|
||||
<h2 className="text-base font-semibold text-text">Upload photos</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isUploading}
|
||||
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text disabled:opacity-40"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 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={clsx(
|
||||
'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-green-500" />}
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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={clsx(
|
||||
'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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user