feat: implement My Trees, admin UI, rating modal, and bundle optimization (Issues #15, #18, #19, #31)
Frontend features: - My Trees personal dashboard with fork tracking (Issue #15) - Tree sharing UI with token generation and copy (Issue #16) - Draft tree badges and validation UI (Issue #25) - Save session as tree modal (Issue #17) - Rate/review modal with localStorage tracking (Issue #19) - Admin category management with drag-and-drop (Issue #18) - Bundle size optimization with code splitting (Issue #31) Components created: - MyTreesPage: Personal tree organization - AdminCategoriesPage: Category CRUD with @dnd-kit - ShareTreeModal: Tree sharing interface - SaveSessionAsTreeModal: Session conversion UI - StepRatingModal: Post-session rating with stars - StarRating: Reusable rating component - PageLoader: Loading fallback for lazy routes - CreateCategoryModal, EditCategoryModal: Admin modals Bundle optimization: - Reduced from 892 KB to 221 KB (75% reduction) - Dynamic imports for 9 heavy pages - Vendor chunk splitting for optimal caching - 6 separate vendor chunks (react, markdown, utils, dnd, icons, state) Dependencies added: - @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities API clients: - stepCategories: Full CRUD for admin - Enhanced sessions: saveAsTree endpoint - Enhanced trees: share, fork, canPublish endpoints Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
287
frontend/src/pages/MyTreesPage.tsx
Normal file
287
frontend/src/pages/MyTreesPage.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { Play, Pencil, Share2, Trash2, GitBranch, Clock, TrendingUp, FolderTree } from 'lucide-react'
|
||||
import { treesApi, sessionsApi } from '@/api'
|
||||
import type { TreeListItem } from '@/types'
|
||||
import { TagBadges } from '@/components/common/TagBadges'
|
||||
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
|
||||
import { ShareTreeModal } from '@/components/library/ShareTreeModal'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { usePermissions } from '@/hooks/usePermissions'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
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 } = 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)
|
||||
|
||||
useEffect(() => {
|
||||
loadMyTrees()
|
||||
}, [user?.id])
|
||||
|
||||
const loadMyTrees = async () => {
|
||||
if (!user?.id) return
|
||||
setIsLoading(true)
|
||||
try {
|
||||
// Get user's trees (authored by current user)
|
||||
const userTrees = await treesApi.list({ author_id: user.id })
|
||||
|
||||
// Load session stats for each tree
|
||||
const treesWithStats = await Promise.all(
|
||||
userTrees.map(async (tree) => {
|
||||
try {
|
||||
const sessions = await sessionsApi.list({ tree_id: tree.id })
|
||||
const lastUsed = sessions.length > 0
|
||||
? sessions.reduce((latest, session) =>
|
||||
new Date(session.started_at) > new Date(latest.started_at) ? session : latest
|
||||
).started_at
|
||||
: undefined
|
||||
return {
|
||||
...tree,
|
||||
lastUsed,
|
||||
sessionCount: sessions.length,
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to load stats for tree ${tree.id}:`, err)
|
||||
return {
|
||||
...tree,
|
||||
sessionCount: 0,
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
setTrees(treesWithStats)
|
||||
} catch (err) {
|
||||
toast.error('Failed to load your trees')
|
||||
console.error(err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStartSession = (treeId: string) => {
|
||||
navigate(`/trees/${treeId}/navigate`)
|
||||
}
|
||||
|
||||
const handleDeleteTree = async () => {
|
||||
if (!treeToDelete) return
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
await treesApi.delete(treeToDelete.id)
|
||||
setTrees(trees.filter((t) => t.id !== treeToDelete.id))
|
||||
toast.success(`Tree "${treeToDelete.name}" deleted successfully`)
|
||||
} catch (err) {
|
||||
console.error('Failed to delete tree:', err)
|
||||
toast.error('Failed to delete tree')
|
||||
} 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="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<h1 className="font-heading text-3xl font-bold sm:text-4xl">
|
||||
<span className="text-gradient-brand">My Trees</span>
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Your forked and custom decision trees
|
||||
</p>
|
||||
</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="rounded-lg border border-dashed border-border bg-card/50 px-4 py-12 text-center">
|
||||
<FolderTree className="mx-auto mb-4 h-12 w-12 text-muted-foreground opacity-50" />
|
||||
<h2 className="mb-2 text-lg font-semibold text-foreground">No personal trees yet</h2>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
Fork a tree from the library to customize it for your workflow
|
||||
</p>
|
||||
<Link
|
||||
to="/trees"
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
|
||||
'hover:bg-primary/90'
|
||||
)}
|
||||
>
|
||||
Browse Trees
|
||||
</Link>
|
||||
</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-4 shadow-sm transition-all hover:-translate-y-0.5 hover:border-primary/30 hover:shadow-md sm:p-6"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="mb-3 flex items-start justify-between gap-2">
|
||||
<h3 className="font-semibold text-card-foreground">{tree.name}</h3>
|
||||
{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>
|
||||
|
||||
{/* 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/50 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-primary 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
|
||||
type="button"
|
||||
onClick={() => handleStartSession(tree.id)}
|
||||
className={cn(
|
||||
'flex flex-1 items-center justify-center gap-2 rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground',
|
||||
'hover:bg-primary/90'
|
||||
)}
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
Start
|
||||
</button>
|
||||
{canEditTree({ author_id: tree.author_id, account_id: tree.account_id }) && (
|
||||
<Link
|
||||
to={`/trees/${tree.id}/edit`}
|
||||
className={cn(
|
||||
'rounded-md border border-input p-2 text-muted-foreground',
|
||||
'hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
title="Edit tree"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTreeToShare(tree)
|
||||
setShowShareModal(true)
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-md border border-input p-2 text-muted-foreground',
|
||||
'hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
title="Share tree"
|
||||
>
|
||||
<Share2 className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTreeToDelete(tree)
|
||||
setShowDeleteConfirm(true)
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-md border border-input p-2 text-muted-foreground',
|
||||
'hover:bg-destructive/10 hover:text-destructive'
|
||||
)}
|
||||
title="Delete tree"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<ConfirmDialog
|
||||
isOpen={showDeleteConfirm}
|
||||
onClose={() => {
|
||||
setShowDeleteConfirm(false)
|
||||
setTreeToDelete(null)
|
||||
}}
|
||||
onConfirm={handleDeleteTree}
|
||||
title="Delete Tree"
|
||||
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)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MyTreesPage
|
||||
Reference in New Issue
Block a user