Files
mule-image/frontend/src/components/layout/LeftSidebar.tsx
dtoro 1c6383e5a0 feat: colored count badge on Colors sidebar entry
Adds a `colored` field to /library/stats counting non-discarded
photos with a color_label set, mirroring how `rated` is exposed.
The Colors sidebar entry now shows the live count and refreshes
through the existing LIBRARY_STATS_QUERY_KEY invalidations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:43:19 +02:00

767 lines
28 KiB
TypeScript

import { useEffect, useRef, useState } from 'react'
import {
ChevronRight,
ChevronDown,
Folder,
FolderPlus,
Image,
Star,
Trash2,
HardDrive,
Copy,
Tag as TagIcon,
Palette,
Layers2,
MoreHorizontal,
Pencil,
PanelLeftClose,
Settings,
} from 'lucide-react'
import clsx from 'clsx'
import { sourceFolders, photos as photosApi, type FolderTreeNode } from '../../services/api'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from '../ToastContainer'
import { useFilterStore } from '../../store/filterStore'
import { HeapsPanel } from '../heaps/HeapsPanel'
import { ActiveHeapCard } from '../heaps/ActiveHeapCard'
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery'
import { useTagsQuery } from '../../hooks/useTagsQuery'
import {
useLibraryStatsQuery,
LIBRARY_STATS_QUERY_KEY,
} from '../../hooks/useLibraryStatsQuery'
import { registerUndoable } from '../../store/undoStore'
import type { Photo } from '../../types/photo'
import { DeleteFolderDialog } from '../dialogs/DeleteFolderDialog'
interface TreeItem {
id: string
label: string
icon?: React.ReactNode
count?: number
children?: TreeItem[]
type?: 'folder' | 'heap' | 'special'
}
interface LeftSidebarProps {
onCollapse: () => void
onOpenSettings: () => void
}
export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) {
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['library', 'folders', 'heaps']))
// Inline rename state for source-root rows. Stores the id being edited
// and the draft name. Double-click a folder row to start.
const [renamingId, setRenamingId] = useState<string | null>(null)
const [renameDraft, setRenameDraft] = useState('')
const queryClient = useQueryClient()
const navigateToSection = useFilterStore((s) => s.navigateToSection)
const currentSection = useFilterStore((s) => s.currentSection)
const { data: allTags = [] } = useTagsQuery()
const { data: stats } = useLibraryStatsQuery()
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
// Per-folder kebab menu open state. Stores the tree-item id ("folder-..."
// or "folders" for the section header). Outside-click + Escape close.
const [openMenuId, setOpenMenuId] = useState<string | null>(null)
const menuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!openMenuId) return
const onDown = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpenMenuId(null)
}
}
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpenMenuId(null)
}
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onDown)
document.removeEventListener('keydown', onKey)
}
}, [openMenuId])
// "Create new folder under {parent}" inline state. parentId is the
// Folder.id (no "folder-" prefix).
const [creatingUnder, setCreatingUnder] = useState<string | null>(null)
const [createDraft, setCreateDraft] = useState('')
// Folder being deleted, drives the DeleteFolderDialog mounted below.
const [deletingFolder, setDeletingFolder] = useState<{
id: string
name: string
photoCount?: number
} | null>(null)
// Bulk discard mutation for the drag-onto-Discarded interaction.
const discardDropMutation = useMutation({
mutationFn: (photoIds: string[]) => photosApi.bulkDiscard(photoIds),
onSuccess: (_data, photoIds) => {
registerUndoable(
`Discarded ${photoIds.length} photo${photoIds.length === 1 ? '' : 's'}`,
async () => {
await photosApi.bulkRestore(photoIds)
queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
}
)
queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
},
onError: (e: any) =>
toast.error('Discard failed', e?.message || 'Unknown error'),
})
// Bulk move mutation for the drag-onto-folder interaction. The mutation
// captures each photo's source folder before issuing the move so the
// undo path can put them back exactly where they came from (different
// sources end up in different undo subgroups).
const moveDropMutation = useMutation({
mutationFn: async ({
targetId,
photoIds,
}: {
targetId: string
photoIds: string[]
}) => {
// Snapshot per-photo source folder ids from the photos cache. We
// walk every cached ['photos', ...] entry because the user could
// be in any section / filter combination, and we don't know the
// exact key offhand.
const sourceMap = new Map<string, string>()
const photoCaches = queryClient.getQueriesData<Photo[]>({ queryKey: ['photos'] })
for (const [, list] of photoCaches) {
if (!list) continue
for (const p of list) {
if (photoIds.includes(p.id) && p.folder_id && !sourceMap.has(p.id)) {
sourceMap.set(p.id, p.folder_id)
}
}
}
const result = await photosApi.move(photoIds, targetId)
return { result, sourceMap }
},
onSuccess: ({ result, sourceMap }) => {
const moved = result?.moved ?? 0
const errCount = result?.errors?.length ?? 0
if (moved > 0) {
// Group photos by their source folder so we can issue one move
// call per group when undoing. Photos whose source folder we
// couldn't recover get dropped from the undo (they'll just stay
// where the move put them).
const groups = new Map<string, string[]>()
for (const [photoId, src] of sourceMap.entries()) {
const arr = groups.get(src) ?? []
arr.push(photoId)
groups.set(src, arr)
}
if (groups.size > 0) {
registerUndoable(
`Moved ${moved} photo${moved === 1 ? '' : 's'}`,
async () => {
for (const [src, ids] of groups.entries()) {
await photosApi.move(ids, src)
}
queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: ['folders'] })
}
)
} else {
toast.success(
'Moved',
`${moved} photo${moved > 1 ? 's' : ''}${errCount ? ` (${errCount} skipped)` : ''}`
)
}
} else if (errCount > 0) {
toast.error(
'Move failed',
`${errCount} file${errCount > 1 ? 's' : ''} could not be moved`
)
}
queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: ['folders'] })
},
onError: (e: any) =>
toast.error('Move failed', e?.response?.data?.detail || e.message || 'Unknown error'),
})
// Bulk copy mutation — Alt-drag uses this instead of move.
const copyDropMutation = useMutation({
mutationFn: ({ targetId, photoIds }: { targetId: string; photoIds: string[] }) =>
photosApi.copy(photoIds, targetId),
onSuccess: (data) => {
const copied = data?.copied ?? 0
const errCount = data?.errors?.length ?? 0
if (copied > 0) {
toast.success(
'Copied',
`${copied} photo${copied > 1 ? 's' : ''}${errCount ? ` (${errCount} skipped)` : ''}`
)
} else if (errCount > 0) {
toast.error('Copy failed', `${errCount} file${errCount > 1 ? 's' : ''} could not be copied`)
}
queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: ['folders'] })
},
onError: (e: any) =>
toast.error('Copy failed', e?.response?.data?.detail || e.message || 'Unknown error'),
})
// Reads the dragged ids out of a drop event payload.
const readDragIds = (e: React.DragEvent): string[] | null => {
const raw = e.dataTransfer.getData(PHOTO_DRAG_MIME)
if (!raw) return null
try {
const parsed = JSON.parse(raw) as string[]
return Array.isArray(parsed) && parsed.length > 0 ? parsed : null
} catch {
return null
}
}
// Map a library tree id to a section navigation. Each "virtual node" in
// the library tree is its own section, with its own remembered filter
// state. The preset is the section's intrinsic filter (the thing that
// makes it that section); user-added filters from the FilterBar layer
// on top and are saved when the user navigates away.
const applyLibraryNode = (id: string) => {
switch (id) {
case 'all-photos':
navigateToSection('all-photos', {})
break
case 'rated':
navigateToSection('rated', { ratingMin: 1, groupBy: 'rating' })
break
case 'discarded':
navigateToSection('discarded', { flag: 'discarded' })
break
case 'duplicates':
navigateToSection('duplicates', { duplicates: true })
break
case 'tags':
navigateToSection('tags', { groupBy: 'tag' })
break
case 'colors':
navigateToSection('colors', { groupBy: 'color' })
break
default:
if (id.startsWith('folder-')) {
const folderId = id.slice('folder-'.length)
navigateToSection(`folder-${folderId}`, { folderId })
}
}
}
// Fetch the recursive folder tree (one root per active source root).
const { data: folderTree = [] } = useFolderTreeQuery()
const renameMutation = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) =>
sourceFolders.rename(id, name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['folders'] })
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
queryClient.invalidateQueries({ queryKey: ['photos'] })
},
onError: (e: any) =>
toast.error('Rename failed', e?.response?.data?.detail || e.message || 'Unknown error'),
})
const createFolderMutation = useMutation({
mutationFn: ({ parentId, name }: { parentId: string; name: string }) =>
sourceFolders.create(parentId, name),
onSuccess: (data) => {
toast.success('Folder created', data.name)
queryClient.invalidateQueries({ queryKey: ['folders'] })
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
setCreatingUnder(null)
setCreateDraft('')
},
onError: (e: any) =>
toast.error('Create failed', e?.response?.data?.detail || e.message || 'Unknown error'),
})
const deleteFolderMutation = useMutation({
mutationFn: ({ id, mode }: { id: string; mode: 'discard' | 'permanent' }) =>
sourceFolders.delete(id, mode),
onSuccess: (data) => {
if (data.mode === 'discard') {
toast.success(
'Folder photos discarded',
`${data.discarded ?? 0} moved to discard pile`
)
} else {
toast.success(
'Folder deleted',
`${data.deleted_photos ?? 0} photos removed from disk`
)
}
queryClient.invalidateQueries({ queryKey: ['folders'] })
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
// If we were viewing the deleted folder, snap back to all-photos.
if (deletingFolder && currentSection === `folder-${deletingFolder.id}`) {
navigateToSection('all-photos', {})
}
setDeletingFolder(null)
},
onError: (e: any) =>
toast.error('Delete failed', e?.response?.data?.detail || e.message || 'Unknown error'),
})
const toggleExpanded = (id: string) => {
const newExpanded = new Set(expandedItems)
if (newExpanded.has(id)) {
newExpanded.delete(id)
} else {
newExpanded.add(id)
}
setExpandedItems(newExpanded)
}
// Recursively map a backend FolderTreeNode into our generic TreeItem.
const folderNodeToTreeItem = (node: FolderTreeNode): TreeItem => ({
id: `folder-${node.id}`,
label: node.name,
icon: <Folder className="h-4 w-4" />,
count: node.photo_count,
type: 'folder',
children: node.children.length > 0
? node.children.map(folderNodeToTreeItem)
: undefined,
})
// Total tag count for the badge on the Tags entry.
const tagsTotalCount = allTags.reduce((sum, t) => sum + (t.photo_count || 0), 0)
const libraryTree: TreeItem[] = [
{
id: 'library',
label: 'Views',
icon: <Layers2 className="h-4 w-4" />,
children: [
{ id: 'all-photos', label: 'All Photos', icon: <Image className="h-4 w-4" />, count: stats?.all_photos ?? 0 },
{ id: 'rated', label: 'Rated', icon: <Star className="h-4 w-4" />, count: stats?.rated ?? 0 },
{ id: 'tags', label: 'Tags', icon: <TagIcon className="h-4 w-4" />, count: tagsTotalCount },
{ id: 'colors', label: 'Colors', icon: <Palette className="h-4 w-4" />, count: stats?.colored ?? 0 },
{ id: 'duplicates', label: 'Duplicates', icon: <Copy className="h-4 w-4" />, count: stats?.duplicates ?? 0 },
{ id: 'discarded', label: 'Discarded', icon: <Trash2 className="h-4 w-4" />, count: stats?.discarded ?? 0 },
],
},
{
id: 'folders',
label: 'Folders',
icon: <HardDrive className="h-4 w-4" />,
children: folderTree.map(folderNodeToTreeItem),
},
]
// Derive whether a tree item is currently the "active" filter target.
// Folder rows are selected when the filter store's folderId matches; the
// library "All Photos" virtual node is selected when no folder/heap filter
// is set.
// Active highlight is now driven entirely by currentSection. Each
// library node and folder row maps 1:1 to a section id.
const isItemActive = (id: string): boolean => {
if (id.startsWith('folder-')) {
return currentSection === id
}
return currentSection === id
}
// Which tree items accept photo drops, and what each does on drop.
const isDropTarget = (id: string): boolean => {
return id === 'discarded' || id.startsWith('folder-')
}
const handleDrop = (id: string, ids: string[], copy: boolean) => {
if (id === 'discarded') {
discardDropMutation.mutate(ids)
return
}
if (id.startsWith('folder-')) {
const targetId = id.slice('folder-'.length)
if (copy) {
copyDropMutation.mutate({ targetId, photoIds: ids })
} else {
moveDropMutation.mutate({ targetId, photoIds: ids })
}
}
}
const renderTreeItem = (item: TreeItem, depth: number = 0) => {
const hasChildren = item.children && item.children.length > 0
const isExpanded = expandedItems.has(item.id)
const isSelected = isItemActive(item.id)
const acceptsDrop = isDropTarget(item.id)
const isDropHover = dropTargetId === item.id
// Top-level entries (Views, Folders) render as small uppercase
// section eyebrows rather than another tree row, so the panel reads
// as distinct sections with the actual items beneath them.
const isSectionHeader = depth === 0
return (
<div key={item.id}>
<div
className={clsx(
'group flex cursor-pointer items-center gap-1',
isSectionHeader
? 'mt-2 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-text-muted hover:text-text'
: clsx(
// Fixed h-[24px] (not min-h) locks the row height so the
// hover-only kebab button can't grow the row vertically.
'h-[24px] rounded px-2 text-[12px] leading-none',
isSelected ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2'
),
isDropHover && (item.id === 'discarded'
? 'ring-2 ring-reject bg-reject/10'
: 'ring-2 ring-primary bg-primary/10')
)}
style={
isSectionHeader
? undefined
: { paddingLeft: `${8 + (depth - 1) * 12}px` }
}
onClick={() => {
if (renamingId === item.id) return
// Folder rows are always filterable, parent or leaf — clicking
// anywhere on the row applies the filter and the chevron
// (separate button below) handles expansion. Other group
// headers (Library, Folders) just toggle expansion since
// they have no associated section.
if (item.id.startsWith('folder-')) {
applyLibraryNode(item.id)
} else if (hasChildren) {
toggleExpanded(item.id)
} else {
applyLibraryNode(item.id)
}
}}
onDoubleClick={
item.id.startsWith('folder-')
? (e) => {
e.stopPropagation()
setRenamingId(item.id)
setRenameDraft(item.label)
}
: undefined
}
onDragOver={acceptsDrop ? (e) => {
if (e.dataTransfer.types.includes(PHOTO_DRAG_MIME)) {
e.preventDefault()
// Alt held → copy (only meaningful for folder targets;
// discarding doesn't copy).
const wantCopy = e.altKey && item.id.startsWith('folder-')
e.dataTransfer.dropEffect = wantCopy ? 'copy' : 'move'
if (dropTargetId !== item.id) setDropTargetId(item.id)
}
} : undefined}
onDragLeave={acceptsDrop ? (e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
if (dropTargetId === item.id) setDropTargetId(null)
}
} : undefined}
onDrop={acceptsDrop ? (e) => {
e.preventDefault()
setDropTargetId(null)
const ids = readDragIds(e)
if (ids) handleDrop(item.id, ids, e.altKey)
} : undefined}
>
{/* Expand/Collapse Icon */}
{hasChildren ? (
<button
onClick={(e) => {
e.stopPropagation()
toggleExpanded(item.id)
}}
className="rounded p-0.5 hover:bg-surface-offset"
>
{isExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
</button>
) : (
!isSectionHeader && <div className="w-3" />
)}
{/* Item Icon — section headers drop their icon in favor of the
* uppercase eyebrow label. */}
{item.icon && !isSectionHeader && (
<span
className={clsx(
'flex-shrink-0',
isSelected ? 'text-primary' : 'text-text-muted'
)}
>
<span className="[&>svg]:h-3.5 [&>svg]:w-3.5">{item.icon}</span>
</span>
)}
{/* Label (or inline rename input for folder rows) */}
{renamingId === item.id ? (
<input
autoFocus
type="text"
value={renameDraft}
onChange={(e) => setRenameDraft(e.target.value)}
onClick={(e) => e.stopPropagation()}
onBlur={() => {
const next = renameDraft.trim()
const id = item.id.slice('folder-'.length)
if (next && next !== item.label) {
renameMutation.mutate({ id, name: next })
}
setRenamingId(null)
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur()
} else if (e.key === 'Escape') {
setRenamingId(null)
}
}}
className="flex-1 rounded border border-border bg-bg px-1 py-0 text-[13px] text-text focus:border-primary focus:outline-none"
/>
) : (
<span className="flex-1 truncate">{item.label}</span>
)}
{/* Count Badge — fixed-width slot so counts line up in a column
* across rows regardless of digit count. Section headers skip
* the badge entirely (they're labels, not navigable rows). */}
{!isSectionHeader && (
item.count !== undefined && item.count > 0 ? (
<span className="flex h-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded bg-surface-offset px-1 text-[10px] tabular-nums text-text-muted">
{item.count}
</span>
) : (
<span className="h-4 min-w-[20px] flex-shrink-0" aria-hidden="true" />
)
)}
{/* Folder kebab menu — only on folder rows. Hidden (display:none)
* until hover so it reserves NO width in the resting state and
* the count column stays aligned across folder + non-folder
* rows. On hover it appears to the right, pushing the count
* left to make room. */}
{item.id.startsWith('folder-') &&
(() => {
const folderId = item.id.slice('folder-'.length)
const isMenuOpen = openMenuId === item.id
return (
<div
className={clsx(
'relative flex-shrink-0',
isMenuOpen ? 'block' : 'hidden group-hover:block'
)}
>
<button
onClick={(e) => {
e.stopPropagation()
setOpenMenuId(isMenuOpen ? null : item.id)
}}
className="rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
title="More actions"
aria-label="More folder actions"
aria-haspopup="menu"
aria-expanded={isMenuOpen}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
{isMenuOpen && (
<div
ref={menuRef}
role="menu"
onClick={(e) => e.stopPropagation()}
className="absolute right-0 top-full z-30 mt-1 min-w-[180px] overflow-hidden rounded-lg border border-border bg-surface py-1 text-sm shadow-xl"
>
<FolderMenuItem
icon={<FolderPlus className="h-3.5 w-3.5" />}
label="New sub-folder"
onClick={() => {
setOpenMenuId(null)
setCreatingUnder(folderId)
setCreateDraft('')
// Make sure the parent is expanded so the new
// input is visible.
if (!expandedItems.has(item.id)) {
toggleExpanded(item.id)
}
}}
/>
<FolderMenuItem
icon={<Pencil className="h-3.5 w-3.5" />}
label="Rename"
onClick={() => {
setOpenMenuId(null)
setRenamingId(item.id)
setRenameDraft(item.label)
}}
/>
<div className="my-1 h-px bg-border" />
<FolderMenuItem
icon={<Trash2 className="h-3.5 w-3.5" />}
label="Delete folder…"
destructive
onClick={() => {
setOpenMenuId(null)
setDeletingFolder({
id: folderId,
name: item.label,
photoCount: item.count,
})
}}
/>
</div>
)}
</div>
)
})()}
</div>
{/* Inline "create new sub-folder" input. Renders just below the
* parent row when its create state is active. */}
{item.id.startsWith('folder-') &&
creatingUnder === item.id.slice('folder-'.length) && (
<div
className="flex items-center gap-1 px-2 py-1"
style={{ paddingLeft: `${8 + (depth + 1) * 16 + 4}px` }}
>
<FolderPlus className="h-3 w-3 flex-shrink-0 text-text-muted" />
<input
autoFocus
type="text"
value={createDraft}
placeholder="New folder name"
onChange={(e) => setCreateDraft(e.target.value)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const name = createDraft.trim()
if (name) {
createFolderMutation.mutate({
parentId: item.id.slice('folder-'.length),
name,
})
}
} else if (e.key === 'Escape') {
setCreatingUnder(null)
setCreateDraft('')
}
}}
onBlur={() => {
// Don't auto-commit on blur — empty/escaped renames
// close the input but don't fire the request.
if (!createFolderMutation.isPending) {
setCreatingUnder(null)
setCreateDraft('')
}
}}
className="flex-1 rounded border border-border bg-bg px-1 py-0 text-[13px] text-text focus:border-primary focus:outline-none"
/>
</div>
)}
{/* Render Children */}
{hasChildren && isExpanded && (
<div>
{item.children!.map((child) => renderTreeItem(child, depth + 1))}
</div>
)}
</div>
)
}
return (
<div className="flex h-full flex-col bg-surface">
{/* Header with collapse button. Matches the right sidebar header
* so both panels have symmetric affordances. */}
<div className="flex h-9 flex-shrink-0 items-center justify-between border-b border-border px-3">
<h2 className="text-[11px] font-semibold uppercase tracking-[0.14em] text-text-muted">
Library
</h2>
<button
onClick={onCollapse}
className="rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
title="Collapse panel (Tab)"
aria-label="Collapse panel"
>
<PanelLeftClose className="h-3.5 w-3.5" />
</button>
</div>
{/* Active heap card — pinned just below the Library header so
* toasts (bottom-left fixed) can't cover it. Returns null when
* no heap is active, so the layout collapses cleanly. */}
<ActiveHeapCard />
{/* Tree View */}
<div className="flex-1 overflow-y-auto pb-2">
{libraryTree.map((item) => renderTreeItem(item))}
<HeapsPanel />
</div>
{/* Settings entry point — pinned to the bottom of the panel so it
* sits out of the way of the library tree but is always reachable. */}
<div className="border-t border-border p-1.5">
<button
onClick={onOpenSettings}
className="flex w-full items-center gap-2 rounded px-2 py-1 text-[12px] text-text-muted hover:bg-surface-2 hover:text-text"
title="Settings"
>
<Settings className="h-3.5 w-3.5" />
Settings
</button>
</div>
<DeleteFolderDialog
isOpen={!!deletingFolder}
folderName={deletingFolder?.name ?? ''}
photoCount={deletingFolder?.photoCount}
onClose={() => setDeletingFolder(null)}
onConfirm={(mode) => {
if (deletingFolder) {
deleteFolderMutation.mutate({ id: deletingFolder.id, mode })
}
}}
/>
</div>
)
}
function FolderMenuItem({
icon,
label,
onClick,
destructive = false,
}: {
icon: React.ReactNode
label: string
onClick: () => void
destructive?: boolean
}) {
return (
<button
role="menuitem"
onClick={onClick}
className={clsx(
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors',
destructive
? 'text-reject hover:bg-reject/10'
: 'text-text hover:bg-surface-2'
)}
>
<span className="text-text-muted">{icon}</span>
{label}
</button>
)
}