Remove the old /ai/chat page, AI wizard modal, and all associated components/stores/types now replaced by the editor-embedded AI panel. Deleted: - AIChatBuilderPage, ai-chat/ components, aiChatStore, aiChat API, ai-chat types - AIFlowBuilderModal, ai-builder/ components, aiFlowBuilderStore Cleaned up: - Router (removed /ai/chat route) - Sidebar (removed Flow Assist nav item) - MyTreesPage (removed AI builder modal and button) - TreeLibraryPage (removed Flow Assist button) - API and type barrel exports Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
608 lines
23 KiB
TypeScript
608 lines
23 KiB
TypeScript
import { useEffect, useState, useCallback, useMemo } from 'react'
|
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
|
import { X, RotateCcw, Play, FileUp } from 'lucide-react'
|
|
import { treesApi } from '@/api/trees'
|
|
import { categoriesApi } from '@/api/categories'
|
|
import { foldersApi } from '@/api/folders'
|
|
import { sessionsApi } from '@/api/sessions'
|
|
import type { TreeListItem, CategoryListItem, FolderListItem, Session } from '@/types'
|
|
import { FolderEditModal } from '@/components/library/FolderEditModal'
|
|
import { ForkModal } from '@/components/library/ForkModal'
|
|
import { ExportFlowModal } from '@/components/library/ExportFlowModal'
|
|
import { ImportFlowModal } from '@/components/library/ImportFlowModal'
|
|
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
|
|
import { TreeGridView } from '@/components/library/TreeGridView'
|
|
import { TreeListView } from '@/components/library/TreeListView'
|
|
import { TreeTableView } from '@/components/library/TreeTableView'
|
|
import { ViewToggle } from '@/components/library/ViewToggle'
|
|
import { SortDropdown } from '@/components/library/SortDropdown'
|
|
import { cn, safeGetItem } from '@/lib/utils'
|
|
import { getSessionResumePath, getTreeNavigatePath } from '@/lib/routing'
|
|
import { usePermissions } from '@/hooks/usePermissions'
|
|
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
|
import { usePinnedFlowsStore } from '@/store/pinnedFlowsStore'
|
|
import { useCachedQuota } from '@/hooks/useCachedQuota'
|
|
import { CreateFlowDropdown } from '@/components/common/CreateFlowDropdown'
|
|
import { Spinner } from '@/components/common/Spinner'
|
|
import { EmptyState } from '@/components/common/EmptyState'
|
|
import { toast } from '@/lib/toast'
|
|
|
|
export function TreeLibraryPage() {
|
|
const { canCreateTrees } = usePermissions()
|
|
const navigate = useNavigate()
|
|
const [searchParams] = useSearchParams()
|
|
const [trees, setTrees] = useState<TreeListItem[]>([])
|
|
const [categories, setCategories] = useState<CategoryListItem[]>([])
|
|
const [folders, setFolders] = useState<FolderListItem[]>([])
|
|
const urlCategory = searchParams.get('category') || ''
|
|
const urlTags = searchParams.get('tags')
|
|
const [selectedCategoryId, setSelectedCategoryId] = useState<string>(urlCategory)
|
|
const [selectedTags, setSelectedTags] = useState<string[]>(urlTags ? urlTags.split(',') : [])
|
|
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null)
|
|
const [searchQuery, setSearchQuery] = useState('')
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
// Read type filter from URL query params (e.g. /trees?type=procedural)
|
|
const urlType = searchParams.get('type')
|
|
const [typeFilter, setTypeFilter] = useState<'all' | 'troubleshooting' | 'procedural' | 'maintenance'>(
|
|
urlType === 'troubleshooting' || urlType === 'procedural' || urlType === 'maintenance' ? urlType : 'all'
|
|
)
|
|
|
|
// Sync filters when URL changes (e.g. clicking sidebar categories/tags or nav sub-items)
|
|
useEffect(() => {
|
|
const t = searchParams.get('type')
|
|
if (t === 'troubleshooting' || t === 'procedural' || t === 'maintenance') {
|
|
setTypeFilter(t)
|
|
} else {
|
|
setTypeFilter('all')
|
|
}
|
|
setSelectedCategoryId(searchParams.get('category') || '')
|
|
const tagsParam = searchParams.get('tags')
|
|
setSelectedTags(tagsParam ? tagsParam.split(',') : [])
|
|
}, [searchParams])
|
|
|
|
// View preferences from store
|
|
const { treeLibraryView, setTreeLibraryView, treeLibrarySortBy, setTreeLibrarySortBy } =
|
|
useUserPreferencesStore()
|
|
|
|
// Folder modal state
|
|
const [folderModalOpen, setFolderModalOpen] = useState(false)
|
|
const [editingFolder, setEditingFolder] = useState<FolderListItem | null>(null)
|
|
const [newFolderParentId, setNewFolderParentId] = useState<string | null>(null)
|
|
|
|
// Delete confirmation state
|
|
const [treeToDelete, setTreeToDelete] = useState<TreeListItem | null>(null)
|
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
|
const [isDeleting, setIsDeleting] = useState(false)
|
|
|
|
// Fork modal state
|
|
const [forkTarget, setForkTarget] = useState<TreeListItem | null>(null)
|
|
|
|
// Import/Export modal state
|
|
const [showImportModal, setShowImportModal] = useState(false)
|
|
const [exportTarget, setExportTarget] = useState<TreeListItem | null>(null)
|
|
|
|
// AI builder state
|
|
|
|
const { aiEnabled } = useCachedQuota()
|
|
|
|
// Pin store
|
|
const pinnedItems = usePinnedFlowsStore((s) => s.items)
|
|
const isMutatingByTreeId = usePinnedFlowsStore((s) => s.isMutatingByTreeId)
|
|
const pinnedTreeIds = useMemo(() => new Set(pinnedItems.map((f) => f.tree_id)), [pinnedItems])
|
|
const pinLoadingTreeIds = useMemo(
|
|
() => new Set(Object.entries(isMutatingByTreeId).filter(([, v]) => v).map(([k]) => k)),
|
|
[isMutatingByTreeId]
|
|
)
|
|
const togglePin = usePinnedFlowsStore((s) => s.toggle)
|
|
const loadPinned = usePinnedFlowsStore((s) => s.load)
|
|
|
|
// Repeat Last Session
|
|
const lastSessionData = (() => {
|
|
const raw = safeGetItem('last-session')
|
|
if (!raw) return null
|
|
try { return JSON.parse(raw) as { tree_id: string; tree_name: string; client_name: string; ticket_number: string; tree_type?: string } }
|
|
catch { return null }
|
|
})()
|
|
|
|
// Incomplete sessions for auto-recovery
|
|
const [incompleteSessions, setIncompleteSessions] = useState<Session[]>([])
|
|
const [dismissedSessionIds, setDismissedSessionIds] = useState<Set<string>>(() => {
|
|
try {
|
|
const raw = sessionStorage.getItem('dismissed-sessions')
|
|
return raw ? new Set(JSON.parse(raw) as string[]) : new Set()
|
|
} catch { return new Set() }
|
|
})
|
|
|
|
const loadFolders = useCallback(async () => {
|
|
try {
|
|
const foldersData = await foldersApi.list()
|
|
setFolders(foldersData)
|
|
} catch (err) {
|
|
console.error('Failed to load folders:', err)
|
|
}
|
|
}, [])
|
|
|
|
// Load incomplete sessions on mount
|
|
useEffect(() => {
|
|
sessionsApi.list({ completed: false, size: 5 })
|
|
.then(setIncompleteSessions)
|
|
.catch((err) => console.error('Failed to load incomplete sessions:', err))
|
|
}, [])
|
|
|
|
// Load pinned flows
|
|
useEffect(() => { loadPinned() }, [loadPinned])
|
|
|
|
const dismissSession = (sessionId: string) => {
|
|
const next = new Set(dismissedSessionIds)
|
|
next.add(sessionId)
|
|
setDismissedSessionIds(next)
|
|
try { sessionStorage.setItem('dismissed-sessions', JSON.stringify([...next])) } catch { /* */ }
|
|
}
|
|
|
|
const visibleIncompleteSessions = incompleteSessions.filter(s => !dismissedSessionIds.has(s.id))
|
|
|
|
const formatTimeAgo = (dateString: string) => {
|
|
const diff = Math.floor((Date.now() - new Date(dateString).getTime()) / 1000)
|
|
if (diff < 60) return 'just now'
|
|
if (diff < 3600) return `${Math.floor(diff / 60)} min ago`
|
|
if (diff < 86400) return `${Math.floor(diff / 3600)} hr ago`
|
|
return `${Math.floor(diff / 86400)} days ago`
|
|
}
|
|
|
|
// Load categories once on mount (they rarely change)
|
|
useEffect(() => {
|
|
categoriesApi.list()
|
|
.then(setCategories)
|
|
.catch((err) => console.error('Failed to load categories:', err))
|
|
}, [])
|
|
|
|
// Load trees when filters change
|
|
useEffect(() => {
|
|
loadTrees()
|
|
}, [selectedCategoryId, selectedTags, selectedFolderId, treeLibrarySortBy, typeFilter])
|
|
|
|
// 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 loadTrees = async () => {
|
|
setIsLoading(true)
|
|
try {
|
|
const treesData = await treesApi.list({
|
|
tree_type: typeFilter !== 'all' ? typeFilter : undefined,
|
|
category_id: selectedCategoryId || undefined,
|
|
tags: selectedTags.length > 0 ? selectedTags.join(',') : undefined,
|
|
folder_id: selectedFolderId || undefined,
|
|
sort_by: treeLibrarySortBy,
|
|
})
|
|
setTrees(treesData)
|
|
} catch (err) {
|
|
toast.error('Failed to load flows')
|
|
console.error(err)
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleSearch = async () => {
|
|
if (!searchQuery.trim()) {
|
|
loadTrees()
|
|
return
|
|
}
|
|
setIsLoading(true)
|
|
try {
|
|
const results = await treesApi.search(searchQuery)
|
|
setTrees(results)
|
|
} catch (err) {
|
|
toast.error('Failed to search flows')
|
|
console.error(err)
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
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, treeType?: string) => {
|
|
navigate(getTreeNavigatePath(treeId, treeType))
|
|
}
|
|
|
|
const handleCreateFolder = (parentId?: string | null) => {
|
|
setEditingFolder(null)
|
|
setNewFolderParentId(parentId || null)
|
|
setFolderModalOpen(true)
|
|
}
|
|
|
|
const handleDeleteTree = async () => {
|
|
if (!treeToDelete) return
|
|
setIsDeleting(true)
|
|
try {
|
|
await treesApi.delete(treeToDelete.id)
|
|
setTrees(trees.filter((t) => t.id !== treeToDelete.id))
|
|
window.dispatchEvent(new Event('folder-changed'))
|
|
toast.success(`"${treeToDelete.name}" deleted successfully`)
|
|
} catch (err) {
|
|
console.error('Failed to delete flow:', err)
|
|
toast.error('Failed to delete flow')
|
|
} finally {
|
|
setIsDeleting(false)
|
|
setShowDeleteConfirm(false)
|
|
setTreeToDelete(null)
|
|
}
|
|
}
|
|
|
|
const handleForkTree = (treeId: string) => {
|
|
const tree = trees.find((t) => t.id === treeId)
|
|
if (tree) setForkTarget(tree)
|
|
}
|
|
|
|
const handleExportTree = (treeId: string) => {
|
|
const tree = trees.find((t) => t.id === treeId)
|
|
if (tree) setExportTarget(tree)
|
|
}
|
|
|
|
const hasActiveFilters =
|
|
selectedCategoryId || selectedTags.length > 0 || searchQuery || selectedFolderId
|
|
|
|
return (
|
|
<div className="min-h-full">
|
|
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
|
<div className="mb-6 flex flex-col gap-4 sm:mb-8 sm:flex-row sm:items-start sm:justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold font-heading text-foreground sm:text-3xl">
|
|
{typeFilter === 'procedural' ? 'Projects' : typeFilter === 'troubleshooting' ? 'Troubleshooting Flows' : typeFilter === 'maintenance' ? 'Maintenance Flows' : 'Flow Library'}
|
|
</h1>
|
|
<p className="mt-2 text-muted-foreground">
|
|
{typeFilter === 'procedural'
|
|
? 'Step-by-step projects and runbooks'
|
|
: typeFilter === 'troubleshooting'
|
|
? 'Branching decision flows for troubleshooting'
|
|
: typeFilter === 'maintenance'
|
|
? 'Scheduled maintenance procedures run across targets'
|
|
: 'Browse and start troubleshooting flows and projects'}
|
|
</p>
|
|
</div>
|
|
{canCreateTrees && (
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => setShowImportModal(true)}
|
|
className="flex items-center gap-2 rounded-lg border border-border bg-[rgba(255,255,255,0.04)] px-4 py-2 text-sm font-medium text-foreground hover:border-[rgba(255,255,255,0.12)] transition-colors"
|
|
>
|
|
<FileUp className="h-4 w-4" />
|
|
Import
|
|
</button>
|
|
<CreateFlowDropdown
|
|
aiEnabled={aiEnabled}
|
|
|
|
label="Create New"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Search and Filter */}
|
|
<div className="mb-4 space-y-4">
|
|
<div className="flex flex-col gap-4 sm:flex-row">
|
|
<div className="flex flex-1 gap-2">
|
|
<input
|
|
type="text"
|
|
placeholder="Search flows..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
|
className={cn(
|
|
'flex-1 rounded-md border border-border bg-card px-3 py-2',
|
|
'text-foreground placeholder:text-muted-foreground',
|
|
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20'
|
|
)}
|
|
/>
|
|
<button
|
|
onClick={handleSearch}
|
|
className={cn(
|
|
'rounded-md bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
|
'hover:opacity-90'
|
|
)}
|
|
>
|
|
Search
|
|
</button>
|
|
</div>
|
|
|
|
<select
|
|
value={selectedCategoryId}
|
|
onChange={(e) => setSelectedCategoryId(e.target.value)}
|
|
aria-label="Filter by category"
|
|
className={cn(
|
|
'rounded-md border border-border bg-card px-3 py-2',
|
|
'text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20'
|
|
)}
|
|
>
|
|
<option value="">All Categories</option>
|
|
{categories.map((cat) => (
|
|
<option key={cat.id} value={cat.id}>
|
|
{cat.name} ({cat.tree_count})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* View Controls */}
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
{/* Type filter tabs */}
|
|
<div className="flex rounded-lg border border-border p-0.5">
|
|
{(['all', 'troubleshooting', 'procedural', 'maintenance'] as const).map((t) => (
|
|
<button
|
|
key={t}
|
|
onClick={() => setTypeFilter(t)}
|
|
className={cn(
|
|
'rounded-md px-3 py-1 text-xs font-medium transition-colors',
|
|
typeFilter === t
|
|
? 'bg-accent text-foreground'
|
|
: 'text-muted-foreground hover:text-foreground'
|
|
)}
|
|
>
|
|
{t === 'all' ? 'All' : t === 'troubleshooting' ? 'Troubleshooting' : t === 'procedural' ? 'Projects' : 'Maintenance'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Right controls: sort + view toggle */}
|
|
<div className="flex items-center gap-2">
|
|
<SortDropdown value={treeLibrarySortBy} onChange={setTreeLibrarySortBy} />
|
|
<ViewToggle view={treeLibraryView} onChange={setTreeLibraryView} />
|
|
</div>
|
|
</div>
|
|
</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 text-foreground">
|
|
Folder
|
|
<button
|
|
onClick={() => setSelectedFolderId(null)}
|
|
className="rounded-full p-0.5 hover:bg-accent"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</span>
|
|
)}
|
|
{selectedCategoryId && (
|
|
<span className="inline-flex items-center gap-1 rounded-full bg-accent px-3 py-1 text-sm text-foreground">
|
|
{categories.find((c) => c.id === selectedCategoryId)?.name}
|
|
<button
|
|
onClick={() => setSelectedCategoryId('')}
|
|
className="rounded-full p-0.5 hover:bg-accent"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</span>
|
|
)}
|
|
{selectedTags.map((tag) => (
|
|
<span
|
|
key={tag}
|
|
className="inline-flex items-center gap-1 rounded-full bg-accent px-3 py-1 text-sm text-foreground"
|
|
>
|
|
{tag}
|
|
<button
|
|
onClick={() => removeTagFilter(tag)}
|
|
className="rounded-full p-0.5 hover:bg-accent"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</span>
|
|
))}
|
|
<button
|
|
onClick={clearAllFilters}
|
|
className="text-sm text-muted-foreground hover:text-foreground"
|
|
>
|
|
Clear all
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Incomplete Session Recovery */}
|
|
{visibleIncompleteSessions.length > 0 && (
|
|
<div className="mb-6 space-y-2">
|
|
{visibleIncompleteSessions.map(s => (
|
|
<div key={s.id} className="bg-card border border-border flex items-center justify-between rounded-xl p-4">
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate font-medium text-foreground">
|
|
{s.tree_snapshot?.name || 'Unknown tree'}
|
|
</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{s.client_name && `${s.client_name} · `}
|
|
Started {formatTimeAgo(s.started_at)}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => navigate(getSessionResumePath(s.tree_id, s.tree_snapshot?.tree_type), { state: { sessionId: s.id } })}
|
|
className="flex items-center gap-1.5 rounded-md bg-gradient-brand px-3 py-1.5 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90"
|
|
>
|
|
<Play className="h-3.5 w-3.5" />
|
|
Resume
|
|
</button>
|
|
<button
|
|
onClick={() => dismissSession(s.id)}
|
|
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Repeat Last Session */}
|
|
{lastSessionData && (
|
|
<div className="mb-6">
|
|
<button
|
|
onClick={() => navigate(getSessionResumePath(lastSessionData.tree_id, lastSessionData.tree_type), {
|
|
state: { prefillClientName: lastSessionData.client_name, prefillTicketNumber: lastSessionData.ticket_number },
|
|
})}
|
|
className={cn(
|
|
'flex items-center gap-2 rounded-lg border border-border px-4 py-2.5 text-sm text-muted-foreground',
|
|
'hover:border-border hover:bg-accent hover:text-foreground'
|
|
)}
|
|
>
|
|
<RotateCcw className="h-4 w-4" />
|
|
Repeat: {lastSessionData.tree_name}
|
|
{lastSessionData.client_name && ` (${lastSessionData.client_name})`}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Loading State */}
|
|
{isLoading ? (
|
|
<div className="flex justify-center py-12">
|
|
<Spinner />
|
|
</div>
|
|
) : trees.length === 0 ? (
|
|
<EmptyState
|
|
title="No flows found"
|
|
description={
|
|
(searchQuery || hasActiveFilters)
|
|
? 'Try adjusting your filters.'
|
|
: 'Create your first flow to get started.'
|
|
}
|
|
/>
|
|
) : (
|
|
<>
|
|
{treeLibraryView === 'grid' && (
|
|
<TreeGridView
|
|
trees={trees}
|
|
onStartSession={handleStartSession}
|
|
onTagClick={handleTagClick}
|
|
onFolderCreated={handleCreateFolder}
|
|
onDeleteTree={(tree) => {
|
|
setTreeToDelete(tree)
|
|
setShowDeleteConfirm(true)
|
|
}}
|
|
onForkTree={handleForkTree}
|
|
onExportTree={handleExportTree}
|
|
pinnedTreeIds={pinnedTreeIds}
|
|
onTogglePin={togglePin}
|
|
pinLoadingTreeIds={pinLoadingTreeIds}
|
|
/>
|
|
)}
|
|
{treeLibraryView === 'list' && (
|
|
<TreeListView
|
|
trees={trees}
|
|
onStartSession={handleStartSession}
|
|
onTagClick={handleTagClick}
|
|
onFolderCreated={handleCreateFolder}
|
|
onDeleteTree={(tree) => {
|
|
setTreeToDelete(tree)
|
|
setShowDeleteConfirm(true)
|
|
}}
|
|
onForkTree={handleForkTree}
|
|
onExportTree={handleExportTree}
|
|
pinnedTreeIds={pinnedTreeIds}
|
|
onTogglePin={togglePin}
|
|
pinLoadingTreeIds={pinLoadingTreeIds}
|
|
/>
|
|
)}
|
|
{treeLibraryView === 'table' && (
|
|
<TreeTableView
|
|
trees={trees}
|
|
onStartSession={handleStartSession}
|
|
onTagClick={handleTagClick}
|
|
onFolderCreated={handleCreateFolder}
|
|
onDeleteTree={(tree) => {
|
|
setTreeToDelete(tree)
|
|
setShowDeleteConfirm(true)
|
|
}}
|
|
onSortChange={(sortBy) => {
|
|
setTreeLibrarySortBy(
|
|
sortBy as 'usage_count' | 'updated_at' | 'created_at' | 'name' | 'name_desc' | 'version'
|
|
)
|
|
}}
|
|
onForkTree={handleForkTree}
|
|
onExportTree={handleExportTree}
|
|
pinnedTreeIds={pinnedTreeIds}
|
|
onTogglePin={togglePin}
|
|
pinLoadingTreeIds={pinLoadingTreeIds}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Folder Edit Modal */}
|
|
<FolderEditModal
|
|
folder={editingFolder}
|
|
parentId={newFolderParentId}
|
|
folders={folders}
|
|
isOpen={folderModalOpen}
|
|
onClose={() => {
|
|
setFolderModalOpen(false)
|
|
setNewFolderParentId(null)
|
|
}}
|
|
onSave={loadTrees}
|
|
/>
|
|
|
|
{/* Delete Confirmation */}
|
|
<ConfirmDialog
|
|
isOpen={showDeleteConfirm}
|
|
onClose={() => {
|
|
setShowDeleteConfirm(false)
|
|
setTreeToDelete(null)
|
|
}}
|
|
onConfirm={handleDeleteTree}
|
|
title="Delete Flow"
|
|
message={`Are you sure you want to delete "${treeToDelete?.name}"? This action can be undone by an administrator.`}
|
|
confirmLabel="Delete"
|
|
confirmVariant="destructive"
|
|
isLoading={isDeleting}
|
|
/>
|
|
|
|
|
|
{forkTarget && (
|
|
<ForkModal
|
|
treeId={forkTarget.id}
|
|
treeName={forkTarget.name}
|
|
onClose={() => setForkTarget(null)}
|
|
/>
|
|
)}
|
|
|
|
{exportTarget && (
|
|
<ExportFlowModal
|
|
treeId={exportTarget.id}
|
|
treeName={exportTarget.name}
|
|
onClose={() => setExportTarget(null)}
|
|
/>
|
|
)}
|
|
|
|
{showImportModal && (
|
|
<ImportFlowModal
|
|
onClose={() => { setShowImportModal(false); loadTrees() }}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default TreeLibraryPage
|