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