- FolderTree, Plus, ListOrdered, Wrench are still used in empty state and tree card rendering — restore the imports - SessionHistoryPage needs default export for lazyWithRetry Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
353 lines
13 KiB
TypeScript
353 lines
13 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { useNavigate, Link } from 'react-router-dom'
|
|
import { Play, Pencil, Share2, Trash2, GitBranch, Clock, TrendingUp, FolderTree, Plus, ListOrdered, Wrench } from 'lucide-react'
|
|
import { PageMeta } from '@/components/common/PageMeta'
|
|
import { StaggerList } from '@/components/common/StaggerList'
|
|
import { Button } from '@/components/ui/Button'
|
|
import { treesApi } from '@/api/trees'
|
|
import { sessionsApi } from '@/api/sessions'
|
|
import type { TreeListItem } from '@/types'
|
|
import { TagBadges } from '@/components/common/TagBadges'
|
|
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
|
|
import { ShareTreeModal } from '@/components/library/ShareTreeModal'
|
|
import { Spinner } from '@/components/common/Spinner'
|
|
import { cn } from '@/lib/utils'
|
|
import { useAuthStore } from '@/store/authStore'
|
|
import { usePermissions } from '@/hooks/usePermissions'
|
|
import { toast } from '@/lib/toast'
|
|
import { ForkModal } from '@/components/library/ForkModal'
|
|
import { CreateFlowDropdown } from '@/components/common/CreateFlowDropdown'
|
|
|
|
interface TreeWithStats extends TreeListItem {
|
|
lastUsed?: string
|
|
sessionCount?: number
|
|
parent_tree_id?: string | null
|
|
parent_tree_name?: string | null
|
|
}
|
|
|
|
export function MyTreesPage() {
|
|
const navigate = useNavigate()
|
|
const { user } = useAuthStore()
|
|
const { canEditTree, canCreateTrees } = usePermissions()
|
|
const [trees, setTrees] = useState<TreeWithStats[]>([])
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const [treeToDelete, setTreeToDelete] = useState<TreeWithStats | null>(null)
|
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
|
const [isDeleting, setIsDeleting] = useState(false)
|
|
const [treeToShare, setTreeToShare] = useState<TreeWithStats | null>(null)
|
|
const [showShareModal, setShowShareModal] = useState(false)
|
|
const [forkTarget, setForkTarget] = useState<TreeWithStats | null>(null)
|
|
|
|
useEffect(() => {
|
|
loadMyTrees()
|
|
}, [user?.id])
|
|
|
|
const loadMyTrees = async () => {
|
|
if (!user?.id) return
|
|
setIsLoading(true)
|
|
try {
|
|
// Fetch trees and recent sessions in parallel (2 API calls total, not N+1)
|
|
const [userTrees, recentSessions] = await Promise.all([
|
|
treesApi.list({ author_id: user.id }),
|
|
sessionsApi.list({ size: 100 }),
|
|
])
|
|
|
|
// Build a map of tree_id -> most recent session start time
|
|
const lastUsedMap = new Map<string, string>()
|
|
for (const session of recentSessions) {
|
|
const existing = lastUsedMap.get(session.tree_id)
|
|
if (session.started_at && (!existing || new Date(session.started_at) > new Date(existing))) {
|
|
lastUsedMap.set(session.tree_id, session.started_at)
|
|
}
|
|
}
|
|
|
|
const treesWithStats: TreeWithStats[] = userTrees.map((tree) => ({
|
|
...tree,
|
|
lastUsed: lastUsedMap.get(tree.id),
|
|
sessionCount: tree.usage_count ?? 0,
|
|
}))
|
|
|
|
setTrees(treesWithStats)
|
|
} catch (err) {
|
|
toast.error('Failed to load your flows')
|
|
console.error(err)
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleStartSession = (tree: TreeWithStats) => {
|
|
if (tree.tree_type === 'maintenance') {
|
|
navigate(`/flows/${tree.id}/maintenance`)
|
|
} else if (tree.tree_type === 'procedural') {
|
|
navigate(`/flows/${tree.id}/navigate`)
|
|
} else {
|
|
navigate(`/trees/${tree.id}/navigate`)
|
|
}
|
|
}
|
|
|
|
const getEditPath = (tree: TreeWithStats) => {
|
|
return tree.tree_type === 'procedural' || tree.tree_type === 'maintenance'
|
|
? `/flows/${tree.id}/edit` : `/trees/${tree.id}/edit`
|
|
}
|
|
|
|
const handleDeleteTree = async () => {
|
|
if (!treeToDelete) return
|
|
setIsDeleting(true)
|
|
try {
|
|
await treesApi.delete(treeToDelete.id)
|
|
setTrees(trees.filter((t) => t.id !== treeToDelete.id))
|
|
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 formatDate = (dateString?: string) => {
|
|
if (!dateString) return 'Never'
|
|
return new Date(dateString).toLocaleDateString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
})
|
|
}
|
|
|
|
return (
|
|
<div className="overflow-y-auto h-full">
|
|
<PageMeta title="My Flows" />
|
|
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
|
<div className="mb-6 flex items-center justify-between sm:mb-8">
|
|
<div>
|
|
<h1 className="text-2xl font-bold font-heading text-foreground sm:text-3xl">My Flows</h1>
|
|
<p className="mt-2 text-muted-foreground">
|
|
Your forked and custom flows
|
|
</p>
|
|
</div>
|
|
{canCreateTrees && (
|
|
<CreateFlowDropdown label="Create New" />
|
|
)}
|
|
</div>
|
|
|
|
{/* Loading State */}
|
|
{isLoading ? (
|
|
<div className="flex justify-center py-12">
|
|
<Spinner />
|
|
</div>
|
|
) : trees.length === 0 ? (
|
|
<div className="rounded-lg border border-dashed border-border bg-accent px-4 py-12 text-center">
|
|
<FolderTree className="mx-auto mb-4 h-12 w-12 text-muted-foreground" />
|
|
<h2 className="mb-2 text-lg font-semibold text-foreground">No personal flows yet</h2>
|
|
<p className="mb-4 text-sm text-muted-foreground">
|
|
Fork a flow from the library to customize it for your workflow
|
|
</p>
|
|
<div className="flex items-center justify-center gap-3">
|
|
<Link
|
|
to="/trees"
|
|
className={cn(
|
|
'inline-flex items-center gap-2 rounded-md bg-primary text-white px-4 py-2 text-sm font-medium',
|
|
'hover:brightness-110'
|
|
)}
|
|
>
|
|
Browse Library
|
|
</Link>
|
|
{canCreateTrees && (
|
|
<Link
|
|
to="/trees/new"
|
|
className={cn(
|
|
'inline-flex items-center gap-2 rounded-md border border-border px-4 py-2 text-sm font-medium text-muted-foreground',
|
|
'hover:bg-accent hover:text-foreground'
|
|
)}
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Create from Scratch
|
|
</Link>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<StaggerList className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
{trees.map((tree) => (
|
|
<div
|
|
key={tree.id}
|
|
className="bg-card border border-border rounded-xl p-4 transition-all hover:border-border/80 sm:p-6"
|
|
>
|
|
{/* Header */}
|
|
<div className="mb-3 flex items-start justify-between gap-2">
|
|
<div className="flex items-center gap-2">
|
|
{tree.tree_type === 'procedural' && (
|
|
<ListOrdered className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
)}
|
|
{tree.tree_type === 'maintenance' && (
|
|
<Wrench className="h-4 w-4 shrink-0 text-amber-400" />
|
|
)}
|
|
<h3 className="font-semibold text-foreground">{tree.name}</h3>
|
|
{tree.parent_tree_id && (
|
|
<span className="shrink-0 rounded-full bg-violet-400/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-violet-400">
|
|
Fork
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
{tree.tree_type === 'procedural' && (
|
|
<span className="rounded-full bg-blue-400/10 px-2 py-0.5 text-[10px] font-medium text-blue-400">
|
|
Procedure
|
|
</span>
|
|
)}
|
|
{tree.tree_type === 'maintenance' && (
|
|
<span className="rounded-full bg-amber-400/10 px-2 py-0.5 text-[10px] font-medium text-amber-400">
|
|
Maintenance
|
|
</span>
|
|
)}
|
|
{tree.category_info && (
|
|
<span className="rounded-full bg-accent px-2 py-0.5 text-xs text-muted-foreground">
|
|
{tree.category_info.name}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<p className="mb-3 text-sm text-muted-foreground line-clamp-2">
|
|
{tree.description || 'No description available'}
|
|
</p>
|
|
|
|
{/* Fork Badge */}
|
|
{tree.parent_tree_id && (
|
|
<div className="mb-3 flex items-center gap-2 rounded-md bg-accent px-2 py-1.5 text-sm">
|
|
<GitBranch className="h-4 w-4 text-muted-foreground" />
|
|
<span className="text-muted-foreground">
|
|
Forked from{' '}
|
|
<Link
|
|
to={`/trees/${tree.parent_tree_id}/navigate`}
|
|
className="font-medium text-foreground hover:underline"
|
|
>
|
|
original
|
|
</Link>
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Tags */}
|
|
{tree.tags && tree.tags.length > 0 && (
|
|
<div className="mb-3">
|
|
<TagBadges tags={tree.tags} maxVisible={3} />
|
|
</div>
|
|
)}
|
|
|
|
{/* Stats */}
|
|
<div className="mb-4 flex items-center gap-4 text-xs text-muted-foreground">
|
|
<div className="flex items-center gap-1">
|
|
<Clock className="h-3.5 w-3.5" />
|
|
<span>{formatDate(tree.lastUsed)}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<TrendingUp className="h-3.5 w-3.5" />
|
|
<span>{tree.sessionCount || 0} uses</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
onClick={() => handleStartSession(tree)}
|
|
className="flex-1"
|
|
>
|
|
<Play className="h-4 w-4" />
|
|
Start
|
|
</Button>
|
|
{canEditTree({ author_id: tree.author_id, account_id: tree.account_id }) && (
|
|
<Link
|
|
to={getEditPath(tree)}
|
|
className={cn(
|
|
'rounded-md border border-border p-2 text-muted-foreground',
|
|
'hover:bg-accent hover:text-foreground'
|
|
)}
|
|
title="Edit tree"
|
|
>
|
|
<Pencil className="h-4 w-4" />
|
|
</Link>
|
|
)}
|
|
<Button
|
|
variant="secondary"
|
|
size="icon"
|
|
onClick={() => {
|
|
setTreeToShare(tree)
|
|
setShowShareModal(true)
|
|
}}
|
|
title="Share tree"
|
|
>
|
|
<Share2 className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
size="icon"
|
|
onClick={() => setForkTarget(tree)}
|
|
title="Fork flow"
|
|
>
|
|
<GitBranch className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
size="icon"
|
|
onClick={() => {
|
|
setTreeToDelete(tree)
|
|
setShowDeleteConfirm(true)
|
|
}}
|
|
title="Delete tree"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</StaggerList>
|
|
)}
|
|
|
|
{/* 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}
|
|
/>
|
|
|
|
{/* Share Tree Modal */}
|
|
{treeToShare && (
|
|
<ShareTreeModal
|
|
tree={treeToShare}
|
|
isOpen={showShareModal}
|
|
onClose={() => {
|
|
setShowShareModal(false)
|
|
setTreeToShare(null)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Fork Modal */}
|
|
{forkTarget && (
|
|
<ForkModal
|
|
treeId={forkTarget.id}
|
|
treeName={forkTarget.name}
|
|
onClose={() => setForkTarget(null)}
|
|
/>
|
|
)}
|
|
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default MyTreesPage
|