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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user