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:
64
frontend/src/components/common/TagBadges.tsx
Normal file
64
frontend/src/components/common/TagBadges.tsx
Normal 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
|
||||
230
frontend/src/components/common/TagInput.tsx
Normal file
230
frontend/src/components/common/TagInput.tsx
Normal 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
|
||||
Reference in New Issue
Block a user