this repo has no description
at fix-ts-uint8array 387 lines 12 kB view raw
1import { useEffect, useCallback, useMemo } from 'react'; 2import { useParams, useNavigate } from 'react-router-dom'; 3import Box from '@mui/material/Box'; 4import Button from '@mui/material/Button'; 5import IconButton from '@mui/material/IconButton'; 6import Typography from '@mui/material/Typography'; 7import CircularProgress from '@mui/material/CircularProgress'; 8import CloseIcon from '@mui/icons-material/Close'; 9import { useLesson } from '../hooks/useLesson'; 10import { useSpeech } from '../hooks/useSpeech'; 11import { getLesson } from '../content/loader'; 12import { apiClient } from '../api/client'; 13import { ProgressBar } from '../components/layout/ProgressBar'; 14import { HeartsDisplay } from '../components/common/HeartsDisplay'; 15import { MultipleChoice } from '../components/exercises/MultipleChoice'; 16import { Translation, checkTranslation } from '../components/exercises/Translation'; 17import { MatchingPairs } from '../components/exercises/MatchingPairs'; 18import { FillInTheBlank } from '../components/exercises/FillInTheBlank'; 19import { SpeakExercise } from '../components/exercises/SpeakExercise'; 20import { CorrectBanner } from '../components/feedback/CorrectBanner'; 21import { IncorrectBanner } from '../components/feedback/IncorrectBanner'; 22import { SpeechBubble } from '../components/mascot/SpeechBubble'; 23import type { Exercise } from '../types/lesson'; 24 25function getCorrectAnswer(exercise: Exercise): string { 26 switch (exercise.type) { 27 case 'multiple-choice': 28 return exercise.choices[exercise.correctIndex]; 29 case 'translation': 30 return exercise.acceptedAnswers[0]; 31 case 'matching-pairs': 32 return exercise.pairs.map((p) => `${p.left} = ${p.right}`).join(', '); 33 case 'fill-in-the-blank': 34 return exercise.blank; 35 case 'speak': 36 return exercise.acceptedAnswers[0]; 37 } 38} 39 40function isAnswerCorrect(exercise: Exercise, answer: unknown): boolean { 41 switch (exercise.type) { 42 case 'multiple-choice': 43 return answer === exercise.correctIndex; 44 45 case 'translation': 46 return checkTranslation( 47 (answer as string) || '', 48 exercise.acceptedAnswers, 49 ); 50 51 case 'matching-pairs': { 52 const pairs = answer as Array<[string, string]> | null; 53 return pairs != null && pairs.length === exercise.pairs.length; 54 } 55 56 case 'fill-in-the-blank': { 57 const input = ((answer as string) || '').trim().toLowerCase(); 58 return input === exercise.blank.trim().toLowerCase(); 59 } 60 61 case 'speak': { 62 const spoken = ((answer as string) || '').trim().toLowerCase(); 63 return exercise.acceptedAnswers.some( 64 (a) => a.trim().toLowerCase() === spoken, 65 ); 66 } 67 } 68} 69 70export function LessonPage() { 71 const { topicId, lessonId } = useParams<{ 72 topicId: string; 73 lessonId: string; 74 }>(); 75 const navigate = useNavigate(); 76 const { 77 state, 78 setExercises, 79 restoreState, 80 selectAnswer, 81 checkAnswer, 82 nextExercise, 83 } = useLesson(); 84 85 const { speak } = useSpeech(); 86 87 const lesson = useMemo( 88 () => (topicId && lessonId ? getLesson(topicId, lessonId) : null), 89 [topicId, lessonId], 90 ); 91 92 // Load lesson exercises and restore saved state if available 93 useEffect(() => { 94 if (!lesson || !topicId || !lessonId) return; 95 96 interface SavedState { 97 current_index: number; 98 hearts: number; 99 correct_count: number; 100 } 101 102 apiClient 103 .get<SavedState | null>(`/api/lesson-state/${topicId}/${lessonId}`) 104 .then((saved) => { 105 if (saved && saved.current_index > 0) { 106 restoreState( 107 lesson.exercises, 108 saved.current_index, 109 saved.hearts, 110 saved.correct_count, 111 ); 112 } else { 113 setExercises(lesson.exercises); 114 } 115 }) 116 .catch(() => { 117 // If fetching saved state fails, just start fresh 118 setExercises(lesson.exercises); 119 }); 120 }, [lesson, topicId, lessonId, setExercises, restoreState]); 121 122 const currentExercise: Exercise | undefined = 123 state.exercises[state.currentIndex]; 124 125 // Auto-play pronunciation when a new task is loaded 126 useEffect(() => { 127 if (!currentExercise || state.isChecked) return; 128 129 let textToSpeak: string | null = null; 130 131 if (currentExercise.type === 'speak') { 132 textToSpeak = currentExercise.phrase; 133 } else if (currentExercise.promptAudio) { 134 textToSpeak = currentExercise.audioText ?? currentExercise.prompt; 135 } 136 137 if (textToSpeak) { 138 speak(textToSpeak); 139 } 140 // Only trigger when the exercise index changes, not on every re-render 141 // eslint-disable-next-line react-hooks/exhaustive-deps 142 }, [state.currentIndex, state.exercises]); 143 144 const handleCheck = useCallback(() => { 145 if (!currentExercise) return; 146 const correct = isAnswerCorrect(currentExercise, state.selectedAnswer); 147 checkAnswer(correct); 148 }, [currentExercise, state.selectedAnswer, checkAnswer]); 149 150 const handleContinue = useCallback(() => { 151 if (!topicId || !lessonId) return; 152 nextExercise(); 153 // Save progress after advancing to next exercise. 154 // Use current state values which reflect the CHECK_ANSWER result. 155 const nextIndex = state.currentIndex + 1; 156 const isLast = nextIndex >= state.exercises.length; 157 if (!isLast) { 158 apiClient 159 .put('/api/lesson-state', { 160 topic_id: topicId, 161 lesson_id: lessonId, 162 current_index: nextIndex, 163 hearts: state.hearts, 164 correct_count: state.correctCount, 165 }) 166 .catch(() => { 167 // Save failed silently - not critical 168 }); 169 } 170 }, [nextExercise, topicId, lessonId, state.currentIndex, state.exercises.length, state.hearts, state.correctCount]); 171 172 // Navigate to review when finished 173 useEffect(() => { 174 if (state.isFinished && lesson) { 175 navigate('/review', { 176 state: { 177 correctCount: state.correctCount, 178 total: state.exercises.length, 179 xp: lesson.xpReward, 180 topicId: lesson.topicId, 181 lessonId: lesson.id, 182 hearts: state.hearts, 183 }, 184 replace: true, 185 }); 186 } 187 }, [state.isFinished, state.correctCount, state.exercises.length, state.hearts, lesson, navigate]); 188 189 if (!lesson) { 190 return ( 191 <Box 192 sx={{ 193 display: 'flex', 194 flexDirection: 'column', 195 alignItems: 'center', 196 justifyContent: 'center', 197 flex: 1, 198 py: 8, 199 }} 200 > 201 <Typography color="text.secondary">Lesson not found</Typography> 202 <Button 203 variant="contained" 204 onClick={() => navigate('/home')} 205 sx={{ mt: 2 }} 206 > 207 Go Home 208 </Button> 209 </Box> 210 ); 211 } 212 213 if (state.exercises.length === 0) { 214 return ( 215 <Box 216 sx={{ 217 display: 'flex', 218 justifyContent: 'center', 219 alignItems: 'center', 220 flex: 1, 221 }} 222 > 223 <CircularProgress color="primary" /> 224 </Box> 225 ); 226 } 227 228 if (!currentExercise) return null; 229 230 const hasAnswer = state.selectedAnswer != null && state.selectedAnswer !== ''; 231 232 const showLowHearts = state.hearts > 0 && state.hearts <= 2 && !state.isChecked; 233 const lowHeartMsg = state.hearts === 1 ? 'Kaya mo pa yan!' : 'Konting tiis pa!'; 234 235 return ( 236 <Box 237 sx={{ 238 display: 'flex', 239 flexDirection: 'column', 240 flex: 1, 241 maxWidth: 600, 242 mx: 'auto', 243 width: '100%', 244 p: 2, 245 }} 246 > 247 {/* Top bar: close, progress, hearts */} 248 <Box 249 sx={{ 250 display: 'flex', 251 alignItems: 'center', 252 gap: 2, 253 mb: 4, 254 }} 255 > 256 <IconButton 257 onClick={() => navigate('/home')} 258 sx={{ color: 'text.secondary' }} 259 > 260 <CloseIcon /> 261 </IconButton> 262 <Box sx={{ flex: 1 }}> 263 <ProgressBar 264 current={state.currentIndex} 265 total={state.exercises.length} 266 /> 267 </Box> 268 <HeartsDisplay hearts={state.hearts} /> 269 </Box> 270 271 {/* Rambi encouragement when hearts are low */} 272 {showLowHearts && ( 273 <Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}> 274 <SpeechBubble 275 mood={state.hearts === 1 ? 'sad' : 'encourage'} 276 size={48} 277 message={lowHeartMsg} 278 /> 279 </Box> 280 )} 281 282 {/* Exercise area */} 283 <Box 284 sx={{ 285 flex: 1, 286 display: 'flex', 287 flexDirection: 'column', 288 justifyContent: 'center', 289 pb: state.isChecked ? 12 : 0, 290 }} 291 > 292 {currentExercise.type === 'multiple-choice' && ( 293 <MultipleChoice 294 exercise={currentExercise} 295 selectedAnswer={ 296 typeof state.selectedAnswer === 'number' 297 ? state.selectedAnswer 298 : null 299 } 300 isChecked={state.isChecked} 301 isCorrect={state.isCorrect} 302 onSelect={(index) => selectAnswer(index)} 303 /> 304 )} 305 306 {currentExercise.type === 'translation' && ( 307 <Translation 308 exercise={currentExercise} 309 selectedAnswer={ 310 typeof state.selectedAnswer === 'string' 311 ? state.selectedAnswer 312 : null 313 } 314 isChecked={state.isChecked} 315 onSelect={(text) => selectAnswer(text)} 316 /> 317 )} 318 319 {currentExercise.type === 'matching-pairs' && ( 320 <MatchingPairs 321 exercise={currentExercise} 322 selectedAnswer={ 323 Array.isArray(state.selectedAnswer) 324 ? (state.selectedAnswer as Array<[string, string]>) 325 : null 326 } 327 isChecked={state.isChecked} 328 onSelect={(pairs) => selectAnswer(pairs)} 329 /> 330 )} 331 332 {currentExercise.type === 'fill-in-the-blank' && ( 333 <FillInTheBlank 334 exercise={currentExercise} 335 selectedAnswer={ 336 typeof state.selectedAnswer === 'string' 337 ? state.selectedAnswer 338 : null 339 } 340 isChecked={state.isChecked} 341 onSelect={(text) => selectAnswer(text)} 342 /> 343 )} 344 345 {currentExercise.type === 'speak' && ( 346 <SpeakExercise 347 exercise={currentExercise} 348 selectedAnswer={ 349 typeof state.selectedAnswer === 'string' 350 ? state.selectedAnswer 351 : null 352 } 353 isChecked={state.isChecked} 354 onSelect={(text) => selectAnswer(text)} 355 /> 356 )} 357 </Box> 358 359 {/* Bottom: Check button (Continue is now inside feedback banners) */} 360 {!state.isChecked && ( 361 <Box sx={{ py: 2 }}> 362 <Button 363 fullWidth 364 variant="contained" 365 color="primary" 366 disabled={!hasAnswer} 367 onClick={handleCheck} 368 sx={{ py: 1.5 }} 369 > 370 Check 371 </Button> 372 </Box> 373 )} 374 375 {/* Feedback banners with embedded Continue button */} 376 {state.isChecked && state.isCorrect === true && ( 377 <CorrectBanner onContinue={handleContinue} /> 378 )} 379 {state.isChecked && state.isCorrect === false && ( 380 <IncorrectBanner 381 correctAnswer={getCorrectAnswer(currentExercise)} 382 onContinue={handleContinue} 383 /> 384 )} 385 </Box> 386 ); 387}