Files
resolutionflow/frontend/src/components/library/FolderSidebar.tsx
Michael Chihlas 0e0e3572f4 refactor: replace barrel imports with direct module imports for tree-shaking
Replace all `from '@/api'` barrel imports with direct imports from
specific module files (e.g. `from '@/api/trees'`) across 20 files.
This enables better tree-shaking so each page only bundles the API
modules it actually uses.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 18:52:14 -05:00

502 lines
16 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react'
import { Folder, ChevronDown, ChevronRight, Plus, MoreVertical, Pencil, Trash2, FolderPlus, X } from 'lucide-react'
import { foldersApi } from '@/api/folders'
import type { FolderListItem, FolderTreeItem } from '@/types'
import { cn } from '@/lib/utils'
interface FolderSidebarProps {
selectedFolderId: string | null
onFolderSelect: (folderId: string | null) => void
onCreateFolder: (parentId?: string | null) => void
onEditFolder: (folder: FolderListItem) => void
mobileOpen?: boolean
onMobileClose?: () => void
}
// Build tree structure from flat folder list
function buildFolderTree(folders: FolderListItem[]): FolderTreeItem[] {
const folderMap = new Map<string, FolderTreeItem>()
const rootFolders: FolderTreeItem[] = []
// First pass: create all folder items
folders.forEach((folder) => {
folderMap.set(folder.id, {
...folder,
children: [],
isExpanded: true, // Default expanded
})
})
// Second pass: build parent-child relationships
folders.forEach((folder) => {
const treeItem = folderMap.get(folder.id)!
if (folder.parent_id && folderMap.has(folder.parent_id)) {
const parent = folderMap.get(folder.parent_id)!
parent.children.push(treeItem)
} else {
rootFolders.push(treeItem)
}
})
// Sort children by display_order
const sortChildren = (items: FolderTreeItem[]) => {
items.sort((a, b) => a.display_order - b.display_order)
items.forEach((item) => sortChildren(item.children))
}
sortChildren(rootFolders)
return rootFolders
}
// Get all descendant IDs (for cascade delete warning)
function getDescendantIds(folders: FolderListItem[], folderId: string): string[] {
const children = folders.filter((f) => f.parent_id === folderId)
const descendantIds: string[] = []
children.forEach((child) => {
descendantIds.push(child.id)
descendantIds.push(...getDescendantIds(folders, child.id))
})
return descendantIds
}
interface ContextMenuState {
x: number
y: number
folder: FolderTreeItem
canAddSubfolder: boolean
}
interface FolderItemProps {
folder: FolderTreeItem
depth: number
selectedFolderId: string | null
expandedIds: Set<string>
menuOpenId: string | null
onFolderSelect: (folderId: string) => void
onToggleExpand: (folderId: string) => void
onMenuToggle: (folderId: string | null) => void
onEditFolder: (folder: FolderListItem) => void
onAddSubfolder: (parentId: string) => void
onDeleteFolder: (folderId: string, hasChildren: boolean) => void
onContextMenu: (e: React.MouseEvent, folder: FolderTreeItem, canAddSubfolder: boolean) => void
canAddSubfolder: boolean
}
function FolderItem({
folder,
depth,
selectedFolderId,
expandedIds,
menuOpenId,
onFolderSelect,
onToggleExpand,
onMenuToggle,
onEditFolder,
onAddSubfolder,
onDeleteFolder,
onContextMenu,
canAddSubfolder,
}: FolderItemProps) {
const isExpanded = expandedIds.has(folder.id)
const hasSubfolders = folder.children.length > 0
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
onContextMenu(e, folder, canAddSubfolder)
}
return (
<div>
<div className="group relative" onContextMenu={handleContextMenu}>
<button
onClick={() => onFolderSelect(folder.id)}
className={cn(
'flex w-full items-center gap-1 rounded-md py-1.5 text-sm',
'transition-colors hover:bg-white/[0.06]',
selectedFolderId === folder.id && 'bg-white/10 text-white font-medium'
)}
style={{ paddingLeft: `${8 + depth * 16}px`, paddingRight: '8px' }}
>
{/* Expand/collapse toggle */}
{hasSubfolders ? (
<button
onClick={(e) => {
e.stopPropagation()
onToggleExpand(folder.id)
}}
className="shrink-0 p-0.5 hover:bg-white/[0.06] rounded"
>
{isExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
</button>
) : (
<span className="w-4" /> // Spacer for alignment
)}
<Folder className="h-4 w-4 shrink-0" style={{ color: folder.color }} />
<span className="flex-1 truncate text-left">{folder.name}</span>
<span className="text-xs text-white/40 group-hover:hidden">{folder.tree_count}</span>
</button>
{/* Folder menu button - replaces tree count on hover */}
<button
onClick={(e) => {
e.stopPropagation()
onMenuToggle(menuOpenId === folder.id ? null : folder.id)
}}
className={cn(
'absolute right-1 top-1/2 -translate-y-1/2 rounded p-1',
'hidden group-hover:block',
'hover:bg-white/[0.06]'
)}
>
<MoreVertical className="h-3 w-3" />
</button>
{/* Dropdown menu */}
{menuOpenId === folder.id && (
<div
className={cn(
'absolute right-0 top-full z-10 mt-1 w-40 rounded-md border border-white/10',
'bg-black/90 backdrop-blur-sm py-1 shadow-lg'
)}
>
<button
onClick={(e) => {
e.stopPropagation()
onEditFolder(folder)
onMenuToggle(null)
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-sm text-white/70 hover:bg-white/[0.06] hover:text-white"
>
<Pencil className="h-3 w-3" />
Edit
</button>
{canAddSubfolder && (
<button
onClick={(e) => {
e.stopPropagation()
onAddSubfolder(folder.id)
onMenuToggle(null)
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-sm text-white/70 hover:bg-white/[0.06] hover:text-white"
>
<FolderPlus className="h-3 w-3" />
Add Subfolder
</button>
)}
<button
onClick={(e) => {
e.stopPropagation()
onDeleteFolder(folder.id, hasSubfolders)
onMenuToggle(null)
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-sm text-red-400 hover:bg-red-400/10"
>
<Trash2 className="h-3 w-3" />
Delete
</button>
</div>
)}
</div>
{/* Children */}
{isExpanded && hasSubfolders && (
<div>
{folder.children.map((child) => (
<FolderItem
key={child.id}
folder={child}
depth={depth + 1}
selectedFolderId={selectedFolderId}
expandedIds={expandedIds}
menuOpenId={menuOpenId}
onFolderSelect={onFolderSelect}
onToggleExpand={onToggleExpand}
onMenuToggle={onMenuToggle}
onEditFolder={onEditFolder}
onAddSubfolder={onAddSubfolder}
onDeleteFolder={onDeleteFolder}
onContextMenu={onContextMenu}
canAddSubfolder={depth + 1 < 2} // Max depth is 3, so can add at depth 0, 1 (not 2)
/>
))}
</div>
)}
</div>
)
}
export function FolderSidebar({
selectedFolderId,
onFolderSelect,
onCreateFolder,
onEditFolder,
mobileOpen = false,
onMobileClose,
}: FolderSidebarProps) {
const [folders, setFolders] = useState<FolderListItem[]>([])
const [folderTree, setFolderTree] = useState<FolderTreeItem[]>([])
const [isExpanded, setIsExpanded] = useState(true)
const [isLoading, setIsLoading] = useState(true)
const [menuOpenId, setMenuOpenId] = useState<string | null>(null)
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
const loadFolders = useCallback(async () => {
setIsLoading(true)
try {
const data = await foldersApi.list()
setFolders(data)
setFolderTree(buildFolderTree(data))
// Expand all by default
setExpandedIds(new Set(data.map((f) => f.id)))
} catch (err) {
console.error('Failed to load folders:', err)
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
loadFolders()
}, [loadFolders])
const handleToggleExpand = (folderId: string) => {
setExpandedIds((prev) => {
const next = new Set(prev)
if (next.has(folderId)) {
next.delete(folderId)
} else {
next.add(folderId)
}
return next
})
}
const handleDeleteFolder = async (folderId: string, folderHasChildren: boolean) => {
const descendantCount = getDescendantIds(folders, folderId).length
const message = folderHasChildren
? `Are you sure you want to delete this folder and its ${descendantCount} subfolder(s)? The trees in them will not be deleted.`
: 'Are you sure you want to delete this folder? The trees in it will not be deleted.'
if (!confirm(message)) {
return
}
try {
await foldersApi.delete(folderId)
// Remove folder and all descendants from local state
const idsToRemove = new Set([folderId, ...getDescendantIds(folders, folderId)])
const updatedFolders = folders.filter((f) => !idsToRemove.has(f.id))
setFolders(updatedFolders)
setFolderTree(buildFolderTree(updatedFolders))
if (selectedFolderId && idsToRemove.has(selectedFolderId)) {
onFolderSelect(null)
}
} catch (err) {
console.error('Failed to delete folder:', err)
}
}
const handleAddSubfolder = (parentId: string) => {
onCreateFolder(parentId)
}
const handleContextMenu = (e: React.MouseEvent, folder: FolderTreeItem, canAddSubfolder: boolean) => {
setContextMenu({
x: e.clientX,
y: e.clientY,
folder,
canAddSubfolder,
})
setMenuOpenId(null) // Close any open hover menu
}
const closeContextMenu = () => {
setContextMenu(null)
}
// Refresh folders when a folder is edited or created
useEffect(() => {
const handleFolderChange = () => loadFolders()
window.addEventListener('folder-changed', handleFolderChange)
return () => window.removeEventListener('folder-changed', handleFolderChange)
}, [loadFolders])
// Close hover menu when clicking outside
useEffect(() => {
if (!menuOpenId) return
const handleClickOutside = () => setMenuOpenId(null)
document.addEventListener('click', handleClickOutside)
return () => document.removeEventListener('click', handleClickOutside)
}, [menuOpenId])
// Close context menu on click outside, right-click elsewhere, or Escape
useEffect(() => {
if (!contextMenu) return
const close = () => setContextMenu(null)
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') close()
}
document.addEventListener('click', close)
document.addEventListener('contextmenu', close)
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('click', close)
document.removeEventListener('contextmenu', close)
document.removeEventListener('keydown', handleKeyDown)
}
}, [contextMenu])
return (
<>
{/* Mobile backdrop */}
{mobileOpen && (
<div
className="fixed inset-0 z-40 bg-black/80 backdrop-blur-sm md:hidden"
onClick={onMobileClose}
aria-hidden="true"
/>
)}
<div className={cn(
'w-56 shrink-0 border-r border-white/[0.06] bg-transparent',
'hidden md:block',
mobileOpen && 'fixed inset-y-0 left-0 z-50 block animate-slide-in-left md:relative md:animate-none'
)}>
<div className="p-4">
{/* Mobile close button */}
{mobileOpen && (
<div className="mb-3 flex items-center justify-between md:hidden">
<span className="text-sm font-medium text-white">Folders</span>
<button
onClick={onMobileClose}
className="rounded-md p-1.5 text-white/40 hover:bg-white/[0.06]"
aria-label="Close folders"
>
<X className="h-4 w-4" />
</button>
</div>
)}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex w-full items-center gap-2 text-sm font-medium text-white"
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
<span>FOLDERS</span>
</button>
{isExpanded && (
<div className="mt-3 space-y-0.5">
{/* All Trees */}
<button
onClick={() => onFolderSelect(null)}
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm',
'transition-colors hover:bg-white/[0.06]',
selectedFolderId === null && 'bg-white/10 text-white font-medium'
)}
>
<Folder className="h-4 w-4" />
<span>All Trees</span>
</button>
{/* Loading state */}
{isLoading ? (
<div className="px-2 py-1.5 text-sm text-white/40">Loading...</div>
) : (
<>
{/* User folders (hierarchical) */}
{folderTree.map((folder) => (
<FolderItem
key={folder.id}
folder={folder}
depth={0}
selectedFolderId={selectedFolderId}
expandedIds={expandedIds}
menuOpenId={menuOpenId}
onFolderSelect={onFolderSelect}
onToggleExpand={handleToggleExpand}
onMenuToggle={setMenuOpenId}
onEditFolder={onEditFolder}
onAddSubfolder={handleAddSubfolder}
onDeleteFolder={handleDeleteFolder}
onContextMenu={handleContextMenu}
canAddSubfolder={true} // Root folders can have subfolders
/>
))}
</>
)}
{/* Create folder button */}
<button
onClick={() => onCreateFolder(null)}
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm',
'text-white/50 transition-colors hover:bg-white/[0.06] hover:text-white'
)}
>
<Plus className="h-4 w-4" />
<span>New Folder</span>
</button>
</div>
)}
</div>
</div>
{/* Right-click context menu */}
{contextMenu && (
<div
className={cn(
'fixed z-50 w-44 rounded-md border border-white/10',
'bg-black/90 backdrop-blur-sm py-1 shadow-lg'
)}
style={{ left: contextMenu.x, top: contextMenu.y }}
onClick={(e) => e.stopPropagation()}
>
<button
onClick={() => {
onEditFolder(contextMenu.folder)
closeContextMenu()
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-sm text-white/70 hover:bg-white/[0.06] hover:text-white"
>
<Pencil className="h-3 w-3" />
Edit
</button>
{contextMenu.canAddSubfolder && (
<button
onClick={() => {
handleAddSubfolder(contextMenu.folder.id)
closeContextMenu()
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-sm text-white/70 hover:bg-white/[0.06] hover:text-white"
>
<FolderPlus className="h-3 w-3" />
Add Subfolder
</button>
)}
<button
onClick={() => {
handleDeleteFolder(contextMenu.folder.id, contextMenu.folder.children.length > 0)
closeContextMenu()
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-sm text-red-400 hover:bg-red-400/10"
>
<Trash2 className="h-3 w-3" />
Delete
</button>
</div>
)}
</>
)
}
export default FolderSidebar