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