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:
32
frontend/src/api/categories.ts
Normal file
32
frontend/src/api/categories.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import apiClient from './client'
|
||||
import type { Category, CategoryListItem, CategoryCreate, CategoryUpdate } from '@/types'
|
||||
|
||||
export const categoriesApi = {
|
||||
async list(includeInactive = false, teamOnly = false): Promise<CategoryListItem[]> {
|
||||
const response = await apiClient.get<CategoryListItem[]>('/categories', {
|
||||
params: { include_inactive: includeInactive, team_only: teamOnly },
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
async get(id: string): Promise<Category> {
|
||||
const response = await apiClient.get<Category>(`/categories/${id}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async create(data: CategoryCreate): Promise<Category> {
|
||||
const response = await apiClient.post<Category>('/categories', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async update(id: string, data: CategoryUpdate): Promise<Category> {
|
||||
const response = await apiClient.put<Category>(`/categories/${id}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await apiClient.delete(`/categories/${id}`)
|
||||
},
|
||||
}
|
||||
|
||||
export default categoriesApi
|
||||
50
frontend/src/api/folders.ts
Normal file
50
frontend/src/api/folders.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import apiClient from './client'
|
||||
import type { Folder, FolderListItem, FolderCreate, FolderUpdate, FolderReorderRequest } from '@/types'
|
||||
|
||||
export const foldersApi = {
|
||||
async list(): Promise<FolderListItem[]> {
|
||||
const response = await apiClient.get<FolderListItem[]>('/folders')
|
||||
return response.data
|
||||
},
|
||||
|
||||
async get(id: string): Promise<Folder> {
|
||||
const response = await apiClient.get<Folder>(`/folders/${id}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async create(data: FolderCreate): Promise<Folder> {
|
||||
const response = await apiClient.post<Folder>('/folders', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async update(id: string, data: FolderUpdate): Promise<Folder> {
|
||||
const response = await apiClient.put<Folder>(`/folders/${id}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await apiClient.delete(`/folders/${id}`)
|
||||
},
|
||||
|
||||
async reorder(folderIds: string[]): Promise<void> {
|
||||
await apiClient.post('/folders/reorder', {
|
||||
folder_ids: folderIds,
|
||||
} as FolderReorderRequest)
|
||||
},
|
||||
|
||||
// Folder tree management
|
||||
async getTreeIds(folderId: string): Promise<string[]> {
|
||||
const response = await apiClient.get<string[]>(`/folders/${folderId}/trees`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async addTree(folderId: string, treeId: string): Promise<void> {
|
||||
await apiClient.post(`/folders/${folderId}/trees`, { tree_id: treeId })
|
||||
},
|
||||
|
||||
async removeTree(folderId: string, treeId: string): Promise<void> {
|
||||
await apiClient.delete(`/folders/${folderId}/trees/${treeId}`)
|
||||
},
|
||||
}
|
||||
|
||||
export default foldersApi
|
||||
@@ -3,3 +3,6 @@ export { default as authApi } from './auth'
|
||||
export { default as treesApi } from './trees'
|
||||
export { default as sessionsApi } from './sessions'
|
||||
export { default as inviteApi } from './invite'
|
||||
export { default as tagsApi } from './tags'
|
||||
export { default as categoriesApi } from './categories'
|
||||
export { default as foldersApi } from './folders'
|
||||
|
||||
54
frontend/src/api/tags.ts
Normal file
54
frontend/src/api/tags.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import apiClient from './client'
|
||||
import type { Tag, TagListItem, TagCreate, TagAssignment } from '@/types'
|
||||
|
||||
export const tagsApi = {
|
||||
async list(includeTeam = true): Promise<TagListItem[]> {
|
||||
const response = await apiClient.get<TagListItem[]>('/tags', {
|
||||
params: { include_team: includeTeam },
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
async search(query: string, limit = 10, includeTeam = true): Promise<TagListItem[]> {
|
||||
const response = await apiClient.get<TagListItem[]>('/tags/search', {
|
||||
params: { q: query, limit, include_team: includeTeam },
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
async get(id: string): Promise<Tag> {
|
||||
const response = await apiClient.get<Tag>(`/tags/${id}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async create(data: TagCreate): Promise<Tag> {
|
||||
const response = await apiClient.post<Tag>('/tags', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Tree tag management
|
||||
async getTreeTags(treeId: string): Promise<TagListItem[]> {
|
||||
const response = await apiClient.get<TagListItem[]>(`/tags/trees/${treeId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async addTagsToTree(treeId: string, tags: string[]): Promise<TagListItem[]> {
|
||||
const response = await apiClient.post<TagListItem[]>(`/tags/trees/${treeId}`, {
|
||||
tags,
|
||||
} as TagAssignment)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async replaceTreeTags(treeId: string, tags: string[]): Promise<TagListItem[]> {
|
||||
const response = await apiClient.put<TagListItem[]>(`/tags/trees/${treeId}`, {
|
||||
tags,
|
||||
} as TagAssignment)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async removeTagFromTree(treeId: string, tagSlug: string): Promise<void> {
|
||||
await apiClient.delete(`/tags/trees/${treeId}/${tagSlug}`)
|
||||
},
|
||||
}
|
||||
|
||||
export default tagsApi
|
||||
@@ -1,23 +1,8 @@
|
||||
import apiClient from './client'
|
||||
import type { Tree, TreeListItem, TreeCreate, TreeUpdate } from '@/types'
|
||||
|
||||
export interface TreeListParams {
|
||||
page?: number
|
||||
size?: number
|
||||
category?: string
|
||||
include_inactive?: boolean
|
||||
}
|
||||
|
||||
export interface TreeListResponse {
|
||||
items: TreeListItem[]
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
pages: number
|
||||
}
|
||||
import type { Tree, TreeListItem, TreeCreate, TreeUpdate, TreeFilters } from '@/types'
|
||||
|
||||
export const treesApi = {
|
||||
async list(params?: TreeListParams): Promise<TreeListItem[]> {
|
||||
async list(params?: TreeFilters): Promise<TreeListItem[]> {
|
||||
const response = await apiClient.get<TreeListItem[]>('/trees', { params })
|
||||
return response.data
|
||||
},
|
||||
@@ -41,14 +26,15 @@ export const treesApi = {
|
||||
await apiClient.delete(`/trees/${id}`)
|
||||
},
|
||||
|
||||
async categories(): Promise<string[]> {
|
||||
// Legacy categories endpoint (returns string categories)
|
||||
async legacyCategories(): Promise<string[]> {
|
||||
const response = await apiClient.get<string[]>('/trees/categories')
|
||||
return response.data
|
||||
},
|
||||
|
||||
async search(query: string, category?: string): Promise<TreeListItem[]> {
|
||||
async search(query: string, limit?: number): Promise<TreeListItem[]> {
|
||||
const response = await apiClient.get<TreeListItem[]>('/trees/search', {
|
||||
params: { q: query, category },
|
||||
params: { q: query, limit },
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
484
frontend/src/components/library/FolderSidebar.tsx
Normal file
484
frontend/src/components/library/FolderSidebar.tsx
Normal file
@@ -0,0 +1,484 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Folder, ChevronDown, ChevronRight, Plus, MoreVertical, Pencil, Trash2, FolderPlus } from 'lucide-react'
|
||||
import { foldersApi } from '@/api'
|
||||
import type { FolderListItem, FolderTreeItem } from '@/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface FolderSidebarProps {
|
||||
selectedFolderId: string | null
|
||||
onFolderSelect: (folderId: string | null) => void
|
||||
onCreateFolder: (parentId?: string | null) => void
|
||||
onEditFolder: (folder: FolderListItem) => void
|
||||
}
|
||||
|
||||
// Build tree structure from flat folder list
|
||||
function buildFolderTree(folders: FolderListItem[]): FolderTreeItem[] {
|
||||
const folderMap = new Map<string, FolderTreeItem>()
|
||||
const rootFolders: FolderTreeItem[] = []
|
||||
|
||||
// First pass: create all folder items
|
||||
folders.forEach((folder) => {
|
||||
folderMap.set(folder.id, {
|
||||
...folder,
|
||||
children: [],
|
||||
isExpanded: true, // Default expanded
|
||||
})
|
||||
})
|
||||
|
||||
// Second pass: build parent-child relationships
|
||||
folders.forEach((folder) => {
|
||||
const treeItem = folderMap.get(folder.id)!
|
||||
if (folder.parent_id && folderMap.has(folder.parent_id)) {
|
||||
const parent = folderMap.get(folder.parent_id)!
|
||||
parent.children.push(treeItem)
|
||||
} else {
|
||||
rootFolders.push(treeItem)
|
||||
}
|
||||
})
|
||||
|
||||
// Sort children by display_order
|
||||
const sortChildren = (items: FolderTreeItem[]) => {
|
||||
items.sort((a, b) => a.display_order - b.display_order)
|
||||
items.forEach((item) => sortChildren(item.children))
|
||||
}
|
||||
sortChildren(rootFolders)
|
||||
|
||||
return rootFolders
|
||||
}
|
||||
|
||||
// Calculate folder depth (for limiting nesting)
|
||||
function getFolderDepth(folders: FolderListItem[], folderId: string): number {
|
||||
const folder = folders.find((f) => f.id === folderId)
|
||||
if (!folder || !folder.parent_id) return 1
|
||||
return 1 + getFolderDepth(folders, folder.parent_id)
|
||||
}
|
||||
|
||||
// Check if folder has children
|
||||
function hasChildren(folders: FolderListItem[], folderId: string): boolean {
|
||||
return folders.some((f) => f.parent_id === folderId)
|
||||
}
|
||||
|
||||
// Get all descendant IDs (for cascade delete warning)
|
||||
function getDescendantIds(folders: FolderListItem[], folderId: string): string[] {
|
||||
const children = folders.filter((f) => f.parent_id === folderId)
|
||||
const descendantIds: string[] = []
|
||||
children.forEach((child) => {
|
||||
descendantIds.push(child.id)
|
||||
descendantIds.push(...getDescendantIds(folders, child.id))
|
||||
})
|
||||
return descendantIds
|
||||
}
|
||||
|
||||
interface ContextMenuState {
|
||||
x: number
|
||||
y: number
|
||||
folder: FolderTreeItem
|
||||
canAddSubfolder: boolean
|
||||
}
|
||||
|
||||
interface FolderItemProps {
|
||||
folder: FolderTreeItem
|
||||
depth: number
|
||||
selectedFolderId: string | null
|
||||
expandedIds: Set<string>
|
||||
menuOpenId: string | null
|
||||
onFolderSelect: (folderId: string) => void
|
||||
onToggleExpand: (folderId: string) => void
|
||||
onMenuToggle: (folderId: string | null) => void
|
||||
onEditFolder: (folder: FolderListItem) => void
|
||||
onAddSubfolder: (parentId: string) => void
|
||||
onDeleteFolder: (folderId: string, hasChildren: boolean) => void
|
||||
onContextMenu: (e: React.MouseEvent, folder: FolderTreeItem, canAddSubfolder: boolean) => void
|
||||
canAddSubfolder: boolean
|
||||
}
|
||||
|
||||
function FolderItem({
|
||||
folder,
|
||||
depth,
|
||||
selectedFolderId,
|
||||
expandedIds,
|
||||
menuOpenId,
|
||||
onFolderSelect,
|
||||
onToggleExpand,
|
||||
onMenuToggle,
|
||||
onEditFolder,
|
||||
onAddSubfolder,
|
||||
onDeleteFolder,
|
||||
onContextMenu,
|
||||
canAddSubfolder,
|
||||
}: FolderItemProps) {
|
||||
const isExpanded = expandedIds.has(folder.id)
|
||||
const hasSubfolders = folder.children.length > 0
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onContextMenu(e, folder, canAddSubfolder)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="group relative" onContextMenu={handleContextMenu}>
|
||||
<button
|
||||
onClick={() => onFolderSelect(folder.id)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-1 rounded-md py-1.5 text-sm',
|
||||
'transition-colors hover:bg-accent',
|
||||
selectedFolderId === folder.id && 'bg-accent font-medium'
|
||||
)}
|
||||
style={{ paddingLeft: `${8 + depth * 16}px`, paddingRight: '8px' }}
|
||||
>
|
||||
{/* Expand/collapse toggle */}
|
||||
{hasSubfolders ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggleExpand(folder.id)
|
||||
}}
|
||||
className="shrink-0 p-0.5 hover:bg-accent rounded"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-4" /> // Spacer for alignment
|
||||
)}
|
||||
<Folder className="h-4 w-4 shrink-0" style={{ color: folder.color }} />
|
||||
<span className="flex-1 truncate text-left">{folder.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{folder.tree_count}</span>
|
||||
</button>
|
||||
|
||||
{/* Folder menu button */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onMenuToggle(menuOpenId === folder.id ? null : folder.id)
|
||||
}}
|
||||
className={cn(
|
||||
'absolute right-1 top-1/2 -translate-y-1/2 rounded p-1',
|
||||
'opacity-0 group-hover:opacity-100',
|
||||
'hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
<MoreVertical className="h-3 w-3" />
|
||||
</button>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
{menuOpenId === folder.id && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute right-0 top-full z-10 mt-1 w-40 rounded-md border border-input',
|
||||
'bg-popover py-1 shadow-lg'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onEditFolder(folder)
|
||||
onMenuToggle(null)
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
Edit
|
||||
</button>
|
||||
{canAddSubfolder && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onAddSubfolder(folder.id)
|
||||
onMenuToggle(null)
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent"
|
||||
>
|
||||
<FolderPlus className="h-3 w-3" />
|
||||
Add Subfolder
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDeleteFolder(folder.id, hasSubfolders)
|
||||
onMenuToggle(null)
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-sm text-destructive hover:bg-accent"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Children */}
|
||||
{isExpanded && hasSubfolders && (
|
||||
<div>
|
||||
{folder.children.map((child) => (
|
||||
<FolderItem
|
||||
key={child.id}
|
||||
folder={child}
|
||||
depth={depth + 1}
|
||||
selectedFolderId={selectedFolderId}
|
||||
expandedIds={expandedIds}
|
||||
menuOpenId={menuOpenId}
|
||||
onFolderSelect={onFolderSelect}
|
||||
onToggleExpand={onToggleExpand}
|
||||
onMenuToggle={onMenuToggle}
|
||||
onEditFolder={onEditFolder}
|
||||
onAddSubfolder={onAddSubfolder}
|
||||
onDeleteFolder={onDeleteFolder}
|
||||
onContextMenu={onContextMenu}
|
||||
canAddSubfolder={depth + 1 < 2} // Max depth is 3, so can add at depth 0, 1 (not 2)
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FolderSidebar({
|
||||
selectedFolderId,
|
||||
onFolderSelect,
|
||||
onCreateFolder,
|
||||
onEditFolder,
|
||||
}: FolderSidebarProps) {
|
||||
const [folders, setFolders] = useState<FolderListItem[]>([])
|
||||
const [folderTree, setFolderTree] = useState<FolderTreeItem[]>([])
|
||||
const [isExpanded, setIsExpanded] = useState(true)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [menuOpenId, setMenuOpenId] = useState<string | null>(null)
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
|
||||
|
||||
const loadFolders = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const data = await foldersApi.list()
|
||||
setFolders(data)
|
||||
setFolderTree(buildFolderTree(data))
|
||||
// Expand all by default
|
||||
setExpandedIds(new Set(data.map((f) => f.id)))
|
||||
} catch (err) {
|
||||
console.error('Failed to load folders:', err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadFolders()
|
||||
}, [loadFolders])
|
||||
|
||||
const handleToggleExpand = (folderId: string) => {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(folderId)) {
|
||||
next.delete(folderId)
|
||||
} else {
|
||||
next.add(folderId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeleteFolder = async (folderId: string, folderHasChildren: boolean) => {
|
||||
const descendantCount = getDescendantIds(folders, folderId).length
|
||||
const message = folderHasChildren
|
||||
? `Are you sure you want to delete this folder and its ${descendantCount} subfolder(s)? The trees in them will not be deleted.`
|
||||
: 'Are you sure you want to delete this folder? The trees in it will not be deleted.'
|
||||
|
||||
if (!confirm(message)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await foldersApi.delete(folderId)
|
||||
// Remove folder and all descendants from local state
|
||||
const idsToRemove = new Set([folderId, ...getDescendantIds(folders, folderId)])
|
||||
const updatedFolders = folders.filter((f) => !idsToRemove.has(f.id))
|
||||
setFolders(updatedFolders)
|
||||
setFolderTree(buildFolderTree(updatedFolders))
|
||||
if (selectedFolderId && idsToRemove.has(selectedFolderId)) {
|
||||
onFolderSelect(null)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete folder:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddSubfolder = (parentId: string) => {
|
||||
onCreateFolder(parentId)
|
||||
}
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent, folder: FolderTreeItem, canAddSubfolder: boolean) => {
|
||||
setContextMenu({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
folder,
|
||||
canAddSubfolder,
|
||||
})
|
||||
setMenuOpenId(null) // Close any open hover menu
|
||||
}
|
||||
|
||||
const closeContextMenu = () => {
|
||||
setContextMenu(null)
|
||||
}
|
||||
|
||||
// Refresh folders when a folder is edited or created
|
||||
useEffect(() => {
|
||||
const handleFolderChange = () => loadFolders()
|
||||
window.addEventListener('folder-changed', handleFolderChange)
|
||||
return () => window.removeEventListener('folder-changed', handleFolderChange)
|
||||
}, [loadFolders])
|
||||
|
||||
// Close hover menu when clicking outside
|
||||
useEffect(() => {
|
||||
if (!menuOpenId) return
|
||||
const handleClickOutside = () => setMenuOpenId(null)
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
return () => document.removeEventListener('click', handleClickOutside)
|
||||
}, [menuOpenId])
|
||||
|
||||
// Close context menu on click outside, right-click elsewhere, or Escape
|
||||
useEffect(() => {
|
||||
if (!contextMenu) return
|
||||
const close = () => setContextMenu(null)
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') close()
|
||||
}
|
||||
document.addEventListener('click', close)
|
||||
document.addEventListener('contextmenu', close)
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('click', close)
|
||||
document.removeEventListener('contextmenu', close)
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [contextMenu])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-56 shrink-0 border-r border-border bg-card">
|
||||
<div className="p-4">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="flex w-full items-center gap-2 text-sm font-medium text-card-foreground"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
<span>FOLDERS</span>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-3 space-y-0.5">
|
||||
{/* All Trees */}
|
||||
<button
|
||||
onClick={() => onFolderSelect(null)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm',
|
||||
'transition-colors hover:bg-accent',
|
||||
selectedFolderId === null && 'bg-accent font-medium'
|
||||
)}
|
||||
>
|
||||
<Folder className="h-4 w-4" />
|
||||
<span>All Trees</span>
|
||||
</button>
|
||||
|
||||
{/* Loading state */}
|
||||
{isLoading ? (
|
||||
<div className="px-2 py-1.5 text-sm text-muted-foreground">Loading...</div>
|
||||
) : (
|
||||
<>
|
||||
{/* User folders (hierarchical) */}
|
||||
{folderTree.map((folder) => (
|
||||
<FolderItem
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
depth={0}
|
||||
selectedFolderId={selectedFolderId}
|
||||
expandedIds={expandedIds}
|
||||
menuOpenId={menuOpenId}
|
||||
onFolderSelect={onFolderSelect}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
onMenuToggle={setMenuOpenId}
|
||||
onEditFolder={onEditFolder}
|
||||
onAddSubfolder={handleAddSubfolder}
|
||||
onDeleteFolder={handleDeleteFolder}
|
||||
onContextMenu={handleContextMenu}
|
||||
canAddSubfolder={true} // Root folders can have subfolders
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Create folder button */}
|
||||
<button
|
||||
onClick={() => onCreateFolder(null)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm',
|
||||
'text-muted-foreground transition-colors hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>New Folder</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right-click context menu */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed z-50 w-44 rounded-md border border-input',
|
||||
'bg-popover py-1 shadow-lg'
|
||||
)}
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
onEditFolder(contextMenu.folder)
|
||||
closeContextMenu()
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
Edit
|
||||
</button>
|
||||
{contextMenu.canAddSubfolder && (
|
||||
<button
|
||||
onClick={() => {
|
||||
handleAddSubfolder(contextMenu.folder.id)
|
||||
closeContextMenu()
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent"
|
||||
>
|
||||
<FolderPlus className="h-3 w-3" />
|
||||
Add Subfolder
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
handleDeleteFolder(contextMenu.folder.id, contextMenu.folder.children.length > 0)
|
||||
closeContextMenu()
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-sm text-destructive hover:bg-accent"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FolderSidebar
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -138,12 +138,14 @@ export function TreeEditorPage() {
|
||||
const treeData = getTreeForSave()
|
||||
if (isEditMode) {
|
||||
await treesApi.update(id!, treeData as TreeUpdate)
|
||||
markSaved()
|
||||
} else {
|
||||
const newTree = await treesApi.create(treeData as TreeCreate)
|
||||
// Mark saved BEFORE navigating to avoid triggering the blocker
|
||||
markSaved()
|
||||
// Navigate to edit mode with the new ID
|
||||
navigate(`/trees/${newTree.id}/edit`, { replace: true })
|
||||
}
|
||||
markSaved()
|
||||
} catch (err) {
|
||||
console.error('Failed to save tree:', err)
|
||||
setSaveError('Failed to save tree. Please try again.')
|
||||
|
||||
@@ -1,30 +1,63 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { Plus, Pencil } from 'lucide-react'
|
||||
import { treesApi } from '@/api'
|
||||
import type { TreeListItem } from '@/types'
|
||||
import { Plus, Pencil, Globe, Lock, X } from 'lucide-react'
|
||||
import { treesApi, categoriesApi, foldersApi } from '@/api'
|
||||
import type { TreeListItem, CategoryListItem, FolderListItem } from '@/types'
|
||||
import { TagBadges } from '@/components/common/TagBadges'
|
||||
import { FolderSidebar } from '@/components/library/FolderSidebar'
|
||||
import { FolderEditModal } from '@/components/library/FolderEditModal'
|
||||
import { AddToFolderMenu } from '@/components/library/AddToFolderMenu'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function TreeLibraryPage() {
|
||||
const navigate = useNavigate()
|
||||
const [trees, setTrees] = useState<TreeListItem[]>([])
|
||||
const [categories, setCategories] = useState<string[]>([])
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('')
|
||||
const [categories, setCategories] = useState<CategoryListItem[]>([])
|
||||
const [folders, setFolders] = useState<FolderListItem[]>([])
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<string>('')
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([])
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Folder modal state
|
||||
const [folderModalOpen, setFolderModalOpen] = useState(false)
|
||||
const [editingFolder, setEditingFolder] = useState<FolderListItem | null>(null)
|
||||
const [newFolderParentId, setNewFolderParentId] = useState<string | null>(null)
|
||||
|
||||
const loadFolders = useCallback(async () => {
|
||||
try {
|
||||
const foldersData = await foldersApi.list()
|
||||
setFolders(foldersData)
|
||||
} catch (err) {
|
||||
console.error('Failed to load folders:', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [selectedCategory])
|
||||
}, [selectedCategoryId, selectedTags, selectedFolderId])
|
||||
|
||||
// Load folders on mount and listen for changes
|
||||
useEffect(() => {
|
||||
loadFolders()
|
||||
const handleFolderChange = () => loadFolders()
|
||||
window.addEventListener('folder-changed', handleFolderChange)
|
||||
return () => window.removeEventListener('folder-changed', handleFolderChange)
|
||||
}, [loadFolders])
|
||||
|
||||
const loadData = async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [treesData, categoriesData] = await Promise.all([
|
||||
treesApi.list({ category: selectedCategory || undefined }),
|
||||
treesApi.categories(),
|
||||
treesApi.list({
|
||||
category_id: selectedCategoryId || undefined,
|
||||
tags: selectedTags.length > 0 ? selectedTags.join(',') : undefined,
|
||||
folder_id: selectedFolderId || undefined,
|
||||
}),
|
||||
categoriesApi.list(),
|
||||
])
|
||||
setTrees(treesData)
|
||||
setCategories(categoriesData)
|
||||
@@ -44,7 +77,7 @@ export function TreeLibraryPage() {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const results = await treesApi.search(searchQuery, selectedCategory || undefined)
|
||||
const results = await treesApi.search(searchQuery)
|
||||
setTrees(results)
|
||||
} catch (err) {
|
||||
setError('Search failed')
|
||||
@@ -54,140 +87,262 @@ export function TreeLibraryPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleTagClick = (tag: string) => {
|
||||
if (!selectedTags.includes(tag)) {
|
||||
setSelectedTags([...selectedTags, tag])
|
||||
}
|
||||
}
|
||||
|
||||
const removeTagFilter = (tag: string) => {
|
||||
setSelectedTags(selectedTags.filter((t) => t !== tag))
|
||||
}
|
||||
|
||||
const clearAllFilters = () => {
|
||||
setSelectedCategoryId('')
|
||||
setSelectedTags([])
|
||||
setSelectedFolderId(null)
|
||||
setSearchQuery('')
|
||||
}
|
||||
|
||||
const handleStartSession = (treeId: string) => {
|
||||
navigate(`/trees/${treeId}/navigate`)
|
||||
}
|
||||
|
||||
const handleCreateFolder = (parentId?: string | null) => {
|
||||
setEditingFolder(null)
|
||||
setNewFolderParentId(parentId || null)
|
||||
setFolderModalOpen(true)
|
||||
}
|
||||
|
||||
const handleEditFolder = (folder: FolderListItem) => {
|
||||
setEditingFolder(folder)
|
||||
setNewFolderParentId(null)
|
||||
setFolderModalOpen(true)
|
||||
}
|
||||
|
||||
const hasActiveFilters =
|
||||
selectedCategoryId || selectedTags.length > 0 || searchQuery || selectedFolderId
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8 flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Decision Trees</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Select a troubleshooting tree to start a new session
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/trees/new"
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
|
||||
'hover:bg-primary/90'
|
||||
)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Tree
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex h-[calc(100vh-4rem)]">
|
||||
{/* Folder Sidebar */}
|
||||
<FolderSidebar
|
||||
selectedFolderId={selectedFolderId}
|
||||
onFolderSelect={setSelectedFolderId}
|
||||
onCreateFolder={handleCreateFolder}
|
||||
onEditFolder={handleEditFolder}
|
||||
/>
|
||||
|
||||
{/* Search and Filter */}
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row">
|
||||
<div className="flex flex-1 gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search trees..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className={cn(
|
||||
'flex-1 rounded-md border border-input bg-background px-3 py-2',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
className={cn(
|
||||
'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
|
||||
'hover:bg-primary/90'
|
||||
)}
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
aria-label="Filter by category"
|
||||
className={cn(
|
||||
'rounded-md border border-input bg-background px-3 py-2',
|
||||
'text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||
)}
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat} value={cat}>
|
||||
{cat}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<div className="mb-6 rounded-md bg-destructive/10 p-4 text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
) : trees.length === 0 ? (
|
||||
<div className="py-12 text-center text-muted-foreground">
|
||||
No trees found. {searchQuery && 'Try adjusting your search.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{trees.map((tree) => (
|
||||
<div
|
||||
key={tree.id}
|
||||
className="rounded-lg border border-border bg-card p-6 shadow-sm transition-shadow hover:shadow-md"
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between">
|
||||
<h3 className="font-semibold text-card-foreground">{tree.name}</h3>
|
||||
{tree.category && (
|
||||
<span className="rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground">
|
||||
{tree.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mb-4 text-sm text-muted-foreground line-clamp-2">
|
||||
{tree.description || 'No description available'}
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8 flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Decision Trees</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Select a troubleshooting tree to start a new session
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
v{tree.version} · {tree.usage_count} uses
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
to={`/trees/${tree.id}/edit`}
|
||||
className={cn(
|
||||
'rounded-md border border-input p-1.5 text-muted-foreground',
|
||||
'hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
title="Edit tree"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleStartSession(tree.id)}
|
||||
className={cn(
|
||||
'rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground',
|
||||
'hover:bg-primary/90'
|
||||
)}
|
||||
>
|
||||
Start Session
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Link
|
||||
to="/trees/new"
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
|
||||
'hover:bg-primary/90'
|
||||
)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Tree
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Search and Filter */}
|
||||
<div className="mb-4 flex flex-col gap-4 sm:flex-row">
|
||||
<div className="flex flex-1 gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search trees..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className={cn(
|
||||
'flex-1 rounded-md border border-input bg-background px-3 py-2',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
className={cn(
|
||||
'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
|
||||
'hover:bg-primary/90'
|
||||
)}
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={selectedCategoryId}
|
||||
onChange={(e) => setSelectedCategoryId(e.target.value)}
|
||||
aria-label="Filter by category"
|
||||
className={cn(
|
||||
'rounded-md border border-input bg-background px-3 py-2',
|
||||
'text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||
)}
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.name} ({cat.tree_count})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Active Filters */}
|
||||
{hasActiveFilters && (
|
||||
<div className="mb-6 flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Filters:</span>
|
||||
{selectedFolderId && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-accent px-3 py-1 text-sm">
|
||||
Folder
|
||||
<button
|
||||
onClick={() => setSelectedFolderId(null)}
|
||||
className="rounded-full p-0.5 hover:bg-accent-foreground/10"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{selectedCategoryId && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-secondary px-3 py-1 text-sm">
|
||||
{categories.find((c) => c.id === selectedCategoryId)?.name}
|
||||
<button
|
||||
onClick={() => setSelectedCategoryId('')}
|
||||
className="rounded-full p-0.5 hover:bg-secondary-foreground/10"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{selectedTags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-3 py-1 text-sm text-primary"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
onClick={() => removeTagFilter(tag)}
|
||||
className="rounded-full p-0.5 hover:bg-primary/20"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<button
|
||||
onClick={clearAllFilters}
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<div className="mb-6 rounded-md bg-destructive/10 p-4 text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
) : trees.length === 0 ? (
|
||||
<div className="py-12 text-center text-muted-foreground">
|
||||
No trees found.{' '}
|
||||
{(searchQuery || hasActiveFilters) && 'Try adjusting your filters.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{trees.map((tree) => (
|
||||
<div
|
||||
key={tree.id}
|
||||
className="rounded-lg border border-border bg-card p-6 shadow-sm transition-shadow hover:shadow-md"
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<h3 className="font-semibold text-card-foreground">{tree.name}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{tree.is_public ? (
|
||||
<Globe className="h-4 w-4 text-muted-foreground" title="Public tree" />
|
||||
) : (
|
||||
<Lock className="h-4 w-4 text-muted-foreground" title="Private tree" />
|
||||
)}
|
||||
{tree.category_info && (
|
||||
<span className="rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground">
|
||||
{tree.category_info.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mb-3 text-sm text-muted-foreground line-clamp-2">
|
||||
{tree.description || 'No description available'}
|
||||
</p>
|
||||
|
||||
{/* Tags */}
|
||||
{tree.tags && tree.tags.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<TagBadges tags={tree.tags} maxVisible={3} onTagClick={handleTagClick} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
v{tree.version} · {tree.usage_count} uses
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<AddToFolderMenu treeId={tree.id} onFolderCreated={handleCreateFolder} />
|
||||
<Link
|
||||
to={`/trees/${tree.id}/edit`}
|
||||
className={cn(
|
||||
'rounded-md border border-input p-1.5 text-muted-foreground',
|
||||
'hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
title="Edit tree"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleStartSession(tree.id)}
|
||||
className={cn(
|
||||
'rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground',
|
||||
'hover:bg-primary/90'
|
||||
)}
|
||||
>
|
||||
Start Session
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Folder Edit Modal */}
|
||||
<FolderEditModal
|
||||
folder={editingFolder}
|
||||
parentId={newFolderParentId}
|
||||
folders={folders}
|
||||
isOpen={folderModalOpen}
|
||||
onClose={() => {
|
||||
setFolderModalOpen(false)
|
||||
setNewFolderParentId(null)
|
||||
}}
|
||||
onSave={loadData}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -101,6 +101,9 @@ interface TreeEditorState {
|
||||
name: string
|
||||
description: string
|
||||
category: string
|
||||
categoryId: string | null
|
||||
tags: string[]
|
||||
isPublic: boolean
|
||||
treeStructure: TreeStructure | null
|
||||
originalTree: Tree | null // For comparison in edit mode
|
||||
|
||||
@@ -127,6 +130,11 @@ interface TreeEditorState {
|
||||
setName: (name: string) => void
|
||||
setDescription: (description: string) => void
|
||||
setCategory: (category: string) => void
|
||||
setCategoryId: (categoryId: string | null) => void
|
||||
setTags: (tags: string[]) => void
|
||||
addTag: (tag: string) => void
|
||||
removeTag: (tag: string) => void
|
||||
setIsPublic: (isPublic: boolean) => void
|
||||
|
||||
// Actions - Node CRUD
|
||||
addNode: (parentId: string | null, type: NodeType, insertIndex?: number) => string
|
||||
@@ -169,6 +177,9 @@ export const useTreeEditorStore = create<TreeEditorState>()(
|
||||
name: '',
|
||||
description: '',
|
||||
category: '',
|
||||
categoryId: null,
|
||||
tags: [],
|
||||
isPublic: false,
|
||||
treeStructure: null,
|
||||
originalTree: null,
|
||||
selectedNodeId: null,
|
||||
@@ -188,6 +199,9 @@ export const useTreeEditorStore = create<TreeEditorState>()(
|
||||
state.name = ''
|
||||
state.description = ''
|
||||
state.category = ''
|
||||
state.categoryId = null
|
||||
state.tags = []
|
||||
state.isPublic = false
|
||||
state.treeStructure = {
|
||||
id: 'root',
|
||||
type: 'decision',
|
||||
@@ -213,6 +227,9 @@ export const useTreeEditorStore = create<TreeEditorState>()(
|
||||
state.name = tree.name
|
||||
state.description = tree.description || ''
|
||||
state.category = tree.category || ''
|
||||
state.categoryId = tree.category_id || null
|
||||
state.tags = tree.tags || []
|
||||
state.isPublic = tree.is_public || false
|
||||
state.treeStructure = tree.tree_structure
|
||||
state.originalTree = tree
|
||||
state.selectedNodeId = tree.tree_structure?.id || null
|
||||
@@ -236,6 +253,9 @@ export const useTreeEditorStore = create<TreeEditorState>()(
|
||||
state.name = draft.name || ''
|
||||
state.description = draft.description || ''
|
||||
state.category = draft.category || ''
|
||||
state.categoryId = draft.categoryId || null
|
||||
state.tags = draft.tags || []
|
||||
state.isPublic = draft.isPublic || false
|
||||
state.treeStructure = draft.treeStructure || null
|
||||
state.isDirty = true
|
||||
state.draftSavedAt = draft.savedAt ? new Date(draft.savedAt) : null
|
||||
@@ -261,6 +281,9 @@ export const useTreeEditorStore = create<TreeEditorState>()(
|
||||
state.name = ''
|
||||
state.description = ''
|
||||
state.category = ''
|
||||
state.categoryId = null
|
||||
state.tags = []
|
||||
state.isPublic = false
|
||||
state.treeStructure = null
|
||||
state.originalTree = null
|
||||
state.selectedNodeId = null
|
||||
@@ -299,6 +322,48 @@ export const useTreeEditorStore = create<TreeEditorState>()(
|
||||
get().autoSaveDraft()
|
||||
},
|
||||
|
||||
setCategoryId: (categoryId: string | null) => {
|
||||
set((state) => {
|
||||
state.categoryId = categoryId
|
||||
state.isDirty = true
|
||||
})
|
||||
get().autoSaveDraft()
|
||||
},
|
||||
|
||||
setTags: (tags: string[]) => {
|
||||
set((state) => {
|
||||
state.tags = tags
|
||||
state.isDirty = true
|
||||
})
|
||||
get().autoSaveDraft()
|
||||
},
|
||||
|
||||
addTag: (tag: string) => {
|
||||
set((state) => {
|
||||
if (!state.tags.includes(tag)) {
|
||||
state.tags.push(tag)
|
||||
state.isDirty = true
|
||||
}
|
||||
})
|
||||
get().autoSaveDraft()
|
||||
},
|
||||
|
||||
removeTag: (tag: string) => {
|
||||
set((state) => {
|
||||
state.tags = state.tags.filter(t => t !== tag)
|
||||
state.isDirty = true
|
||||
})
|
||||
get().autoSaveDraft()
|
||||
},
|
||||
|
||||
setIsPublic: (isPublic: boolean) => {
|
||||
set((state) => {
|
||||
state.isPublic = isPublic
|
||||
state.isDirty = true
|
||||
})
|
||||
get().autoSaveDraft()
|
||||
},
|
||||
|
||||
// Node CRUD
|
||||
addNode: (parentId: string | null, type: NodeType, insertIndex?: number) => {
|
||||
const newId = generateId()
|
||||
@@ -605,6 +670,9 @@ export const useTreeEditorStore = create<TreeEditorState>()(
|
||||
name: state.name,
|
||||
description: state.description,
|
||||
category: state.category,
|
||||
categoryId: state.categoryId,
|
||||
tags: state.tags,
|
||||
isPublic: state.isPublic,
|
||||
treeStructure: state.treeStructure,
|
||||
savedAt: new Date().toISOString()
|
||||
}
|
||||
@@ -628,6 +696,9 @@ export const useTreeEditorStore = create<TreeEditorState>()(
|
||||
name: state.name,
|
||||
description: state.description || undefined,
|
||||
category: state.category || undefined,
|
||||
category_id: state.categoryId || undefined,
|
||||
tags: state.tags.length > 0 ? state.tags : undefined,
|
||||
is_public: state.isPublic,
|
||||
tree_structure: state.treeStructure!
|
||||
}
|
||||
},
|
||||
@@ -694,6 +765,9 @@ export const useTreeEditorStore = create<TreeEditorState>()(
|
||||
name: state.name,
|
||||
description: state.description,
|
||||
category: state.category,
|
||||
categoryId: state.categoryId,
|
||||
tags: state.tags,
|
||||
isPublic: state.isPublic,
|
||||
treeStructure: state.treeStructure
|
||||
})
|
||||
}
|
||||
|
||||
45
frontend/src/types/category.ts
Normal file
45
frontend/src/types/category.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// Category types for tree organization
|
||||
|
||||
export interface Category {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
description: string | null
|
||||
team_id: string | null
|
||||
display_order: number
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
tree_count: number
|
||||
}
|
||||
|
||||
export interface CategoryListItem {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
description: string | null
|
||||
team_id: string | null
|
||||
display_order: number
|
||||
is_active: boolean
|
||||
tree_count: number
|
||||
}
|
||||
|
||||
export interface CategoryCreate {
|
||||
name: string
|
||||
description?: string | null
|
||||
team_id?: string | null
|
||||
}
|
||||
|
||||
export interface CategoryUpdate {
|
||||
name?: string
|
||||
description?: string | null
|
||||
display_order?: number
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
// Embedded category info for tree responses
|
||||
export interface CategoryInfo {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
52
frontend/src/types/folder.ts
Normal file
52
frontend/src/types/folder.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// Folder types for user tree organization
|
||||
|
||||
export interface Folder {
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
icon: string
|
||||
parent_id: string | null
|
||||
display_order: number
|
||||
tree_count: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface FolderListItem {
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
icon: string
|
||||
parent_id: string | null
|
||||
display_order: number
|
||||
tree_count: number
|
||||
}
|
||||
|
||||
export interface FolderCreate {
|
||||
name: string
|
||||
color?: string
|
||||
icon?: string
|
||||
parent_id?: string | null
|
||||
}
|
||||
|
||||
export interface FolderUpdate {
|
||||
name?: string
|
||||
color?: string
|
||||
icon?: string
|
||||
display_order?: number
|
||||
parent_id?: string | null
|
||||
}
|
||||
|
||||
export interface FolderReorderRequest {
|
||||
folder_ids: string[]
|
||||
}
|
||||
|
||||
export interface FolderTreeRequest {
|
||||
tree_id: string
|
||||
}
|
||||
|
||||
// For hierarchical display of folders
|
||||
export interface FolderTreeItem extends FolderListItem {
|
||||
children: FolderTreeItem[]
|
||||
isExpanded?: boolean
|
||||
}
|
||||
@@ -3,6 +3,9 @@ export * from './auth'
|
||||
export * from './tree'
|
||||
export * from './session'
|
||||
export * from './invite'
|
||||
export * from './tag'
|
||||
export * from './category'
|
||||
export * from './folder'
|
||||
|
||||
// API response wrapper types
|
||||
export interface PaginatedResponse<T> {
|
||||
|
||||
27
frontend/src/types/tag.ts
Normal file
27
frontend/src/types/tag.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// Tag types for tree organization
|
||||
|
||||
export interface Tag {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
team_id: string | null
|
||||
usage_count: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface TagListItem {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
team_id: string | null
|
||||
usage_count: number
|
||||
}
|
||||
|
||||
export interface TagCreate {
|
||||
name: string
|
||||
team_id?: string | null
|
||||
}
|
||||
|
||||
export interface TagAssignment {
|
||||
tags: string[]
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { CategoryInfo } from './category'
|
||||
|
||||
// Tree node types
|
||||
export type NodeType = 'decision' | 'action' | 'solution'
|
||||
|
||||
@@ -60,10 +62,15 @@ export interface Tree {
|
||||
name: string
|
||||
description: string | null
|
||||
category: string | null
|
||||
category_id: string | null
|
||||
category_info: CategoryInfo | null
|
||||
tags: string[]
|
||||
tree_structure: TreeStructure
|
||||
author_id: string | null
|
||||
team_id: string | null
|
||||
is_active: boolean
|
||||
is_public: boolean
|
||||
is_default: boolean
|
||||
version: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
@@ -75,7 +82,13 @@ export interface TreeListItem {
|
||||
name: string
|
||||
description: string | null
|
||||
category: string | null
|
||||
category_id: string | null
|
||||
category_info: CategoryInfo | null
|
||||
tags: string[]
|
||||
author_id: string | null
|
||||
is_active: boolean
|
||||
is_public: boolean
|
||||
is_default: boolean
|
||||
version: number
|
||||
usage_count: number
|
||||
created_at: string
|
||||
@@ -86,13 +99,33 @@ export interface TreeCreate {
|
||||
name: string
|
||||
description?: string
|
||||
category?: string
|
||||
category_id?: string | null
|
||||
tags?: string[]
|
||||
tree_structure: TreeStructure
|
||||
is_public?: boolean
|
||||
is_default?: boolean
|
||||
}
|
||||
|
||||
export interface TreeUpdate {
|
||||
name?: string
|
||||
description?: string
|
||||
category?: string
|
||||
category_id?: string | null
|
||||
tags?: string[]
|
||||
tree_structure?: TreeStructure
|
||||
is_active?: boolean
|
||||
is_public?: boolean
|
||||
}
|
||||
|
||||
// Filter params for tree listing
|
||||
export interface TreeFilters {
|
||||
category?: string
|
||||
category_id?: string
|
||||
tags?: string
|
||||
folder_id?: string
|
||||
is_active?: boolean
|
||||
author_id?: string
|
||||
is_public?: boolean
|
||||
skip?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface User {
|
||||
email: string
|
||||
name: string
|
||||
role: UserRole
|
||||
is_team_admin: boolean
|
||||
team_id: string | null
|
||||
created_at: string
|
||||
last_login: string | null
|
||||
|
||||
Reference in New Issue
Block a user