dramaling-vocab-learning/frontend/app/learn-backup/page-v2-smaller.tsx

774 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { Navigation } from '@/components/Navigation'
import VoiceRecorder from '@/components/VoiceRecorder'
import LearningComplete from '@/components/LearningComplete'
import SegmentedProgressBar from '@/components/SegmentedProgressBar'
import { studySessionService, type StudySession, type CurrentTest, type Progress } from '@/lib/services/studySession'
export default function NewLearnPage() {
const router = useRouter()
const [mounted, setMounted] = useState(false)
// 會話狀態
const [session, setSession] = useState<StudySession | null>(null)
const [currentTest, setCurrentTest] = useState<CurrentTest | null>(null)
const [progress, setProgress] = useState<Progress | null>(null)
// UI狀態
const [isLoading, setIsLoading] = useState(false)
const [selectedAnswer, setSelectedAnswer] = useState<string | null>(null)
const [showResult, setShowResult] = useState(false)
const [fillAnswer, setFillAnswer] = useState('')
const [showHint, setShowHint] = useState(false)
const [showComplete, setShowComplete] = useState(false)
const [showTaskListModal, setShowTaskListModal] = useState(false)
// 例句重組狀態
const [shuffledWords, setShuffledWords] = useState<string[]>([])
const [arrangedWords, setArrangedWords] = useState<string[]>([])
const [reorderResult, setReorderResult] = useState<boolean | null>(null)
// 分數狀態
const [score, setScore] = useState({ correct: 0, total: 0 })
// Client-side mounting
useEffect(() => {
setMounted(true)
startNewSession()
}, [])
// 開始新的學習會話
const startNewSession = async () => {
try {
setIsLoading(true)
console.log('🎯 開始新的學習會話...')
const sessionResult = await studySessionService.startSession()
if (sessionResult.success && sessionResult.data) {
const newSession = sessionResult.data
setSession(newSession)
console.log('✅ 學習會話創建成功:', newSession)
// 載入第一個測驗和詳細進度
await loadCurrentTest(newSession.sessionId)
await loadProgress(newSession.sessionId)
} else {
console.error('❌ 創建學習會話失敗:', sessionResult.error)
if (sessionResult.error === 'No due cards available for study') {
setShowComplete(true)
}
}
} catch (error) {
console.error('💥 創建學習會話異常:', error)
} finally {
setIsLoading(false)
}
}
// 載入當前測驗
const loadCurrentTest = async (sessionId: string) => {
try {
const testResult = await studySessionService.getCurrentTest(sessionId)
if (testResult.success && testResult.data) {
setCurrentTest(testResult.data)
resetTestStates()
console.log('🎯 載入當前測驗:', testResult.data.testType, 'for', testResult.data.card.word)
} else {
console.error('❌ 載入測驗失敗:', testResult.error)
}
} catch (error) {
console.error('💥 載入測驗異常:', error)
}
}
// 載入詳細進度
const loadProgress = async (sessionId: string) => {
try {
const progressResult = await studySessionService.getProgress(sessionId)
if (progressResult.success && progressResult.data) {
setProgress(progressResult.data)
console.log('📊 載入進度成功:', progressResult.data)
} else {
console.error('❌ 載入進度失敗:', progressResult.error)
}
} catch (error) {
console.error('💥 載入進度異常:', error)
}
}
// 提交測驗結果
const submitTest = async (isCorrect: boolean, userAnswer?: string, confidenceLevel?: number) => {
if (!session || !currentTest) return
try {
const result = await studySessionService.submitTest(session.sessionId, {
testType: currentTest.testType,
isCorrect,
userAnswer,
confidenceLevel,
responseTimeMs: 2000 // 簡化時間計算
})
if (result.success && result.data) {
console.log('✅ 測驗結果提交成功:', result.data)
// 更新分數
setScore(prev => ({
correct: isCorrect ? prev.correct + 1 : prev.correct,
total: prev.total + 1
}))
// 更新本地進度顯示
if (progress && result.data) {
setProgress(prev => prev ? {
...prev,
completedTests: result.data!.progress.completedTests,
completedCards: result.data!.progress.completedCards
} : null)
}
// 重新載入完整進度數據
await loadProgress(session.sessionId)
// 檢查是否有下一個測驗
setTimeout(async () => {
await loadNextTest()
}, 1500) // 顯示結果1.5秒後自動進入下一題
} else {
console.error('❌ 提交測驗結果失敗:', result.error)
}
} catch (error) {
console.error('💥 提交測驗結果異常:', error)
}
}
// 載入下一個測驗
const loadNextTest = async () => {
if (!session) return
try {
const nextResult = await studySessionService.getNextTest(session.sessionId)
if (nextResult.success && nextResult.data) {
const nextTest = nextResult.data
if (nextTest.hasNextTest) {
// 載入下一個測驗
await loadCurrentTest(session.sessionId)
} else {
// 會話完成
console.log('🎉 學習會話完成!')
await studySessionService.completeSession(session.sessionId)
setShowComplete(true)
}
}
} catch (error) {
console.error('💥 載入下一個測驗異常:', error)
}
}
// 重置測驗狀態
const resetTestStates = () => {
setSelectedAnswer(null)
setShowResult(false)
setFillAnswer('')
setShowHint(false)
setShuffledWords([])
setArrangedWords([])
setReorderResult(null)
}
// 測驗處理函數
const handleQuizAnswer = async (answer: string) => {
if (showResult || !currentTest) return
setSelectedAnswer(answer)
setShowResult(true)
const isCorrect = answer === currentTest.card.word
await submitTest(isCorrect, answer)
}
const handleFillAnswer = async () => {
if (showResult || !currentTest) return
setShowResult(true)
const isCorrect = fillAnswer.toLowerCase().trim() === currentTest.card.word.toLowerCase()
await submitTest(isCorrect, fillAnswer)
}
const handleConfidenceLevel = async (level: number) => {
if (!currentTest) return
await submitTest(true, undefined, level) // 翻卡記憶以信心等級為準
}
const handleReorderAnswer = async () => {
if (!currentTest) return
const userSentence = arrangedWords.join(' ')
const correctSentence = currentTest.card.example
const isCorrect = userSentence.toLowerCase().trim() === correctSentence.toLowerCase().trim()
setReorderResult(isCorrect)
setShowResult(true)
await submitTest(isCorrect, userSentence)
}
const handleSpeakingAnswer = async (transcript: string) => {
if (!currentTest) return
setShowResult(true)
const isCorrect = transcript.toLowerCase().includes(currentTest.card.word.toLowerCase())
await submitTest(isCorrect, transcript)
}
// 初始化例句重組
useEffect(() => {
if (currentTest && currentTest.testType === 'sentence-reorder') {
const words = currentTest.card.example.split(/\s+/).filter(word => word.length > 0)
const shuffled = [...words].sort(() => Math.random() - 0.5)
setShuffledWords(shuffled)
setArrangedWords([])
setReorderResult(null)
}
}, [currentTest])
// 例句重組處理
const handleWordClick = (word: string) => {
setShuffledWords(prev => prev.filter(w => w !== word))
setArrangedWords(prev => [...prev, word])
setReorderResult(null)
}
const handleRemoveFromArranged = (word: string) => {
setArrangedWords(prev => prev.filter(w => w !== word))
setShuffledWords(prev => [...prev, word])
setReorderResult(null)
}
const handleResetReorder = () => {
if (!currentTest) return
const words = currentTest.card.example.split(/\s+/).filter(word => word.length > 0)
const shuffled = [...words].sort(() => Math.random() - 0.5)
setShuffledWords(shuffled)
setArrangedWords([])
setReorderResult(null)
}
// 重新開始
const handleRestart = async () => {
setScore({ correct: 0, total: 0 })
setShowComplete(false)
await startNewSession()
}
// Loading screen
if (!mounted || isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center">
<div className="text-gray-500 text-lg">...</div>
</div>
)
}
// No session or complete
if (!session || showComplete) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
<Navigation />
<div className="max-w-4xl mx-auto px-4 py-8 flex items-center justify-center min-h-[calc(100vh-80px)]">
{showComplete ? (
<LearningComplete
score={score}
mode="mixed"
onRestart={handleRestart}
onBackToDashboard={() => router.push('/dashboard')}
/>
) : (
<div className="bg-white rounded-xl p-8 max-w-md w-full text-center shadow-lg">
<div className="text-6xl mb-4">📚</div>
<h2 className="text-2xl font-bold text-gray-900 mb-4">
</h2>
<p className="text-gray-600 mb-6">
</p>
<div className="flex gap-3">
<button
onClick={() => router.push('/flashcards')}
className="flex-1 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors font-medium"
>
</button>
<button
onClick={() => router.push('/dashboard')}
className="flex-1 py-3 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors font-medium"
>
</button>
</div>
</div>
)}
</div>
</div>
)
}
// No current test
if (!currentTest) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center">
<div className="text-gray-500 text-lg">...</div>
</div>
)
}
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
<Navigation />
<div className="max-w-4xl mx-auto px-4 py-8">
{/* 分段式進度條 */}
<div className="mb-8">
<div className="flex justify-between items-center mb-3">
<span className="text-sm font-medium text-gray-900"></span>
<button
onClick={() => setShowTaskListModal(true)}
className="text-sm text-gray-600 hover:text-blue-600 transition-colors cursor-pointer flex items-center gap-1"
title="點擊查看詳細進度"
>
📋
</button>
</div>
{progress && (
<SegmentedProgressBar
progress={progress}
onClick={() => setShowTaskListModal(true)}
/>
)}
</div>
{/* 測驗內容渲染 */}
{currentTest.testType === 'flip-memory' && (
<FlipMemoryTest
card={currentTest.card}
onConfidenceSelect={handleConfidenceLevel}
showResult={showResult}
/>
)}
{currentTest.testType === 'vocab-choice' && (
<VocabChoiceTest
card={currentTest.card}
onAnswer={handleQuizAnswer}
selectedAnswer={selectedAnswer}
showResult={showResult}
/>
)}
{currentTest.testType === 'sentence-fill' && (
<SentenceFillTest
card={currentTest.card}
fillAnswer={fillAnswer}
setFillAnswer={setFillAnswer}
onSubmit={handleFillAnswer}
showHint={showHint}
setShowHint={setShowHint}
showResult={showResult}
/>
)}
{currentTest.testType === 'sentence-reorder' && (
<SentenceReorderTest
card={currentTest.card}
shuffledWords={shuffledWords}
arrangedWords={arrangedWords}
onWordClick={handleWordClick}
onRemoveWord={handleRemoveFromArranged}
onCheckAnswer={handleReorderAnswer}
onReset={handleResetReorder}
showResult={showResult}
result={reorderResult}
/>
)}
{currentTest.testType === 'sentence-speaking' && (
<SentenceSpeakingTest
card={currentTest.card}
onComplete={handleSpeakingAnswer}
showResult={showResult}
/>
)}
{/* 任務清單模態框 */}
{showTaskListModal && progress && (
<TaskListModal
progress={progress}
onClose={() => setShowTaskListModal(false)}
/>
)}
</div>
</div>
)
}
// 測驗組件定義
interface TestComponentProps {
card: any
showResult: boolean
}
function FlipMemoryTest({ card, onConfidenceSelect, showResult }: TestComponentProps & {
onConfidenceSelect: (level: number) => void
}) {
const [isFlipped, setIsFlipped] = useState(false)
return (
<div className="bg-white rounded-xl shadow-lg p-8">
<h2 className="text-2xl font-bold text-gray-900 mb-6"></h2>
<div className="text-center mb-8" onClick={() => setIsFlipped(!isFlipped)}>
{!isFlipped ? (
<div className="bg-gray-50 rounded-lg p-8 cursor-pointer">
<h3 className="text-4xl font-bold text-gray-900 mb-4">{card.word}</h3>
<p className="text-gray-500">{card.pronunciation}</p>
</div>
) : (
<div className="bg-gray-50 rounded-lg p-8">
<p className="text-xl text-gray-700 mb-4">{card.definition}</p>
<p className="text-lg text-gray-600 italic">"{card.example}"</p>
<p className="text-sm text-gray-500 mt-2">"{card.exampleTranslation}"</p>
</div>
)}
</div>
{isFlipped && !showResult && (
<div className="flex gap-2 justify-center">
{[1, 2, 3, 4, 5].map(level => (
<button
key={level}
onClick={() => onConfidenceSelect(level)}
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors"
>
{level}
</button>
))}
</div>
)}
</div>
)
}
function VocabChoiceTest({ card, onAnswer, selectedAnswer, showResult }: TestComponentProps & {
onAnswer: (answer: string) => void
selectedAnswer: string | null
}) {
const options = [card.word, 'example1', 'example2', 'example3'].sort(() => Math.random() - 0.5)
return (
<div className="bg-white rounded-xl shadow-lg p-8">
<h2 className="text-2xl font-bold text-gray-900 mb-6"></h2>
<div className="mb-6">
<p className="text-lg text-gray-700">{card.definition}</p>
</div>
<div className="space-y-3">
{options.map((option, idx) => (
<button
key={idx}
onClick={() => !showResult && onAnswer(option)}
disabled={showResult}
className={`w-full p-4 text-left rounded-lg border-2 transition-all ${
showResult
? option === card.word
? 'border-green-500 bg-green-50 text-green-700'
: option === selectedAnswer
? 'border-red-500 bg-red-50 text-red-700'
: 'border-gray-200 bg-gray-50 text-gray-500'
: 'border-gray-200 hover:border-blue-300 hover:bg-blue-50'
}`}
>
{option}
</button>
))}
</div>
{showResult && (
<div className={`mt-6 p-4 rounded-lg ${
selectedAnswer === card.word ? 'bg-green-50' : 'bg-red-50'
}`}>
<p className={`font-semibold ${
selectedAnswer === card.word ? 'text-green-700' : 'text-red-700'
}`}>
{selectedAnswer === card.word ? '正確!' : '錯誤!'}
</p>
{selectedAnswer !== card.word && (
<p className="text-gray-700 mt-2">
: <strong>{card.word}</strong>
</p>
)}
</div>
)}
</div>
)
}
function SentenceFillTest({ card, fillAnswer, setFillAnswer, onSubmit, showHint, setShowHint, showResult }: TestComponentProps & {
fillAnswer: string
setFillAnswer: (value: string) => void
onSubmit: () => void
showHint: boolean
setShowHint: (show: boolean) => void
}) {
return (
<div className="bg-white rounded-xl shadow-lg p-8">
<h2 className="text-2xl font-bold text-gray-900 mb-6"></h2>
<div className="mb-6">
<div className="bg-gray-50 rounded-lg p-6 text-lg">
{card.example.split(new RegExp(`(${card.word})`, 'gi')).map((part: string, index: number) => {
const isTargetWord = part.toLowerCase() === card.word.toLowerCase()
return isTargetWord ? (
<input
key={index}
type="text"
value={fillAnswer}
onChange={(e) => setFillAnswer(e.target.value)}
placeholder="____"
disabled={showResult}
className="inline-block px-2 py-1 mx-1 border-b-2 border-blue-500 focus:outline-none"
style={{ width: `${Math.max(60, card.word.length * 12)}px` }}
/>
) : (
<span key={index}>{part}</span>
)
})}
</div>
</div>
<div className="flex gap-3 mb-4">
{!showResult && fillAnswer.trim() && (
<button
onClick={onSubmit}
className="px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors"
>
</button>
)}
<button
onClick={() => setShowHint(!showHint)}
className="px-6 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors"
>
{showHint ? '隱藏提示' : '顯示提示'}
</button>
</div>
{showHint && (
<div className="mb-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-yellow-800">{card.definition}</p>
</div>
)}
{showResult && (
<div className={`mt-6 p-4 rounded-lg ${
fillAnswer.toLowerCase().trim() === card.word.toLowerCase() ? 'bg-green-50' : 'bg-red-50'
}`}>
<p className={`font-semibold ${
fillAnswer.toLowerCase().trim() === card.word.toLowerCase() ? 'text-green-700' : 'text-red-700'
}`}>
{fillAnswer.toLowerCase().trim() === card.word.toLowerCase() ? '正確!' : '錯誤!'}
</p>
</div>
)}
</div>
)
}
function SentenceReorderTest({ card, shuffledWords, arrangedWords, onWordClick, onRemoveWord, onCheckAnswer, onReset, showResult, result }: TestComponentProps & {
shuffledWords: string[]
arrangedWords: string[]
onWordClick: (word: string) => void
onRemoveWord: (word: string) => void
onCheckAnswer: () => void
onReset: () => void
result: boolean | null
}) {
return (
<div className="bg-white rounded-xl shadow-lg p-8">
<h2 className="text-2xl font-bold text-gray-900 mb-6"></h2>
{/* 重組區域 */}
<div className="mb-6">
<h3 className="text-lg font-semibold text-gray-900 mb-3"></h3>
<div className="min-h-[80px] bg-gray-50 rounded-lg p-4 border-2 border-dashed border-gray-300">
{arrangedWords.length === 0 ? (
<div className="flex items-center justify-center h-full text-gray-400">
</div>
) : (
<div className="flex flex-wrap gap-2">
{arrangedWords.map((word, index) => (
<button
key={index}
onClick={() => onRemoveWord(word)}
className="bg-blue-100 text-blue-800 px-3 py-2 rounded-full hover:bg-blue-200"
>
{word} ×
</button>
))}
</div>
)}
</div>
</div>
{/* 可用單字 */}
<div className="mb-6">
<h3 className="text-lg font-semibold text-gray-900 mb-3"></h3>
<div className="bg-white border border-gray-200 rounded-lg p-4 min-h-[60px]">
<div className="flex flex-wrap gap-2">
{shuffledWords.map((word, index) => (
<button
key={index}
onClick={() => onWordClick(word)}
className="bg-gray-100 text-gray-800 px-3 py-2 rounded-full hover:bg-gray-200"
>
{word}
</button>
))}
</div>
</div>
</div>
<div className="flex gap-3 mb-6">
{arrangedWords.length > 0 && !showResult && (
<button
onClick={onCheckAnswer}
className="px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors"
>
</button>
)}
<button
onClick={onReset}
className="px-6 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors"
>
</button>
</div>
{result !== null && (
<div className={`p-4 rounded-lg ${result ? 'bg-green-50' : 'bg-red-50'}`}>
<p className={`font-semibold ${result ? 'text-green-700' : 'text-red-700'}`}>
{result ? '正確!' : '錯誤!'}
</p>
{!result && (
<p className="text-gray-700 mt-2">
: <strong>"{card.example}"</strong>
</p>
)}
</div>
)}
</div>
)
}
function SentenceSpeakingTest({ card, onComplete, showResult }: TestComponentProps & {
onComplete: (transcript: string) => void
}) {
return (
<div className="bg-white rounded-xl shadow-lg p-8">
<h2 className="text-2xl font-bold text-gray-900 mb-6"></h2>
<VoiceRecorder
targetText={card.example}
targetTranslation={card.exampleTranslation}
instructionText="請大聲說出完整的例句:"
onRecordingComplete={(_audioBlob) => onComplete(card.example)}
/>
{showResult && (
<div className="mt-6 p-4 rounded-lg bg-blue-50">
<p className="text-blue-700 font-semibold"></p>
<p className="text-gray-600">...</p>
</div>
)}
</div>
)
}
function TaskListModal({ progress, onClose }: {
progress: Progress
onClose: () => void
}) {
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-2xl max-w-4xl w-full mx-4 max-h-[80vh] overflow-hidden">
<div className="flex items-center justify-between p-6 border-b">
<h2 className="text-2xl font-bold text-gray-900">📚 </h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-2xl"></button>
</div>
<div className="p-6 overflow-y-auto max-h-[60vh]">
<div className="mb-6 bg-blue-50 rounded-lg p-4">
<div className="flex justify-between text-sm">
<span className="text-blue-900 font-medium">
: {progress.completedTests} / {progress.totalTests}
({Math.round((progress.completedTests / progress.totalTests) * 100)}%)
</span>
<span className="text-blue-800">
: {progress.completedCards} / {progress.totalCards}
</span>
</div>
</div>
<div className="space-y-4">
{progress.cards.map((card, index) => (
<div key={card.cardId} className="border rounded-lg p-4">
<div className="flex items-center gap-3 mb-3">
<span className="font-medium">{index + 1}: {card.word}</span>
<span className={`text-xs px-2 py-1 rounded ${
card.isCompleted ? 'bg-green-100 text-green-700' : 'bg-blue-100 text-blue-700'
}`}>
{card.completedTestsCount}/{card.plannedTests.length}
</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{card.plannedTests.map(testType => {
const isCompleted = card.tests.some(t => t.testType === testType)
return (
<div
key={testType}
className={`p-2 rounded text-sm ${
isCompleted ? 'bg-green-50 text-green-700' : 'bg-gray-50 text-gray-500'
}`}
>
{isCompleted ? '✅' : '⚪'} {testType}
</div>
)
})}
</div>
</div>
))}
</div>
</div>
<div className="px-6 py-4 border-t bg-gray-50">
<button
onClick={onClose}
className="w-full py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors"
>
</button>
</div>
</div>
</div>
)
}