Files
resolutionflow/frontend/src/components/library/FolderSidebar.tsx
Michael Chihlas f33c3c8b29 fix: Swap folder tree count with menu button on hover
The tree count and hamburger menu were overlapping at the right edge of
folder items. Now the count hides on hover and the menu button appears
in its place.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 23:59:16 -05:00

473 lines
15 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react'
import { Folder, ChevronDown, ChevronRight, Plus, MoreVertical, Pencil, Trash2, FolderPlus } from 'lucide-react'
import { foldersApi } from '@/api'
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
}
// 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-accent',
selectedFolderId === folder.id && 'bg-accent 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-accent 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-muted-foreground 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-accent'
)}
>
<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-input',
'bg-popover 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 hover:bg-accent"
>
<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 hover:bg-accent"
>
<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-destructive hover:bg-accent"
>
<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,
}: 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 (
<>
<div className="w-56 shrink-0 border-r border-border bg-card">
<div className="p-4">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex w-full items-center gap-2 text-sm font-medium text-card-foreground"
>
{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-accent',
selectedFolderId === null && 'bg-accent 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-muted-foreground">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-muted-foreground transition-colors hover:bg-accent hover:text-foreground'
)}
>
<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-input',
'bg-popover 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 hover:bg-accent"
>
<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 hover:bg-accent"
>
<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-destructive hover:bg-accent"
>
<Trash2 className="h-3 w-3" />
Delete
</button>
</div>
)}
</>
)
}
export default FolderSidebar