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>
596 lines
22 KiB
TypeScript
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}
|
|
/>
|
|
))}
|
|
</>
|
|
)
|
|
}
|