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:
241
frontend/src/pages/AdminCategoriesPage.tsx
Normal file
241
frontend/src/pages/AdminCategoriesPage.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { DndContext, closestCenter } from '@dnd-kit/core'
|
||||
import type { DragEndEvent } from '@dnd-kit/core'
|
||||
import { SortableContext, verticalListSortingStrategy, arrayMove } from '@dnd-kit/sortable'
|
||||
import { stepCategoriesApi, stepsApi } from '@/api'
|
||||
import { CategoryRow } from '@/components/admin/CategoryRow'
|
||||
import { CreateCategoryModal } from '@/components/admin/CreateCategoryModal'
|
||||
import { EditCategoryModal } from '@/components/admin/EditCategoryModal'
|
||||
import type { StepCategoryListItem } from '@/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
export function AdminCategoriesPage() {
|
||||
const [categories, setCategories] = useState<StepCategoryListItem[]>([])
|
||||
const [allSteps, setAllSteps] = useState<any[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [showEditModal, setShowEditModal] = useState(false)
|
||||
const [editingCategory, setEditingCategory] = useState<StepCategoryListItem | null>(null)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [includeArchived, setIncludeArchived] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [includeArchived])
|
||||
|
||||
const loadData = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const [categoriesData, stepsData] = await Promise.all([
|
||||
stepCategoriesApi.list({ include_inactive: includeArchived }),
|
||||
stepsApi.list({})
|
||||
])
|
||||
setCategories(categoriesData)
|
||||
setAllSteps(stepsData)
|
||||
} catch (err) {
|
||||
console.error('Failed to load categories:', err)
|
||||
toast.error('Failed to load categories')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getStepCount = (categoryId: string) => {
|
||||
return allSteps?.filter(s => s.category_id === categoryId).length || 0
|
||||
}
|
||||
|
||||
const handleCreate = async (data: { name: string; description: string }) => {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
await stepCategoriesApi.create({
|
||||
name: data.name,
|
||||
description: data.description || undefined
|
||||
})
|
||||
toast.success('Category created successfully')
|
||||
setShowCreateModal(false)
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
console.error('Failed to create category:', err)
|
||||
toast.error('Failed to create category')
|
||||
throw err
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = async (data: { name: string; description: string }) => {
|
||||
if (!editingCategory) return
|
||||
setIsSaving(true)
|
||||
try {
|
||||
await stepCategoriesApi.update(editingCategory.id, {
|
||||
name: data.name,
|
||||
description: data.description || undefined
|
||||
})
|
||||
toast.success('Category updated successfully')
|
||||
setShowEditModal(false)
|
||||
setEditingCategory(null)
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
console.error('Failed to update category:', err)
|
||||
toast.error('Failed to update category')
|
||||
throw err
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleArchive = async (id: string) => {
|
||||
try {
|
||||
await stepCategoriesApi.archive(id)
|
||||
toast.success('Category archived')
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
console.error('Failed to archive category:', err)
|
||||
toast.error('Failed to archive category')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestore = async (id: string) => {
|
||||
try {
|
||||
await stepCategoriesApi.restore(id)
|
||||
toast.success('Category restored')
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
console.error('Failed to restore category:', err)
|
||||
toast.error('Failed to restore category')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id) return
|
||||
|
||||
const oldIndex = categories.findIndex(c => c.id === active.id)
|
||||
const newIndex = categories.findIndex(c => c.id === over.id)
|
||||
|
||||
const reordered = arrayMove(categories, oldIndex, newIndex)
|
||||
|
||||
// Optimistic update
|
||||
setCategories(reordered)
|
||||
|
||||
try {
|
||||
// Update display_order for all affected categories
|
||||
const updates = reordered.map((cat, index) => ({
|
||||
id: cat.id,
|
||||
display_order: index
|
||||
}))
|
||||
await stepCategoriesApi.updateOrder(updates)
|
||||
toast.success('Categories reordered')
|
||||
} catch (err) {
|
||||
console.error('Failed to reorder categories:', err)
|
||||
toast.error('Failed to save order')
|
||||
// Revert on error
|
||||
await loadData()
|
||||
}
|
||||
}
|
||||
|
||||
const openEditModal = (category: StepCategoryListItem) => {
|
||||
setEditingCategory(category)
|
||||
setShowEditModal(true)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground sm:text-3xl">
|
||||
Step Categories
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Manage categories for organizing step library
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
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'
|
||||
)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Category
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter Toggle */}
|
||||
<div className="mb-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeArchived}
|
||||
onChange={(e) => setIncludeArchived(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input text-primary focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">Show archived categories</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Categories List */}
|
||||
{categories.length === 0 ? (
|
||||
<div className="rounded-lg border border-border bg-card p-12 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
No categories found. Create your first category to get started.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext
|
||||
items={categories.map(c => c.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{categories.map(category => (
|
||||
<CategoryRow
|
||||
key={category.id}
|
||||
category={category}
|
||||
stepCount={getStepCount(category.id)}
|
||||
onEdit={openEditModal}
|
||||
onArchive={handleArchive}
|
||||
onRestore={handleRestore}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
{/* Create Modal */}
|
||||
<CreateCategoryModal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onSubmit={handleCreate}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<EditCategoryModal
|
||||
isOpen={showEditModal}
|
||||
onClose={() => {
|
||||
setShowEditModal(false)
|
||||
setEditingCategory(null)
|
||||
}}
|
||||
onSubmit={handleEdit}
|
||||
category={editingCategory}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AdminCategoriesPage
|
||||
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
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { Copy, Check, Eye } from 'lucide-react'
|
||||
import { sessionsApi } from '@/api'
|
||||
import { Copy, Check, Eye, Save } from 'lucide-react'
|
||||
import { sessionsApi, stepsApi } from '@/api'
|
||||
import { ExportPreviewModal } from '@/components/session/ExportPreviewModal'
|
||||
import { SaveSessionAsTreeModal } from '@/components/session/SaveSessionAsTreeModal'
|
||||
import { StepRatingModal } from '@/components/session/StepRatingModal'
|
||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
import type { Session, SessionExport } from '@/types'
|
||||
import type { Session, SessionExport, SaveAsTreeRequest, Step } from '@/types'
|
||||
import { hasRatedSession, markSessionRated } from '@/lib/sessionRatings'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
@@ -20,6 +23,11 @@ export function SessionDetailPage() {
|
||||
const [exportContent, setExportContent] = useState<string | null>(null)
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [showSaveAsTreeModal, setShowSaveAsTreeModal] = useState(false)
|
||||
const [isSavingTree, setIsSavingTree] = useState(false)
|
||||
const [showRatingModal, setShowRatingModal] = useState(false)
|
||||
const [isSavingRatings, setIsSavingRatings] = useState(false)
|
||||
const [librarySteps, setLibrarySteps] = useState<Step[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
@@ -27,6 +35,36 @@ export function SessionDetailPage() {
|
||||
}
|
||||
}, [id])
|
||||
|
||||
// Auto-show rating modal for completed sessions with library steps
|
||||
useEffect(() => {
|
||||
if (!session || !session.completed_at) return
|
||||
|
||||
// Check if already rated
|
||||
if (hasRatedSession(session.id)) return
|
||||
|
||||
// Extract library steps from custom_steps
|
||||
const stepsFromLibrary = session.custom_steps?.filter(
|
||||
(customStep) => {
|
||||
// Check if step_data is a Step (from library) by checking if it has an id
|
||||
const stepData = customStep.step_data
|
||||
return 'id' in stepData && stepData.id
|
||||
}
|
||||
) || []
|
||||
|
||||
if (stepsFromLibrary.length === 0) return
|
||||
|
||||
// Extract the Step objects
|
||||
const steps = stepsFromLibrary.map((cs) => cs.step_data as Step)
|
||||
setLibrarySteps(steps)
|
||||
|
||||
// Show modal after 1 second delay
|
||||
const timer = setTimeout(() => {
|
||||
setShowRatingModal(true)
|
||||
}, 1000)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [session])
|
||||
|
||||
const loadSession = async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
@@ -104,6 +142,58 @@ export function SessionDetailPage() {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const handleSaveAsTree = async (data: SaveAsTreeRequest) => {
|
||||
if (!session) return
|
||||
setIsSavingTree(true)
|
||||
try {
|
||||
const result = await sessionsApi.saveAsTree(session.id, data)
|
||||
toast.success(result.message)
|
||||
setShowSaveAsTreeModal(false)
|
||||
// Navigate to tree editor with the new tree
|
||||
navigate(`/trees/${result.tree_id}/edit`)
|
||||
} catch (err) {
|
||||
console.error('Failed to save session as tree:', err)
|
||||
toast.error('Failed to save session as tree')
|
||||
} finally {
|
||||
setIsSavingTree(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getDefaultTreeName = () => {
|
||||
if (!session) return ''
|
||||
const treeName = session.tree_snapshot?.name || 'Tree'
|
||||
const ticket = session.ticket_number ? ` - ${session.ticket_number}` : ''
|
||||
return `${treeName}${ticket}`
|
||||
}
|
||||
|
||||
const handleSubmitRatings = async (ratings: Map<string, { rating: number; helpful: boolean | null; review: string }>) => {
|
||||
if (!session) return
|
||||
setIsSavingRatings(true)
|
||||
try {
|
||||
// Submit each rating individually
|
||||
const ratingPromises = Array.from(ratings.entries()).map(([stepId, data]) =>
|
||||
stepsApi.rate(stepId, {
|
||||
rating: data.rating,
|
||||
review_text: data.review || undefined,
|
||||
was_helpful: data.helpful !== null ? data.helpful : undefined,
|
||||
session_id: session.id,
|
||||
is_verified_use: true
|
||||
})
|
||||
)
|
||||
|
||||
await Promise.all(ratingPromises)
|
||||
|
||||
toast.success(`Submitted ${ratings.size} rating${ratings.size > 1 ? 's' : ''}!`)
|
||||
markSessionRated(session.id)
|
||||
setShowRatingModal(false)
|
||||
} catch (err) {
|
||||
console.error('Failed to submit ratings:', err)
|
||||
toast.error('Failed to submit ratings')
|
||||
} finally {
|
||||
setIsSavingRatings(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString()
|
||||
}
|
||||
@@ -166,43 +256,61 @@ export function SessionDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Export */}
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={exportFormat}
|
||||
onChange={(e) => setExportFormat(e.target.value as typeof exportFormat)}
|
||||
aria-label="Export format"
|
||||
className={cn(
|
||||
'w-full rounded-md border border-input bg-background px-3 py-2 text-sm sm:w-auto',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||
)}
|
||||
>
|
||||
<option value="markdown">Markdown</option>
|
||||
<option value="text">Plain Text</option>
|
||||
<option value="html">HTML</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
disabled={isExporting}
|
||||
title="Copy to clipboard"
|
||||
className={cn(
|
||||
'rounded-md border border-input bg-background p-2 text-muted-foreground',
|
||||
'hover:bg-accent hover:text-accent-foreground disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePreview}
|
||||
disabled={isExporting}
|
||||
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'
|
||||
)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
{isExporting ? 'Loading...' : 'Preview'}
|
||||
</button>
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
{/* Save as Tree - Only for completed sessions */}
|
||||
{session.completed_at && (
|
||||
<button
|
||||
onClick={() => setShowSaveAsTreeModal(true)}
|
||||
disabled={isSavingTree}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md border border-input bg-background px-4 py-2 text-sm font-medium',
|
||||
'hover:bg-accent hover:text-accent-foreground disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
Save as Tree
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Export Controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={exportFormat}
|
||||
onChange={(e) => setExportFormat(e.target.value as typeof exportFormat)}
|
||||
aria-label="Export format"
|
||||
className={cn(
|
||||
'w-full rounded-md border border-input bg-background px-3 py-2 text-sm sm:w-auto',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||
)}
|
||||
>
|
||||
<option value="markdown">Markdown</option>
|
||||
<option value="text">Plain Text</option>
|
||||
<option value="html">HTML</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
disabled={isExporting}
|
||||
title="Copy to clipboard"
|
||||
className={cn(
|
||||
'rounded-md border border-input bg-background p-2 text-muted-foreground',
|
||||
'hover:bg-accent hover:text-accent-foreground disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePreview}
|
||||
disabled={isExporting}
|
||||
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'
|
||||
)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
{isExporting ? 'Loading...' : 'Preview'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -267,6 +375,24 @@ export function SessionDetailPage() {
|
||||
format={exportFormat}
|
||||
onDownload={handleDownload}
|
||||
/>
|
||||
|
||||
{/* Save as Tree Modal */}
|
||||
<SaveSessionAsTreeModal
|
||||
isOpen={showSaveAsTreeModal}
|
||||
onClose={() => setShowSaveAsTreeModal(false)}
|
||||
onSave={handleSaveAsTree}
|
||||
defaultTreeName={getDefaultTreeName()}
|
||||
isSaving={isSavingTree}
|
||||
/>
|
||||
|
||||
{/* Step Rating Modal */}
|
||||
<StepRatingModal
|
||||
isOpen={showRatingModal}
|
||||
onClose={() => setShowRatingModal(false)}
|
||||
onSubmit={handleSubmitRatings}
|
||||
librarySteps={librarySteps}
|
||||
isSaving={isSavingRatings}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -27,6 +27,7 @@ export function TreeLibraryPage() {
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [showDrafts, setShowDrafts] = useState(false)
|
||||
|
||||
// View preferences from store
|
||||
const { treeLibraryView, setTreeLibraryView, treeLibrarySortBy, setTreeLibrarySortBy } =
|
||||
@@ -45,6 +46,9 @@ export function TreeLibraryPage() {
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
// Fork state
|
||||
const [isForkingTree, setIsForkingTree] = useState(false)
|
||||
|
||||
const loadFolders = useCallback(async () => {
|
||||
try {
|
||||
const foldersData = await foldersApi.list()
|
||||
@@ -56,7 +60,7 @@ export function TreeLibraryPage() {
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [selectedCategoryId, selectedTags, selectedFolderId, treeLibrarySortBy])
|
||||
}, [selectedCategoryId, selectedTags, selectedFolderId, treeLibrarySortBy, showDrafts])
|
||||
|
||||
// Load folders on mount and listen for changes
|
||||
useEffect(() => {
|
||||
@@ -75,6 +79,7 @@ export function TreeLibraryPage() {
|
||||
tags: selectedTags.length > 0 ? selectedTags.join(',') : undefined,
|
||||
folder_id: selectedFolderId || undefined,
|
||||
sort_by: treeLibrarySortBy,
|
||||
include_drafts: showDrafts || undefined,
|
||||
}),
|
||||
categoriesApi.list(),
|
||||
])
|
||||
@@ -156,6 +161,21 @@ export function TreeLibraryPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleForkTree = async (treeId: string) => {
|
||||
if (isForkingTree) return
|
||||
setIsForkingTree(true)
|
||||
try {
|
||||
await treesApi.fork(treeId)
|
||||
toast.success('Tree forked successfully')
|
||||
navigate('/my-trees')
|
||||
} catch (err) {
|
||||
console.error('Failed to fork tree:', err)
|
||||
toast.error('Failed to fork tree')
|
||||
} finally {
|
||||
setIsForkingTree(false)
|
||||
}
|
||||
}
|
||||
|
||||
const hasActiveFilters =
|
||||
selectedCategoryId || selectedTags.length > 0 || searchQuery || selectedFolderId
|
||||
|
||||
@@ -257,7 +277,18 @@ export function TreeLibraryPage() {
|
||||
|
||||
{/* View Controls */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<SortDropdown value={treeLibrarySortBy} onChange={setTreeLibrarySortBy} />
|
||||
<div className="flex items-center gap-4">
|
||||
<SortDropdown value={treeLibrarySortBy} onChange={setTreeLibrarySortBy} />
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showDrafts}
|
||||
onChange={(e) => setShowDrafts(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input text-primary focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">Show my drafts</span>
|
||||
</label>
|
||||
</div>
|
||||
<ViewToggle view={treeLibraryView} onChange={setTreeLibraryView} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -333,6 +364,7 @@ export function TreeLibraryPage() {
|
||||
setTreeToDelete(tree)
|
||||
setShowDeleteConfirm(true)
|
||||
}}
|
||||
onForkTree={handleForkTree}
|
||||
/>
|
||||
)}
|
||||
{treeLibraryView === 'list' && (
|
||||
@@ -345,6 +377,7 @@ export function TreeLibraryPage() {
|
||||
setTreeToDelete(tree)
|
||||
setShowDeleteConfirm(true)
|
||||
}}
|
||||
onForkTree={handleForkTree}
|
||||
/>
|
||||
)}
|
||||
{treeLibraryView === 'table' && (
|
||||
@@ -362,6 +395,7 @@ export function TreeLibraryPage() {
|
||||
sortBy as 'usage_count' | 'updated_at' | 'created_at' | 'name' | 'name_desc' | 'version'
|
||||
)
|
||||
}}
|
||||
onForkTree={handleForkTree}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
export { default as LoginPage } from './LoginPage'
|
||||
export { default as RegisterPage } from './RegisterPage'
|
||||
export { default as TreeLibraryPage } from './TreeLibraryPage'
|
||||
export { default as MyTreesPage } from './MyTreesPage'
|
||||
export { default as TreeNavigationPage } from './TreeNavigationPage'
|
||||
export { default as TreeEditorPage } from './TreeEditorPage'
|
||||
export { default as SessionHistoryPage } from './SessionHistoryPage'
|
||||
export { default as SessionDetailPage } from './SessionDetailPage'
|
||||
export { default as SettingsPage } from './SettingsPage'
|
||||
export { default as AccountSettingsPage } from './AccountSettingsPage'
|
||||
export { default as AdminCategoriesPage } from './AdminCategoriesPage'
|
||||
|
||||
Reference in New Issue
Block a user