fix: resolve infinite render loop when clicking Next in tutorial player
Added isInitializing flag to prevent onValueChange callback feedback loop during step transitions. When a new step initializes, AbacusReact would call onValueChange with the startValue, causing TutorialPlayer to set the same value again, creating an infinite loop. Also removed problematic auto-click play function from EditingMode story that was interfering with component state. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
290
apps/web/src/components/tutorial/TutorialEditor.stories.tsx
Normal file
290
apps/web/src/components/tutorial/TutorialEditor.stories.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { action } from '@storybook/addon-actions'
|
||||
import { TutorialEditor } from './TutorialEditor'
|
||||
import { DevAccessProvider } from '../../hooks/useAccessControl'
|
||||
import { getTutorialForEditor } from '../../utils/tutorialConverter'
|
||||
import { TutorialValidation } from '../../types/tutorial'
|
||||
|
||||
const meta: Meta<typeof TutorialEditor> = {
|
||||
title: 'Tutorial/TutorialEditor',
|
||||
component: TutorialEditor,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component: `
|
||||
The TutorialEditor component provides a comprehensive editing interface for creating and modifying tutorial content.
|
||||
It includes both the editor interface and an integrated preview player for testing changes.
|
||||
|
||||
## Features
|
||||
- Visual step editor with form-based editing
|
||||
- Real-time validation and error reporting
|
||||
- Integrated tutorial player for preview
|
||||
- Step management (add, duplicate, delete, reorder)
|
||||
- Tutorial metadata editing
|
||||
- Save/load functionality hooks
|
||||
- Access control integration
|
||||
|
||||
## Editor Capabilities
|
||||
- Edit tutorial metadata (title, description, category, difficulty, tags)
|
||||
- Manage tutorial steps with detailed form controls
|
||||
- Set start/target values and highlight configurations
|
||||
- Edit tooltips, error messages, and multi-step instructions
|
||||
- Validate tutorial structure and content
|
||||
- Preview changes in real-time
|
||||
`
|
||||
}
|
||||
}
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<DevAccessProvider>
|
||||
<div style={{ height: '100vh' }}>
|
||||
<Story />
|
||||
</div>
|
||||
</DevAccessProvider>
|
||||
)
|
||||
],
|
||||
tags: ['autodocs']
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const mockTutorial = getTutorialForEditor()
|
||||
|
||||
// Mock validation function that returns realistic validation results
|
||||
const mockValidate = async (tutorial: any): Promise<TutorialValidation> => {
|
||||
const errors = []
|
||||
const warnings = []
|
||||
|
||||
// Simulate some validation logic
|
||||
if (!tutorial.title.trim()) {
|
||||
errors.push({
|
||||
stepId: '',
|
||||
field: 'title',
|
||||
message: 'Tutorial title is required',
|
||||
severity: 'error' as const
|
||||
})
|
||||
}
|
||||
|
||||
if (tutorial.steps.length === 0) {
|
||||
errors.push({
|
||||
stepId: '',
|
||||
field: 'steps',
|
||||
message: 'Tutorial must have at least one step',
|
||||
severity: 'error' as const
|
||||
})
|
||||
}
|
||||
|
||||
// Add some warnings for demonstration
|
||||
if (tutorial.description.length < 50) {
|
||||
warnings.push({
|
||||
stepId: '',
|
||||
field: 'description',
|
||||
message: 'Tutorial description could be more detailed',
|
||||
severity: 'warning' as const
|
||||
})
|
||||
}
|
||||
|
||||
tutorial.steps.forEach((step: any, index: number) => {
|
||||
if (step.startValue === step.targetValue) {
|
||||
warnings.push({
|
||||
stepId: step.id,
|
||||
field: 'values',
|
||||
message: `Step ${index + 1}: Start and target values are the same`,
|
||||
severity: 'warning' as const
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings
|
||||
}
|
||||
}
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
tutorial: mockTutorial,
|
||||
onSave: action('save-tutorial'),
|
||||
onValidate: mockValidate,
|
||||
onPreview: action('preview-step')
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Default tutorial editor with the guided addition tutorial loaded for editing.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const EditingMode: Story = {
|
||||
args: {
|
||||
...Default.args
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: `
|
||||
Tutorial editor in editing mode. This story demonstrates:
|
||||
|
||||
**Editor Features:**
|
||||
- Click "Edit Tutorial" to enable editing mode
|
||||
- Modify tutorial metadata in the left sidebar
|
||||
- Click on steps to expand detailed editing forms
|
||||
- Add, duplicate, or delete steps using step controls
|
||||
- Use "Preview" buttons to test steps in the integrated player
|
||||
- Real-time validation shows errors and warnings
|
||||
|
||||
**Try These Actions:**
|
||||
1. Click "Edit Tutorial" to enable editing
|
||||
2. Modify the tutorial title or description
|
||||
3. Click on a step to expand its editing form
|
||||
4. Change step values and see validation updates
|
||||
5. Add a new step using the "+ Add Step" button
|
||||
6. Preview specific steps using the "Preview" buttons
|
||||
`
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const WithValidationErrors: Story = {
|
||||
args: {
|
||||
tutorial: {
|
||||
...mockTutorial,
|
||||
title: '', // This will trigger a validation error
|
||||
description: 'Short desc', // This will trigger a warning
|
||||
steps: mockTutorial.steps.map(step => ({
|
||||
...step,
|
||||
startValue: step.targetValue // This will trigger warnings
|
||||
}))
|
||||
},
|
||||
onSave: action('save-tutorial'),
|
||||
onValidate: mockValidate,
|
||||
onPreview: action('preview-step')
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Tutorial editor with validation errors and warnings to demonstrate the validation system.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const MinimalTutorial: Story = {
|
||||
args: {
|
||||
tutorial: {
|
||||
...mockTutorial,
|
||||
steps: mockTutorial.steps.slice(0, 2) // Only 2 steps for easier editing demo
|
||||
},
|
||||
onSave: action('save-tutorial'),
|
||||
onValidate: mockValidate,
|
||||
onPreview: action('preview-step')
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Tutorial editor with a minimal tutorial (2 steps) for easier demonstration of editing features.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const ReadOnlyPreview: Story = {
|
||||
args: {
|
||||
tutorial: mockTutorial,
|
||||
onSave: undefined, // No save function = read-only mode
|
||||
onValidate: mockValidate,
|
||||
onPreview: action('preview-step')
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Tutorial editor in read-only mode (no save function provided) showing the preview functionality.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomTutorial: Story = {
|
||||
args: {
|
||||
tutorial: {
|
||||
id: 'custom-tutorial',
|
||||
title: 'Custom Math Tutorial',
|
||||
description: 'A custom tutorial for demonstrating the editor capabilities with different content.',
|
||||
category: 'Advanced Operations',
|
||||
difficulty: 'intermediate' as const,
|
||||
estimatedDuration: 15,
|
||||
steps: [
|
||||
{
|
||||
id: 'custom-1',
|
||||
title: 'Custom Step 1',
|
||||
problem: '5 + 5',
|
||||
description: 'Add 5 to 5 using the heaven bead',
|
||||
startValue: 5,
|
||||
targetValue: 10,
|
||||
highlightBeads: [{ columnIndex: 0, beadType: 'heaven' as const }],
|
||||
expectedAction: 'add' as const,
|
||||
actionDescription: 'Click the heaven bead',
|
||||
tooltip: {
|
||||
content: 'Using heaven bead for 10',
|
||||
explanation: 'When adding 5 to 5, use the tens place heaven bead'
|
||||
},
|
||||
errorMessages: {
|
||||
wrongBead: 'Click the tens place heaven bead',
|
||||
wrongAction: 'Move the bead down to activate',
|
||||
hint: 'Think about place value'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'custom-2',
|
||||
title: 'Custom Step 2',
|
||||
problem: '7 + 8',
|
||||
description: 'A more complex addition problem',
|
||||
startValue: 7,
|
||||
targetValue: 15,
|
||||
highlightBeads: [
|
||||
{ columnIndex: 1, beadType: 'heaven' as const },
|
||||
{ columnIndex: 0, beadType: 'heaven' as const }
|
||||
],
|
||||
expectedAction: 'multi-step' as const,
|
||||
actionDescription: 'Activate both heaven beads for 15',
|
||||
multiStepInstructions: [
|
||||
'Click the tens place heaven bead',
|
||||
'Click the ones place heaven bead'
|
||||
],
|
||||
tooltip: {
|
||||
content: 'Complex addition',
|
||||
explanation: '7 + 8 = 15, which needs both tens and ones heaven beads'
|
||||
},
|
||||
errorMessages: {
|
||||
wrongBead: 'Follow the two-step process',
|
||||
wrongAction: 'Activate both heaven beads',
|
||||
hint: '15 = 10 + 5'
|
||||
}
|
||||
}
|
||||
],
|
||||
tags: ['custom', 'demo', 'advanced'],
|
||||
author: 'Demo Author',
|
||||
version: '1.0.0',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
isPublished: false
|
||||
},
|
||||
onSave: action('save-tutorial'),
|
||||
onValidate: mockValidate,
|
||||
onPreview: action('preview-step')
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Tutorial editor with custom tutorial content to demonstrate editing different types of mathematical operations.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
568
apps/web/src/components/tutorial/TutorialPlayer.tsx
Normal file
568
apps/web/src/components/tutorial/TutorialPlayer.tsx
Normal file
@@ -0,0 +1,568 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
import { css } from '../../styled-system/css'
|
||||
import { stack, hstack, vstack } from '../../styled-system/patterns'
|
||||
import { Tutorial, TutorialStep, TutorialEvent, NavigationState, UIState } from '../../types/tutorial'
|
||||
|
||||
interface TutorialPlayerProps {
|
||||
tutorial: Tutorial
|
||||
initialStepIndex?: number
|
||||
isDebugMode?: boolean
|
||||
showDebugPanel?: boolean
|
||||
onStepChange?: (stepIndex: number, step: TutorialStep) => void
|
||||
onStepComplete?: (stepIndex: number, step: TutorialStep, success: boolean) => void
|
||||
onTutorialComplete?: (score: number, timeSpent: number) => void
|
||||
onEvent?: (event: TutorialEvent) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function TutorialPlayer({
|
||||
tutorial,
|
||||
initialStepIndex = 0,
|
||||
isDebugMode = false,
|
||||
showDebugPanel = false,
|
||||
onStepChange,
|
||||
onStepComplete,
|
||||
onTutorialComplete,
|
||||
onEvent,
|
||||
className
|
||||
}: TutorialPlayerProps) {
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(initialStepIndex)
|
||||
const [currentValue, setCurrentValue] = useState(0)
|
||||
const [isStepCompleted, setIsStepCompleted] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isInitializing, setIsInitializing] = useState(false)
|
||||
const [events, setEvents] = useState<TutorialEvent[]>([])
|
||||
const [startTime] = useState(Date.now())
|
||||
const [stepStartTime, setStepStartTime] = useState(Date.now())
|
||||
const [uiState, setUIState] = useState<UIState>({
|
||||
isPlaying: true,
|
||||
isPaused: false,
|
||||
isEditing: false,
|
||||
showDebugPanel,
|
||||
showStepList: false,
|
||||
autoAdvance: false,
|
||||
playbackSpeed: 1
|
||||
})
|
||||
|
||||
const currentStep = tutorial.steps[currentStepIndex]
|
||||
const beadRefs = useRef<Map<string, SVGElement>>(new Map())
|
||||
|
||||
// Navigation state
|
||||
const navigationState: NavigationState = {
|
||||
currentStepIndex,
|
||||
canGoNext: currentStepIndex < tutorial.steps.length - 1,
|
||||
canGoPrevious: currentStepIndex > 0,
|
||||
totalSteps: tutorial.steps.length,
|
||||
completionPercentage: (currentStepIndex / tutorial.steps.length) * 100
|
||||
}
|
||||
|
||||
// Event logging
|
||||
const logEvent = useCallback((event: TutorialEvent) => {
|
||||
setEvents(prev => [...prev, event])
|
||||
onEvent?.(event)
|
||||
}, [onEvent])
|
||||
|
||||
// Initialize step
|
||||
useEffect(() => {
|
||||
if (currentStep) {
|
||||
setIsInitializing(true)
|
||||
setCurrentValue(currentStep.startValue)
|
||||
setIsStepCompleted(false)
|
||||
setError(null)
|
||||
setStepStartTime(Date.now())
|
||||
|
||||
logEvent({
|
||||
type: 'STEP_STARTED',
|
||||
stepId: currentStep.id,
|
||||
timestamp: new Date()
|
||||
})
|
||||
|
||||
onStepChange?.(currentStepIndex, currentStep)
|
||||
|
||||
// Clear initialization flag after a brief delay
|
||||
setTimeout(() => setIsInitializing(false), 50)
|
||||
}
|
||||
}, [currentStepIndex, currentStep, onStepChange, logEvent])
|
||||
|
||||
// Check if step is completed
|
||||
useEffect(() => {
|
||||
if (currentStep && currentValue === currentStep.targetValue && !isStepCompleted) {
|
||||
setIsStepCompleted(true)
|
||||
setError(null)
|
||||
|
||||
logEvent({
|
||||
type: 'STEP_COMPLETED',
|
||||
stepId: currentStep.id,
|
||||
success: true,
|
||||
timestamp: new Date()
|
||||
})
|
||||
|
||||
onStepComplete?.(currentStepIndex, currentStep, true)
|
||||
|
||||
// Auto-advance if enabled
|
||||
if (uiState.autoAdvance && navigationState.canGoNext) {
|
||||
setTimeout(() => goToNextStep(), 1500)
|
||||
}
|
||||
}
|
||||
}, [currentValue, currentStep, isStepCompleted, uiState.autoAdvance, navigationState.canGoNext, logEvent, onStepComplete, currentStepIndex])
|
||||
|
||||
// Navigation functions
|
||||
const goToStep = useCallback((stepIndex: number) => {
|
||||
if (stepIndex >= 0 && stepIndex < tutorial.steps.length) {
|
||||
setCurrentStepIndex(stepIndex)
|
||||
}
|
||||
}, [tutorial.steps.length])
|
||||
|
||||
const goToNextStep = useCallback(() => {
|
||||
if (navigationState.canGoNext) {
|
||||
goToStep(currentStepIndex + 1)
|
||||
} else if (currentStepIndex === tutorial.steps.length - 1) {
|
||||
// Tutorial completed
|
||||
const timeSpent = (Date.now() - startTime) / 1000
|
||||
const score = events.filter(e => e.type === 'STEP_COMPLETED' && e.success).length / tutorial.steps.length * 100
|
||||
|
||||
logEvent({
|
||||
type: 'TUTORIAL_COMPLETED',
|
||||
tutorialId: tutorial.id,
|
||||
score,
|
||||
timestamp: new Date()
|
||||
})
|
||||
|
||||
onTutorialComplete?.(score, timeSpent)
|
||||
}
|
||||
}, [navigationState.canGoNext, currentStepIndex, tutorial.steps.length, tutorial.id, startTime, events, logEvent, onTutorialComplete, goToStep])
|
||||
|
||||
const goToPreviousStep = useCallback(() => {
|
||||
if (navigationState.canGoPrevious) {
|
||||
goToStep(currentStepIndex - 1)
|
||||
}
|
||||
}, [navigationState.canGoPrevious, currentStepIndex, goToStep])
|
||||
|
||||
// Abacus event handlers
|
||||
const handleValueChange = useCallback((newValue: number) => {
|
||||
// Ignore value changes during step initialization to prevent loops
|
||||
if (isInitializing) {
|
||||
return
|
||||
}
|
||||
|
||||
const oldValue = currentValue
|
||||
setCurrentValue(newValue)
|
||||
|
||||
logEvent({
|
||||
type: 'VALUE_CHANGED',
|
||||
stepId: currentStep.id,
|
||||
oldValue,
|
||||
newValue,
|
||||
timestamp: new Date()
|
||||
})
|
||||
}, [currentValue, currentStep, logEvent, isInitializing])
|
||||
|
||||
const handleBeadClick = useCallback((beadInfo: any) => {
|
||||
logEvent({
|
||||
type: 'BEAD_CLICKED',
|
||||
stepId: currentStep.id,
|
||||
beadInfo,
|
||||
timestamp: new Date()
|
||||
})
|
||||
|
||||
// Check if this is the correct action
|
||||
if (currentStep.highlightBeads && Array.isArray(currentStep.highlightBeads)) {
|
||||
const isCorrectBead = currentStep.highlightBeads.some(highlight =>
|
||||
highlight.columnIndex === beadInfo.columnIndex &&
|
||||
highlight.beadType === beadInfo.beadType &&
|
||||
(highlight.position === undefined || highlight.position === beadInfo.position)
|
||||
)
|
||||
|
||||
if (!isCorrectBead) {
|
||||
setError(currentStep.errorMessages.wrongBead)
|
||||
|
||||
logEvent({
|
||||
type: 'ERROR_OCCURRED',
|
||||
stepId: currentStep.id,
|
||||
error: currentStep.errorMessages.wrongBead,
|
||||
timestamp: new Date()
|
||||
})
|
||||
} else {
|
||||
setError(null)
|
||||
}
|
||||
}
|
||||
}, [currentStep, logEvent])
|
||||
|
||||
const handleBeadRef = useCallback((bead: any, element: SVGElement | null) => {
|
||||
const key = `${bead.columnIndex}-${bead.type}-${bead.position}`
|
||||
if (element) {
|
||||
beadRefs.current.set(key, element)
|
||||
} else {
|
||||
beadRefs.current.delete(key)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// UI state updaters
|
||||
const toggleDebugPanel = useCallback(() => {
|
||||
setUIState(prev => ({ ...prev, showDebugPanel: !prev.showDebugPanel }))
|
||||
}, [])
|
||||
|
||||
const toggleStepList = useCallback(() => {
|
||||
setUIState(prev => ({ ...prev, showStepList: !prev.showStepList }))
|
||||
}, [])
|
||||
|
||||
const toggleAutoAdvance = useCallback(() => {
|
||||
setUIState(prev => ({ ...prev, autoAdvance: !prev.autoAdvance }))
|
||||
}, [])
|
||||
|
||||
if (!currentStep) {
|
||||
return <div>No steps available</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
minHeight: '600px'
|
||||
})} ${className || ''}`}>
|
||||
{/* Header */}
|
||||
<div className={css({
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
p: 4,
|
||||
bg: 'white'
|
||||
})}>
|
||||
<div className={hstack({ justifyContent: 'space-between', alignItems: 'center' })}>
|
||||
<div>
|
||||
<h1 className={css({ fontSize: 'xl', fontWeight: 'bold' })}>
|
||||
{tutorial.title}
|
||||
</h1>
|
||||
<p className={css({ fontSize: 'sm', color: 'gray.600' })}>
|
||||
Step {currentStepIndex + 1} of {tutorial.steps.length}: {currentStep.title}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={hstack({ gap: 2 })}>
|
||||
{isDebugMode && (
|
||||
<>
|
||||
<button
|
||||
onClick={toggleDebugPanel}
|
||||
className={css({
|
||||
px: 3,
|
||||
py: 1,
|
||||
fontSize: 'sm',
|
||||
border: '1px solid',
|
||||
borderColor: 'blue.300',
|
||||
borderRadius: 'md',
|
||||
bg: uiState.showDebugPanel ? 'blue.100' : 'white',
|
||||
color: 'blue.700',
|
||||
cursor: 'pointer',
|
||||
_hover: { bg: 'blue.50' }
|
||||
})}
|
||||
>
|
||||
Debug
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleStepList}
|
||||
className={css({
|
||||
px: 3,
|
||||
py: 1,
|
||||
fontSize: 'sm',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.300',
|
||||
borderRadius: 'md',
|
||||
bg: uiState.showStepList ? 'gray.100' : 'white',
|
||||
cursor: 'pointer',
|
||||
_hover: { bg: 'gray.50' }
|
||||
})}
|
||||
>
|
||||
Steps
|
||||
</button>
|
||||
<label className={hstack({ gap: 2, fontSize: 'sm' })}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={uiState.autoAdvance}
|
||||
onChange={toggleAutoAdvance}
|
||||
/>
|
||||
Auto-advance
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className={css({ mt: 2, bg: 'gray.200', borderRadius: 'full', h: 2 })}>
|
||||
<div
|
||||
className={css({
|
||||
bg: 'blue.500',
|
||||
h: 'full',
|
||||
borderRadius: 'full',
|
||||
transition: 'width 0.3s ease'
|
||||
})}
|
||||
style={{ width: `${navigationState.completionPercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={hstack({ flex: 1, gap: 0 })}>
|
||||
{/* Step list sidebar */}
|
||||
{uiState.showStepList && (
|
||||
<div className={css({
|
||||
w: '300px',
|
||||
borderRight: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
bg: 'gray.50',
|
||||
p: 4,
|
||||
overflowY: 'auto'
|
||||
})}>
|
||||
<h3 className={css({ fontWeight: 'bold', mb: 3 })}>Tutorial Steps</h3>
|
||||
<div className={stack({ gap: 2 })}>
|
||||
{tutorial.steps && Array.isArray(tutorial.steps) ? tutorial.steps.map((step, index) => (
|
||||
<button
|
||||
key={step.id}
|
||||
onClick={() => goToStep(index)}
|
||||
className={css({
|
||||
p: 3,
|
||||
textAlign: 'left',
|
||||
border: '1px solid',
|
||||
borderColor: index === currentStepIndex ? 'blue.300' : 'gray.200',
|
||||
borderRadius: 'md',
|
||||
bg: index === currentStepIndex ? 'blue.50' : 'white',
|
||||
cursor: 'pointer',
|
||||
_hover: { bg: index === currentStepIndex ? 'blue.100' : 'gray.50' }
|
||||
})}
|
||||
>
|
||||
<div className={css({ fontSize: 'sm', fontWeight: 'medium' })}>
|
||||
{index + 1}. {step.title}
|
||||
</div>
|
||||
<div className={css({ fontSize: 'xs', color: 'gray.600', mt: 1 })}>
|
||||
{step.problem}
|
||||
</div>
|
||||
</button>
|
||||
)) : (
|
||||
<div className={css({ color: 'gray.500', textAlign: 'center', py: 4 })}>
|
||||
No tutorial steps available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<div className={css({ flex: 1, display: 'flex', flexDirection: 'column' })}>
|
||||
{/* Step content */}
|
||||
<div className={css({ flex: 1, p: 6 })}>
|
||||
<div className={vstack({ gap: 6, alignItems: 'center' })}>
|
||||
{/* Step instructions */}
|
||||
<div className={css({ textAlign: 'center', maxW: '600px' })}>
|
||||
<h2 className={css({ fontSize: '2xl', fontWeight: 'bold', mb: 2 })}>
|
||||
{currentStep.problem}
|
||||
</h2>
|
||||
<p className={css({ fontSize: 'lg', color: 'gray.700', mb: 4 })}>
|
||||
{currentStep.description}
|
||||
</p>
|
||||
<p className={css({ fontSize: 'md', color: 'blue.600' })}>
|
||||
{currentStep.actionDescription}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className={css({
|
||||
p: 4,
|
||||
bg: 'red.50',
|
||||
border: '1px solid',
|
||||
borderColor: 'red.200',
|
||||
borderRadius: 'md',
|
||||
color: 'red.700',
|
||||
maxW: '600px'
|
||||
})}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success message */}
|
||||
{isStepCompleted && (
|
||||
<div className={css({
|
||||
p: 4,
|
||||
bg: 'green.50',
|
||||
border: '1px solid',
|
||||
borderColor: 'green.200',
|
||||
borderRadius: 'md',
|
||||
color: 'green.700',
|
||||
maxW: '600px'
|
||||
})}>
|
||||
Great! You completed this step correctly.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Abacus */}
|
||||
<div className={css({
|
||||
bg: 'white',
|
||||
border: '2px solid',
|
||||
borderColor: 'gray.200',
|
||||
borderRadius: 'lg',
|
||||
p: 6,
|
||||
shadow: 'lg'
|
||||
})}>
|
||||
<AbacusReact
|
||||
value={currentValue}
|
||||
columns={5}
|
||||
interactive={true}
|
||||
animated={true}
|
||||
scaleFactor={2.5}
|
||||
colorScheme="place-value"
|
||||
highlightBeads={currentStep.highlightBeads}
|
||||
customStyles={currentStep.highlightBeads && Array.isArray(currentStep.highlightBeads) ? {
|
||||
beads: currentStep.highlightBeads.reduce((acc, highlight) => ({
|
||||
...acc,
|
||||
[highlight.columnIndex]: {
|
||||
[highlight.beadType]: highlight.beadType === 'earth' && highlight.position !== undefined
|
||||
? { [highlight.position]: { fill: '#fbbf24', stroke: '#f59e0b', strokeWidth: 3 } }
|
||||
: { fill: '#fbbf24', stroke: '#f59e0b', strokeWidth: 3 }
|
||||
}
|
||||
}), {})
|
||||
} : undefined}
|
||||
onValueChange={handleValueChange}
|
||||
callbacks={{
|
||||
onBeadClick: handleBeadClick,
|
||||
onBeadRef: handleBeadRef
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tooltip */}
|
||||
{currentStep.tooltip && (
|
||||
<div className={css({
|
||||
maxW: '500px',
|
||||
p: 4,
|
||||
bg: 'yellow.50',
|
||||
border: '1px solid',
|
||||
borderColor: 'yellow.200',
|
||||
borderRadius: 'md'
|
||||
})}>
|
||||
<h4 className={css({ fontWeight: 'bold', color: 'yellow.800', mb: 1 })}>
|
||||
{currentStep.tooltip.content}
|
||||
</h4>
|
||||
<p className={css({ fontSize: 'sm', color: 'yellow.700' })}>
|
||||
{currentStep.tooltip.explanation}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation controls */}
|
||||
<div className={css({
|
||||
borderTop: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
p: 4,
|
||||
bg: 'gray.50'
|
||||
})}>
|
||||
<div className={hstack({ justifyContent: 'space-between' })}>
|
||||
<button
|
||||
onClick={goToPreviousStep}
|
||||
disabled={!navigationState.canGoPrevious}
|
||||
className={css({
|
||||
px: 4,
|
||||
py: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.300',
|
||||
borderRadius: 'md',
|
||||
bg: 'white',
|
||||
cursor: navigationState.canGoPrevious ? 'pointer' : 'not-allowed',
|
||||
opacity: navigationState.canGoPrevious ? 1 : 0.5,
|
||||
_hover: navigationState.canGoPrevious ? { bg: 'gray.50' } : {}
|
||||
})}
|
||||
>
|
||||
← Previous
|
||||
</button>
|
||||
|
||||
<div className={css({ fontSize: 'sm', color: 'gray.600' })}>
|
||||
Step {currentStepIndex + 1} of {navigationState.totalSteps}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={goToNextStep}
|
||||
disabled={!navigationState.canGoNext && !isStepCompleted}
|
||||
className={css({
|
||||
px: 4,
|
||||
py: 2,
|
||||
border: '1px solid',
|
||||
borderColor: navigationState.canGoNext || isStepCompleted ? 'blue.300' : 'gray.300',
|
||||
borderRadius: 'md',
|
||||
bg: navigationState.canGoNext || isStepCompleted ? 'blue.500' : 'gray.200',
|
||||
color: navigationState.canGoNext || isStepCompleted ? 'white' : 'gray.500',
|
||||
cursor: navigationState.canGoNext || isStepCompleted ? 'pointer' : 'not-allowed',
|
||||
_hover: navigationState.canGoNext || isStepCompleted ? { bg: 'blue.600' } : {}
|
||||
})}
|
||||
>
|
||||
{navigationState.canGoNext ? 'Next →' : 'Complete Tutorial'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Debug panel */}
|
||||
{uiState.showDebugPanel && (
|
||||
<div className={css({
|
||||
w: '400px',
|
||||
borderLeft: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
bg: 'gray.50',
|
||||
p: 4,
|
||||
overflowY: 'auto'
|
||||
})}>
|
||||
<h3 className={css({ fontWeight: 'bold', mb: 3 })}>Debug Panel</h3>
|
||||
|
||||
<div className={stack({ gap: 4 })}>
|
||||
{/* Current state */}
|
||||
<div>
|
||||
<h4 className={css({ fontWeight: 'medium', mb: 2 })}>Current State</h4>
|
||||
<div className={css({ fontSize: 'sm', fontFamily: 'mono', bg: 'white', p: 2, borderRadius: 'md' })}>
|
||||
<div>Step: {currentStepIndex + 1}/{navigationState.totalSteps}</div>
|
||||
<div>Value: {currentValue}</div>
|
||||
<div>Target: {currentStep.targetValue}</div>
|
||||
<div>Completed: {isStepCompleted ? 'Yes' : 'No'}</div>
|
||||
<div>Time: {Math.round((Date.now() - stepStartTime) / 1000)}s</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Event log */}
|
||||
<div>
|
||||
<h4 className={css({ fontWeight: 'medium', mb: 2 })}>Event Log</h4>
|
||||
<div className={css({
|
||||
maxH: '300px',
|
||||
overflowY: 'auto',
|
||||
fontSize: 'xs',
|
||||
fontFamily: 'mono',
|
||||
bg: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
borderRadius: 'md'
|
||||
})}>
|
||||
{events.slice(-20).reverse().map((event, index) => (
|
||||
<div key={index} className={css({ p: 2, borderBottom: '1px solid', borderColor: 'gray.100' })}>
|
||||
<div className={css({ fontWeight: 'bold', color: 'blue.600' })}>
|
||||
{event.type}
|
||||
</div>
|
||||
<div className={css({ color: 'gray.600' })}>
|
||||
{event.timestamp.toLocaleTimeString()}
|
||||
</div>
|
||||
{event.type === 'VALUE_CHANGED' && (
|
||||
<div>{event.oldValue} → {event.newValue}</div>
|
||||
)}
|
||||
{event.type === 'ERROR_OCCURRED' && (
|
||||
<div className={css({ color: 'red.600' })}>{event.error}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user