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:
chihlasm
2026-02-02 01:31:13 -05:00
parent 2d99c52025
commit fafdaa50a5
41 changed files with 5006 additions and 221 deletions

View File

@@ -0,0 +1,64 @@
import { cn } from '@/lib/utils'
interface TagBadgesProps {
tags: string[]
maxVisible?: number
onTagClick?: (tag: string) => void
size?: 'sm' | 'md'
variant?: 'default' | 'muted'
}
export function TagBadges({
tags,
maxVisible = 3,
onTagClick,
size = 'sm',
variant = 'default',
}: TagBadgesProps) {
if (!tags || tags.length === 0) return null
const visibleTags = tags.slice(0, maxVisible)
const hiddenCount = tags.length - maxVisible
return (
<div className="flex flex-wrap items-center gap-1">
{visibleTags.map((tag) => (
<button
key={tag}
type="button"
onClick={(e) => {
if (onTagClick) {
e.stopPropagation()
onTagClick(tag)
}
}}
disabled={!onTagClick}
className={cn(
'rounded-full transition-colors',
size === 'sm' ? 'px-2 py-0.5 text-xs' : 'px-2.5 py-1 text-sm',
variant === 'default'
? 'bg-primary/10 text-primary hover:bg-primary/20'
: 'bg-muted text-muted-foreground hover:bg-muted/80',
!onTagClick && 'cursor-default'
)}
>
{tag}
</button>
))}
{hiddenCount > 0 && (
<span
className={cn(
'rounded-full',
size === 'sm' ? 'px-2 py-0.5 text-xs' : 'px-2.5 py-1 text-sm',
'bg-muted text-muted-foreground'
)}
title={tags.slice(maxVisible).join(', ')}
>
+{hiddenCount} more
</span>
)}
</div>
)
}
export default TagBadges

View File

@@ -0,0 +1,230 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { X, Plus } from 'lucide-react'
import { tagsApi } from '@/api'
import type { TagListItem } from '@/types'
import { cn } from '@/lib/utils'
interface TagInputProps {
tags: string[]
onChange: (tags: string[]) => void
maxTags?: number
placeholder?: string
disabled?: boolean
}
export function TagInput({
tags,
onChange,
maxTags = 10,
placeholder = 'Add tags...',
disabled = false,
}: TagInputProps) {
const [inputValue, setInputValue] = useState('')
const [suggestions, setSuggestions] = useState<TagListItem[]>([])
const [showSuggestions, setShowSuggestions] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(-1)
const inputRef = useRef<HTMLInputElement>(null)
const wrapperRef = useRef<HTMLDivElement>(null)
// Debounced search for suggestions
useEffect(() => {
const timer = setTimeout(() => {
if (inputValue.length >= 1) {
tagsApi
.search(inputValue, 5)
.then((results) => {
// Filter out already selected tags
const filtered = results.filter(
(tag) => !tags.includes(tag.name)
)
setSuggestions(filtered)
setShowSuggestions(filtered.length > 0)
setSelectedIndex(-1)
})
.catch(console.error)
} else {
setSuggestions([])
setShowSuggestions(false)
}
}, 200)
return () => clearTimeout(timer)
}, [inputValue, tags])
// Close suggestions on outside click
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
setShowSuggestions(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const addTag = useCallback(
(tagName: string) => {
const normalized = tagName.trim()
if (!normalized) return
if (tags.length >= maxTags) return
if (tags.includes(normalized)) return
onChange([...tags, normalized])
setInputValue('')
setSuggestions([])
setShowSuggestions(false)
inputRef.current?.focus()
},
[tags, maxTags, onChange]
)
const removeTag = useCallback(
(tagName: string) => {
onChange(tags.filter((t) => t !== tagName))
},
[tags, onChange]
)
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
if (selectedIndex >= 0 && suggestions[selectedIndex]) {
addTag(suggestions[selectedIndex].name)
} else if (inputValue.trim()) {
addTag(inputValue)
}
} else if (e.key === 'Backspace' && !inputValue && tags.length > 0) {
removeTag(tags[tags.length - 1])
} else if (e.key === 'ArrowDown') {
e.preventDefault()
setSelectedIndex((prev) =>
prev < suggestions.length - 1 ? prev + 1 : prev
)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1))
} else if (e.key === 'Escape') {
setShowSuggestions(false)
setSelectedIndex(-1)
} else if (e.key === ',' || e.key === 'Tab') {
if (inputValue.trim()) {
e.preventDefault()
addTag(inputValue)
}
}
}
return (
<div ref={wrapperRef} className="relative">
<div
className={cn(
'flex flex-wrap gap-1.5 rounded-md border px-2 py-1.5',
'bg-background text-foreground',
'focus-within:border-primary focus-within:ring-1 focus-within:ring-primary',
disabled ? 'cursor-not-allowed opacity-50' : '',
'border-input'
)}
onClick={() => inputRef.current?.focus()}
>
{/* Tag chips */}
{tags.map((tag) => (
<span
key={tag}
className={cn(
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs',
'bg-primary/10 text-primary'
)}
>
{tag}
{!disabled && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
removeTag(tag)
}}
className="rounded-full p-0.5 hover:bg-primary/20"
>
<X className="h-3 w-3" />
</button>
)}
</span>
))}
{/* Input field */}
{tags.length < maxTags && (
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => {
if (inputValue.length >= 1 && suggestions.length > 0) {
setShowSuggestions(true)
}
}}
placeholder={tags.length === 0 ? placeholder : ''}
disabled={disabled}
className={cn(
'flex-1 min-w-[80px] border-0 bg-transparent px-1 py-0.5 text-sm',
'placeholder:text-muted-foreground',
'focus:outline-none focus:ring-0'
)}
/>
)}
</div>
{/* Suggestions dropdown */}
{showSuggestions && suggestions.length > 0 && (
<div
className={cn(
'absolute z-10 mt-1 w-full rounded-md border border-input',
'bg-popover shadow-lg'
)}
>
{suggestions.map((suggestion, index) => (
<button
key={suggestion.id}
type="button"
onClick={() => addTag(suggestion.name)}
className={cn(
'flex w-full items-center justify-between px-3 py-2 text-sm',
'hover:bg-accent',
index === selectedIndex && 'bg-accent'
)}
>
<span>{suggestion.name}</span>
<span className="text-xs text-muted-foreground">
{suggestion.usage_count} trees
</span>
</button>
))}
{inputValue.trim() &&
!suggestions.some(
(s) => s.name.toLowerCase() === inputValue.toLowerCase()
) && (
<button
type="button"
onClick={() => addTag(inputValue)}
className={cn(
'flex w-full items-center gap-2 border-t border-input px-3 py-2 text-sm',
'hover:bg-accent text-primary'
)}
>
<Plus className="h-4 w-4" />
Create "{inputValue}"
</button>
)}
</div>
)}
{/* Helper text */}
<p className="mt-1 text-xs text-muted-foreground">
{tags.length}/{maxTags} tags. Press Enter or comma to add.
</p>
</div>
)
}
export default TagInput

