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

@@ -1,9 +1,9 @@
import { useEffect, useState, useCallback } from 'react'
import { useParams, useNavigate, useBlocker } from 'react-router-dom'
import { useStore } from 'zustand'
import { Undo2, Redo2, Save, CheckCircle2, Monitor } from 'lucide-react'
import { Undo2, Redo2, Save, CheckCircle2, Monitor, FileText } from 'lucide-react'
import { treesApi } from '@/api'
import type { TreeCreate, TreeUpdate } from '@/types'
import type { TreeCreate, TreeUpdate, TreeStatus } from '@/types'
import { useTreeEditorStore, useTreeEditorTemporal } from '@/store/treeEditorStore'
import { TreeEditorLayout } from '@/components/tree-editor/TreeEditorLayout'
import { ValidationSummary } from '@/components/tree-editor/ValidationSummary'
@@ -41,6 +41,7 @@ export function TreeEditorPage() {
const { undo, redo, pastStates, futureStates } = useStore(useTreeEditorTemporal)
const [showDraftPrompt, setShowDraftPrompt] = useState(false)
const [treeStatus, setTreeStatus] = useState<TreeStatus>('draft')
// Mobile detection
const [isMobile, setIsMobile] = useState(false)
@@ -107,12 +108,14 @@ export function TreeEditorPage() {
return
}
loadTree(tree)
setTreeStatus(tree.status) // Load status from existing tree
} catch (err) {
console.error('Failed to load tree:', err)
navigate('/trees')
}
} else {
initNewTree()
setTreeStatus('draft') // New trees start as draft
// Check for draft after initializing
const draftExists = localStorage.getItem('tree-editor-draft') !== null
if (draftExists) {
@@ -159,38 +162,76 @@ export function TreeEditorPage() {
selectNode(nodeId)
}
const handleSave = useCallback(async () => {
const handleSaveDraft = useCallback(async () => {
setSaving(true)
try {
const treeData = { ...getTreeForSave(), status: 'draft' as TreeStatus }
if (isEditMode) {
await treesApi.update(id!, treeData as TreeUpdate)
setTreeStatus('draft')
markSaved()
toast.success('Draft saved successfully')
} else {
const newTree = await treesApi.create(treeData as TreeCreate)
setTreeStatus('draft')
// Mark saved BEFORE navigating to avoid triggering the blocker
markSaved()
toast.success('Draft created successfully')
// Navigate to edit mode with the new ID
navigate(`/trees/${newTree.id}/edit`, { replace: true })
}
} catch (err) {
console.error('Failed to save draft:', err)
toast.error('Failed to save draft. Please try again.')
} finally {
setSaving(false)
}
}, [isEditMode, id, getTreeForSave, markSaved, navigate])
const handlePublish = useCallback(async () => {
// Validate first
const errors = validate()
const hasErrors = errors.some(e => e.severity === 'error')
if (hasErrors) {
toast.error('Please fix validation errors before saving')
toast.error('Please fix validation errors before publishing')
return
}
setSaving(true)
try {
const treeData = getTreeForSave()
const treeData = { ...getTreeForSave(), status: 'published' as TreeStatus }
if (isEditMode) {
await treesApi.update(id!, treeData as TreeUpdate)
setTreeStatus('published')
markSaved()
toast.success('Tree updated successfully')
toast.success('Tree published successfully')
} else {
const newTree = await treesApi.create(treeData as TreeCreate)
setTreeStatus('published')
// Mark saved BEFORE navigating to avoid triggering the blocker
markSaved()
toast.success('Tree created successfully')
toast.success('Tree published successfully')
// Navigate to edit mode with the new ID
navigate(`/trees/${newTree.id}/edit`, { replace: true })
}
} catch (err) {
console.error('Failed to save tree:', err)
toast.error('Failed to save tree. Please try again.')
console.error('Failed to publish tree:', err)
toast.error('Failed to publish tree. Please try again.')
} finally {
setSaving(false)
}
}, [isEditMode, id, validate, getTreeForSave, markSaved, navigate])
// Keep handleSave for backward compatibility (Ctrl+S shortcut)
const handleSave = useCallback(async () => {
// If tree is already published or has no errors, publish; otherwise save as draft
if (treeStatus === 'published' || !hasBlockingErrors) {
await handlePublish()
} else {
await handleSaveDraft()
}
}, [treeStatus, hasBlockingErrors, handlePublish, handleSaveDraft])
// Handle blocker
const handleBlockerProceed = () => {
if (blocker.state === 'blocked') {
@@ -314,11 +355,19 @@ export function TreeEditorPage() {
{isEditMode ? 'Edit Tree' : 'Create New Tree'}
{name && <span className="ml-2 text-muted-foreground">- {name}</span>}
</h1>
{isDirty && (
<span className="rounded-full bg-yellow-500/20 px-2 py-0.5 text-xs text-yellow-600 dark:text-yellow-400">
Unsaved
</span>
)}
<div className="flex items-center gap-2">
{treeStatus === 'draft' && (
<span className="inline-flex items-center gap-1 rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400">
<FileText className="h-3 w-3" />
Draft
</span>
)}
{isDirty && (
<span className="rounded-full bg-yellow-500/20 px-2 py-0.5 text-xs text-yellow-600 dark:text-yellow-400">
Unsaved
</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
@@ -371,18 +420,32 @@ export function TreeEditorPage() {
Validate
</button>
{/* Save */}
{/* Save Draft */}
<button
onClick={handleSave}
onClick={handleSaveDraft}
disabled={isSaving || !isDirty}
title="Save as draft (Ctrl+S when draft or has errors)"
className={cn(
'flex items-center gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm font-medium',
'hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
<Save className="h-4 w-4" />
Save Draft
</button>
{/* Publish */}
<button
onClick={handlePublish}
disabled={isSaving || !isDirty || hasBlockingErrors}
title={hasBlockingErrors ? 'Fix validation errors before saving' : undefined}
title={hasBlockingErrors ? 'Fix validation errors before publishing (Ctrl+S when no errors)' : 'Publish tree (Ctrl+S when no errors)'}
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 disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
<Save className="h-4 w-4" />
{isSaving ? 'Saving...' : 'Save'}
<CheckCircle2 className="h-4 w-4" />
{isSaving ? 'Publishing...' : 'Publish'}
</button>
</div>
</div>