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

@@ -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>
)
}