View File

@@ -0,0 +1,155 @@
import { useState, useEffect, useRef } from 'react'
import { FolderPlus, Check, Plus } from 'lucide-react'
import { foldersApi } from '@/api'
import type { FolderListItem } from '@/types'
import { cn } from '@/lib/utils'
interface AddToFolderMenuProps {
treeId: string
onFolderCreated?: () => void
}
export function AddToFolderMenu({ treeId, onFolderCreated }: AddToFolderMenuProps) {
const [isOpen, setIsOpen] = useState(false)
const [folders, setFolders] = useState<FolderListItem[]>([])
const [treeFolderIds, setTreeFolderIds] = useState<Set<string>>(new Set())
const [isLoading, setIsLoading] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (isOpen) {
loadFoldersAndAssignments()
}
}, [isOpen, treeId])
// Close on outside click
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setIsOpen(false)
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [isOpen])
const loadFoldersAndAssignments = async () => {
setIsLoading(true)
try {
const [foldersData, allFolders] = await Promise.all([
foldersApi.list(),
Promise.resolve([]), // Will load tree's folder assignments below
])
setFolders(foldersData)
// Check which folders contain this tree
const folderIds = new Set<string>()
for (const folder of foldersData) {
try {
const treeIds = await foldersApi.getTreeIds(folder.id)
if (treeIds.includes(treeId)) {
folderIds.add(folder.id)
}
} catch {
// Ignore errors for individual folder checks
}
}
setTreeFolderIds(folderIds)
} catch (err) {
console.error('Failed to load folders:', err)
} finally {
setIsLoading(false)
}
}
const toggleFolder = async (folderId: string) => {
try {
if (treeFolderIds.has(folderId)) {
await foldersApi.removeTree(folderId, treeId)
setTreeFolderIds((prev) => {
const next = new Set(prev)
next.delete(folderId)
return next
})
} else {
await foldersApi.addTree(folderId, treeId)
setTreeFolderIds((prev) => new Set([...prev, folderId]))
}
// Dispatch event to refresh folder counts
window.dispatchEvent(new Event('folder-changed'))
} catch (err) {
console.error('Failed to toggle folder:', err)
}
}
return (
<div ref={menuRef} className="relative">
<button
onClick={(e) => {
e.stopPropagation()
setIsOpen(!isOpen)
}}
className={cn(
'rounded-md border border-input p-1.5 text-muted-foreground',
'hover:bg-accent hover:text-accent-foreground'
)}
title="Add to folder"
>
<FolderPlus className="h-4 w-4" />
</button>
{isOpen && (
<div
className={cn(
'absolute right-0 top-full z-20 mt-1 w-48 rounded-md border border-input',
'bg-popover py-1 shadow-lg'
)}
>
{isLoading ? (
<div className="px-3 py-2 text-sm text-muted-foreground">Loading...</div>
) : folders.length === 0 ? (
<div className="px-3 py-2 text-sm text-muted-foreground">No folders yet</div>
) : (
folders.map((folder) => (
<button
key={folder.id}
onClick={(e) => {
e.stopPropagation()
toggleFolder(folder.id)
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent"
>
<div
className="h-3 w-3 rounded-sm"
style={{ backgroundColor: folder.color }}
/>
<span className="flex-1 truncate text-left">{folder.name}</span>
{treeFolderIds.has(folder.id) && (
<Check className="h-4 w-4 text-primary" />
)}
</button>
))
)}
<div className="border-t border-input my-1" />
<button
onClick={(e) => {
e.stopPropagation()
setIsOpen(false)
onFolderCreated?.()
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-sm text-primary hover:bg-accent"
>
<Plus className="h-4 w-4" />
Create new folder
</button>
</div>
)}
</div>
)
}
export default AddToFolderMenu

View File

@@ -0,0 +1,291 @@
import { useState, useEffect, useMemo } from 'react'
import { X } from 'lucide-react'
import { foldersApi } from '@/api'
import type { FolderListItem, FolderCreate, FolderUpdate } from '@/types'
import { cn } from '@/lib/utils'
// Predefined color options
const FOLDER_COLORS = [
'#6366f1', // Indigo (default)
'#8b5cf6', // Violet
'#ec4899', // Pink
'#ef4444', // Red
'#f97316', // Orange
'#eab308', // Yellow
'#22c55e', // Green
'#14b8a6', // Teal
'#3b82f6', // Blue
'#64748b', // Slate
]
interface FolderEditModalProps {
folder: FolderListItem | null // null for create mode
parentId?: string | null // Pre-selected parent for creating subfolders
folders: FolderListItem[] // All folders for parent dropdown
isOpen: boolean
onClose: () => void
onSave: () => void
}
// Get all descendant IDs of a folder (to prevent cycles)
function getDescendantIds(folders: FolderListItem[], folderId: string): Set<string> {
const descendants = new Set<string>()
const children = folders.filter((f) => f.parent_id === folderId)
children.forEach((child) => {
descendants.add(child.id)
getDescendantIds(folders, child.id).forEach((id) => descendants.add(id))
})
return descendants
}
// Calculate folder depth
function getFolderDepth(folders: FolderListItem[], folderId: string | null): number {
if (!folderId) return 0
const folder = folders.find((f) => f.id === folderId)
if (!folder || !folder.parent_id) return 1
return 1 + getFolderDepth(folders, folder.parent_id)
}
// Get indented folder name for dropdown display
function getIndentedName(folders: FolderListItem[], folderId: string): string {
const depth = getFolderDepth(folders, folderId)
const folder = folders.find((f) => f.id === folderId)
const indent = ' '.repeat(depth - 1)
return indent + (depth > 1 ? '└ ' : '') + (folder?.name || '')
}
export function FolderEditModal({
folder,
parentId: initialParentId,
folders,
isOpen,
onClose,
onSave,
}: FolderEditModalProps) {
const [name, setName] = useState('')
const [color, setColor] = useState(FOLDER_COLORS[0])
const [parentId, setParentId] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const isEditMode = folder !== null
// Build list of valid parent options
const parentOptions = useMemo(() => {
// Can't be own parent, can't create cycles
const invalidIds = new Set<string>()
if (folder) {
// Exclude self and all descendants
invalidIds.add(folder.id)
getDescendantIds(folders, folder.id).forEach((id) => invalidIds.add(id))
}
// Filter to valid parents only (max depth 2 so that new folder is at depth 3)
return folders
.filter((f) => !invalidIds.has(f.id))
.filter((f) => {
const depth = getFolderDepth(folders, f.id)
// If creating new folder, parent can be at depth 1 or 2 (new folder at 2 or 3)
// If editing, we need to check if moving would exceed depth limit
if (folder) {
// Get max depth of folder's subtree
const getMaxSubtreeDepth = (folderId: string): number => {
const children = folders.filter((c) => c.parent_id === folderId)
if (children.length === 0) return 0
return 1 + Math.max(...children.map((c) => getMaxSubtreeDepth(c.id)))
}
const subtreeDepth = getMaxSubtreeDepth(folder.id)
// New parent depth + 1 (for this folder) + subtree must be <= 3
return depth + 1 + subtreeDepth <= 3
}
return depth < 3 // Can add child to folders at depth 1 or 2
})
.sort((a, b) => {
// Sort by hierarchy for better UX
const aPath = getPath(folders, a.id)
const bPath = getPath(folders, b.id)
return aPath.localeCompare(bPath)
})
}, [folder, folders])
// Get path string for sorting
function getPath(allFolders: FolderListItem[], folderId: string): string {
const f = allFolders.find((x) => x.id === folderId)
if (!f) return ''
if (!f.parent_id) return f.name
return getPath(allFolders, f.parent_id) + '/' + f.name
}
useEffect(() => {
if (folder) {
setName(folder.name)
setColor(folder.color)
setParentId(folder.parent_id || null)
} else {
setName('')
setColor(FOLDER_COLORS[0])
setParentId(initialParentId || null)
}
setError(null)
}, [folder, initialParentId, isOpen])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
if (!name.trim()) {
setError('Folder name is required')
return
}
setIsSubmitting(true)
try {
if (isEditMode && folder) {
const updateData: FolderUpdate = { name, color }
// Only include parent_id if it changed
if (parentId !== folder.parent_id) {
updateData.parent_id = parentId
}
await foldersApi.update(folder.id, updateData)
} else {
const createData: FolderCreate = { name, color }
if (parentId) {
createData.parent_id = parentId
}
await foldersApi.create(createData)
}
onSave()
onClose()
// Dispatch event to refresh folder list
window.dispatchEvent(new Event('folder-changed'))
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to save folder')
} finally {
setIsSubmitting(false)
}
}
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm" onClick={onClose} />
{/* Modal */}
<div className="relative z-10 w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-card-foreground">
{isEditMode ? 'Edit Folder' : initialParentId ? 'Create Subfolder' : 'Create Folder'}
</h2>
<button onClick={onClose} className="rounded-md p-1 hover:bg-accent">
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit}>
{/* Name input */}
<div className="mb-4">
<label htmlFor="folder-name" className="block text-sm font-medium text-foreground">
Name
</label>
<input
id="folder-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Citrix Issues"
className={cn(
'mt-1 block w-full rounded-md border px-3 py-2 text-sm',
'bg-background text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary',
'border-input'
)}
autoFocus
/>
</div>
{/* Parent folder dropdown */}
<div className="mb-4">
<label htmlFor="folder-parent" className="block text-sm font-medium text-foreground">
Parent Folder
</label>
<select
id="folder-parent"
value={parentId || ''}
onChange={(e) => setParentId(e.target.value || null)}
className={cn(
'mt-1 block w-full rounded-md border px-3 py-2 text-sm',
'bg-background text-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary',
'border-input'
)}
>
<option value="">None (root level)</option>
{parentOptions.map((f) => (
<option key={f.id} value={f.id}>
{getIndentedName(folders, f.id)}
</option>
))}
</select>
<p className="mt-1 text-xs text-muted-foreground">
Folders can be nested up to 3 levels deep.
</p>
</div>
{/* Color picker */}
<div className="mb-6">
<label className="block text-sm font-medium text-foreground">Color</label>
<div className="mt-2 flex flex-wrap gap-2">
{FOLDER_COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
className={cn(
'h-8 w-8 rounded-full transition-transform',
color === c && 'ring-2 ring-offset-2 ring-offset-background ring-primary scale-110'
)}
style={{ backgroundColor: c }}
title={c}
/>
))}
</div>
</div>
{/* Error message */}
{error && (
<div className="mb-4 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className={cn('rounded-md border border-input px-4 py-2 text-sm', 'hover:bg-accent')}
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className={cn(
'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
'hover:bg-primary/90',
'disabled:opacity-50'
)}
>
{isSubmitting ? 'Saving...' : isEditMode ? 'Save Changes' : 'Create Folder'}
</button>
</div>
</form>
</div>
</div>
)
}
export default FolderEditModal

