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:
155
frontend/src/components/library/AddToFolderMenu.tsx
Normal file
155
frontend/src/components/library/AddToFolderMenu.tsx
Normal 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
|
||||
Reference in New Issue
Block a user