Add tree organization system with categories, tags, and folders
Features: - Categories: Global and team-specific tree categorization (admin-managed) - Tags: Flexible tree tagging with autocomplete (author + admin) - User folders: Personal tree collections with subfolder support - Hierarchical structure (max 3 levels deep) - Right-click context menu for folder management - Cascade delete for subfolders - Filter trees by category, tags, and folder in library view Backend: - New models: Category, Tag, UserFolder with relationships - New API endpoints for categories, tags, and folders - Tree organization migrations (005, 006) Frontend: - FolderSidebar with hierarchical folder tree - FolderEditModal for create/edit with color picker - AddToFolderMenu for quick tree organization - TagInput with autocomplete and TagBadges display - Updated TreeMetadataForm and TreeLibraryPage Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
484
frontend/src/components/library/FolderSidebar.tsx
Normal file
484
frontend/src/components/library/FolderSidebar.tsx
Normal file
@@ -0,0 +1,484 @@
|
||||
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
|
||||
}
|
||||
|
||||
// Calculate folder depth (for limiting nesting)
|
||||
function getFolderDepth(folders: FolderListItem[], folderId: string): number {
|
||||
const folder = folders.find((f) => f.id === folderId)
|
||||
if (!folder || !folder.parent_id) return 1
|
||||
return 1 + getFolderDepth(folders, folder.parent_id)
|
||||
}
|
||||
|
||||
// Check if folder has children
|
||||
function hasChildren(folders: FolderListItem[], folderId: string): boolean {
|
||||
return folders.some((f) => f.parent_id === folderId)
|
||||
}
|
||||
|
||||
// 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">{folder.tree_count}</span>
|
||||
</button>
|
||||
|
||||
{/* Folder menu button */}
|
||||
<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',
|
||||
'opacity-0 group-hover:opacity-100',
|
||||
'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
|
||||
Reference in New Issue
Block a user