View 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

View File

@@ -1,27 +1,54 @@
import { useEffect, useState } from 'react'
import { treesApi } from '@/api'
import { categoriesApi } from '@/api'
import { useTreeEditorStore } from '@/store/treeEditorStore'
import { TagInput } from '@/components/common/TagInput'
import type { CategoryListItem } from '@/types'
import { cn } from '@/lib/utils'
import { Globe, Lock } from 'lucide-react'
export function TreeMetadataForm() {
const { name, description, category, setName, setDescription, setCategory, validationErrors } =
useTreeEditorStore()
const {
name,
description,
category,
categoryId,
tags,
isPublic,
setName,
setDescription,
setCategory,
setCategoryId,
setTags,
setIsPublic,
validationErrors,
} = useTreeEditorStore()
const [categories, setCategories] = useState<string[]>([])
const [categories, setCategories] = useState<CategoryListItem[]>([])
const [legacyCategories, setLegacyCategories] = useState<string[]>([])
const [customCategory, setCustomCategory] = useState(false)
// Load existing categories
// Load categories
useEffect(() => {
treesApi.categories().then(setCategories).catch(console.error)
categoriesApi.list().then(setCategories).catch(console.error)
}, [])
const handleCategoryChange = (value: string) => {
if (value === '__custom__') {
setCustomCategory(true)
setCategory('')
setCategoryId(null)
} else if (value === '') {
setCustomCategory(false)
setCategory('')
setCategoryId(null)
} else {
setCustomCategory(false)
setCategory(value)
setCategoryId(value)
// Find category name for display
const cat = categories.find((c) => c.id === value)
if (cat) {
setCategory(cat.name)
}
}
}
@@ -51,9 +78,7 @@ export function TreeMetadataForm() {
nameError ? 'border-destructive' : 'border-input'
)}
/>
{nameError && (
<p className="mt-1 text-xs text-destructive">{nameError.message}</p>
)}
{nameError && <p className="mt-1 text-xs text-destructive">{nameError.message}</p>}
</div>
{/* Description */}
@@ -83,7 +108,7 @@ export function TreeMetadataForm() {
{!customCategory ? (
<select
id="tree-category"
value={category || ''}
value={categoryId || ''}
onChange={(e) => handleCategoryChange(e.target.value)}
className={cn(
'mt-1 block w-full rounded-md border border-input px-3 py-2 text-sm',
@@ -93,8 +118,9 @@ export function TreeMetadataForm() {
>
<option value="">No category</option>
{categories.map((cat) => (
<option key={cat} value={cat}>
{cat}
<option key={cat.id} value={cat.id}>
{cat.name}
{cat.team_id ? ' (Team)' : ''}
</option>
))}
<option value="__custom__">+ Add custom category</option>
@@ -117,6 +143,7 @@ export function TreeMetadataForm() {
onClick={() => {
setCustomCategory(false)
setCategory('')
setCategoryId(null)
}}
className="rounded-md border border-input px-3 py-2 text-sm hover:bg-accent"
>
@@ -125,6 +152,60 @@ export function TreeMetadataForm() {
</div>
)}
</div>
{/* Tags */}
<div>
<label className="block text-sm font-medium text-foreground">Tags</label>
<div className="mt-1">
<TagInput tags={tags} onChange={setTags} maxTags={10} placeholder="Add tags..." />
</div>
</div>
{/* Visibility */}
<div>
<label className="block text-sm font-medium text-foreground">Visibility</label>
<div className="mt-2 flex gap-4">
<label
className={cn(
'flex cursor-pointer items-center gap-2 rounded-md border px-4 py-2',
'transition-colors',
!isPublic ? 'border-primary bg-primary/10' : 'border-input hover:bg-accent'
)}
>
<input
type="radio"
name="visibility"
checked={!isPublic}
onChange={() => setIsPublic(false)}
className="sr-only"
/>
<Lock className="h-4 w-4" />
<span className="text-sm">Private</span>
</label>
<label
className={cn(
'flex cursor-pointer items-center gap-2 rounded-md border px-4 py-2',
'transition-colors',
isPublic ? 'border-primary bg-primary/10' : 'border-input hover:bg-accent'
)}
>
<input
type="radio"
name="visibility"
checked={isPublic}
onChange={() => setIsPublic(true)}
className="sr-only"
/>
<Globe className="h-4 w-4" />
<span className="text-sm">Public</span>
</label>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{isPublic
? 'Anyone can view this tree'
: 'Only you and your team can view this tree'}
</p>
</div>
</div>
)
}