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:
Michael Chihlas
2026-02-07 23:06:46 -05:00
parent c7b2c59ef6
commit 996b664ca9
30 changed files with 2973 additions and 92 deletions

View 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