348 lines
13 KiB
TypeScript
348 lines
13 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect, useCallback } from "react"
|
|
import { useParams, useRouter } from "next/navigation"
|
|
import { startLearningSession, submitAnswer, endSession } from "@/actions/learning"
|
|
|
|
interface Card {
|
|
hanziId: string
|
|
simplified: string
|
|
options: string[]
|
|
correctPinyin: string
|
|
meaning: string
|
|
}
|
|
|
|
interface SessionSummary {
|
|
totalCards: number
|
|
correctCount: number
|
|
incorrectCount: number
|
|
accuracyPercent: number
|
|
durationMinutes: number
|
|
}
|
|
|
|
export default function LearnPage() {
|
|
const params = useParams()
|
|
const router = useRouter()
|
|
const collectionId = params.collectionId as string
|
|
|
|
// Session state
|
|
const [sessionId, setSessionId] = useState<string | null>(null)
|
|
const [cards, setCards] = useState<Card[]>([])
|
|
const [currentIndex, setCurrentIndex] = useState(0)
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
// Card state
|
|
const [selectedOption, setSelectedOption] = useState<number | null>(null)
|
|
const [showFeedback, setShowFeedback] = useState(false)
|
|
const [isCorrect, setIsCorrect] = useState(false)
|
|
const [answerStartTime, setAnswerStartTime] = useState<number>(Date.now())
|
|
|
|
// Summary state
|
|
const [showSummary, setShowSummary] = useState(false)
|
|
const [summary, setSummary] = useState<SessionSummary | null>(null)
|
|
|
|
const currentCard = cards[currentIndex]
|
|
const progress = ((currentIndex / cards.length) * 100) || 0
|
|
|
|
// Start learning session
|
|
useEffect(() => {
|
|
const startSession = async () => {
|
|
setLoading(true)
|
|
setError(null)
|
|
|
|
const collectionIdParam = collectionId === "all" ? undefined : collectionId
|
|
|
|
const result = await startLearningSession(collectionIdParam)
|
|
|
|
if (result.success && result.data) {
|
|
setSessionId(result.data.sessionId)
|
|
setCards(result.data.cards)
|
|
setAnswerStartTime(Date.now())
|
|
} else {
|
|
setError(result.message || "Failed to start learning session")
|
|
}
|
|
|
|
setLoading(false)
|
|
}
|
|
|
|
startSession()
|
|
}, [collectionId])
|
|
|
|
// Handle answer selection
|
|
const handleSelectAnswer = useCallback((index: number) => {
|
|
if (showFeedback) return // Prevent changing answer after submission
|
|
|
|
setSelectedOption(index)
|
|
}, [showFeedback])
|
|
|
|
// Submit answer
|
|
const handleSubmitAnswer = useCallback(async () => {
|
|
if (selectedOption === null || !currentCard || !sessionId) return
|
|
if (showFeedback) return // Already submitted
|
|
|
|
const selectedPinyin = currentCard.options[selectedOption]
|
|
const correct = selectedPinyin === currentCard.correctPinyin
|
|
const timeSpentMs = Date.now() - answerStartTime
|
|
|
|
setIsCorrect(correct)
|
|
setShowFeedback(true)
|
|
|
|
// Submit to backend
|
|
await submitAnswer(
|
|
sessionId,
|
|
currentCard.hanziId,
|
|
selectedPinyin,
|
|
correct,
|
|
timeSpentMs
|
|
)
|
|
}, [selectedOption, currentCard, sessionId, showFeedback, answerStartTime])
|
|
|
|
// Continue to next card or end session
|
|
const handleContinue = useCallback(async () => {
|
|
if (currentIndex < cards.length - 1) {
|
|
setCurrentIndex(prev => prev + 1)
|
|
setSelectedOption(null)
|
|
setShowFeedback(false)
|
|
setAnswerStartTime(Date.now())
|
|
} else {
|
|
// End session and show summary
|
|
if (sessionId) {
|
|
const result = await endSession(sessionId)
|
|
if (result.success && result.data) {
|
|
setSummary(result.data)
|
|
}
|
|
}
|
|
setShowSummary(true)
|
|
}
|
|
}, [currentIndex, cards.length, sessionId])
|
|
|
|
// Keyboard shortcuts
|
|
useEffect(() => {
|
|
const handleKeyPress = (e: KeyboardEvent) => {
|
|
// Numbers 1-4 for answer selection
|
|
if (["1", "2", "3", "4"].includes(e.key)) {
|
|
const index = parseInt(e.key) - 1
|
|
if (index < currentCard?.options.length) {
|
|
if (!showFeedback) {
|
|
handleSelectAnswer(index)
|
|
// Auto-submit after selection
|
|
setTimeout(() => {
|
|
handleSubmitAnswer()
|
|
}, 100)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Space to continue
|
|
if (e.key === " " && showFeedback) {
|
|
e.preventDefault()
|
|
handleContinue()
|
|
}
|
|
}
|
|
|
|
window.addEventListener("keydown", handleKeyPress)
|
|
return () => window.removeEventListener("keydown", handleKeyPress)
|
|
}, [currentCard, showFeedback, handleSelectAnswer, handleSubmitAnswer, handleContinue])
|
|
|
|
// Loading state
|
|
if (loading) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
|
<div className="text-center">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
|
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading cards...</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Error state
|
|
if (error) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8 max-w-md">
|
|
<div className="text-red-600 dark:text-red-400 text-center mb-4">
|
|
<svg className="w-16 h-16 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
</div>
|
|
<h2 className="text-2xl font-bold text-center mb-4">No Cards Available</h2>
|
|
<p className="text-gray-600 dark:text-gray-400 text-center mb-6">{error}</p>
|
|
<button
|
|
onClick={() => router.push("/collections")}
|
|
className="w-full py-2 px-4 bg-blue-600 hover:bg-blue-700 text-white rounded-md font-medium"
|
|
>
|
|
Go to Collections
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Summary screen
|
|
if (showSummary && summary) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8 max-w-md w-full">
|
|
<div className="text-center mb-6">
|
|
<div className="text-6xl mb-4">🎉</div>
|
|
<h2 className="text-3xl font-bold mb-2">Session Complete!</h2>
|
|
<p className="text-gray-600 dark:text-gray-400">Great work!</p>
|
|
</div>
|
|
|
|
<div className="space-y-4 mb-6">
|
|
<div className="flex justify-between items-center p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
|
<span className="text-gray-600 dark:text-gray-400">Total Cards</span>
|
|
<span className="text-2xl font-bold">{summary.totalCards}</span>
|
|
</div>
|
|
|
|
<div className="flex justify-between items-center p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
|
<span className="text-green-600 dark:text-green-400">Correct</span>
|
|
<span className="text-2xl font-bold text-green-600 dark:text-green-400">{summary.correctCount}</span>
|
|
</div>
|
|
|
|
<div className="flex justify-between items-center p-4 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
|
<span className="text-red-600 dark:text-red-400">Incorrect</span>
|
|
<span className="text-2xl font-bold text-red-600 dark:text-red-400">{summary.incorrectCount}</span>
|
|
</div>
|
|
|
|
<div className="flex justify-between items-center p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
|
<span className="text-blue-600 dark:text-blue-400">Accuracy</span>
|
|
<span className="text-2xl font-bold text-blue-600 dark:text-blue-400">{summary.accuracyPercent}%</span>
|
|
</div>
|
|
|
|
<div className="flex justify-between items-center p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
|
<span className="text-gray-600 dark:text-gray-400">Duration</span>
|
|
<span className="text-2xl font-bold">{summary.durationMinutes} min</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
<button
|
|
onClick={() => window.location.reload()}
|
|
className="w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 text-white rounded-md font-medium"
|
|
>
|
|
Start New Session
|
|
</button>
|
|
<button
|
|
onClick={() => router.push("/dashboard")}
|
|
className="w-full py-3 px-4 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-md font-medium"
|
|
>
|
|
Back to Dashboard
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Learning card screen
|
|
if (!currentCard) {
|
|
return <div>No cards available</div>
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
|
{/* Progress bar */}
|
|
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
|
<div className="max-w-4xl mx-auto px-4 py-4">
|
|
<div className="flex justify-between items-center mb-2">
|
|
<span className="text-sm text-gray-600 dark:text-gray-400">
|
|
Card {currentIndex + 1} of {cards.length}
|
|
</span>
|
|
<span className="text-sm font-semibold text-blue-600 dark:text-blue-400">
|
|
{Math.round(progress)}%
|
|
</span>
|
|
</div>
|
|
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
|
<div
|
|
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
|
style={{ width: `${progress}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main card area */}
|
|
<div className="max-w-4xl mx-auto px-4 py-12">
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-8 relative overflow-hidden">
|
|
{/* Feedback overlay */}
|
|
{showFeedback && (
|
|
<div
|
|
className={`absolute inset-0 flex items-center justify-center z-10 ${
|
|
isCorrect
|
|
? "bg-green-500/10 dark:bg-green-500/20"
|
|
: "bg-red-500/10 dark:bg-red-500/20"
|
|
}`}
|
|
>
|
|
<div className="text-center">
|
|
<div className={`text-8xl mb-4 ${isCorrect ? "text-green-600" : "text-red-600"}`}>
|
|
{isCorrect ? "✓" : "✗"}
|
|
</div>
|
|
<p className={`text-2xl font-bold mb-2 ${isCorrect ? "text-green-600" : "text-red-600"}`}>
|
|
{isCorrect ? "Correct!" : "Incorrect"}
|
|
</p>
|
|
{!isCorrect && (
|
|
<p className="text-lg text-gray-700 dark:text-gray-300">
|
|
Correct answer: <span className="font-bold">{currentCard.correctPinyin}</span>
|
|
</p>
|
|
)}
|
|
{currentCard.meaning && (
|
|
<p className="text-lg text-gray-600 dark:text-gray-400 mt-3 italic">
|
|
{currentCard.meaning}
|
|
</p>
|
|
)}
|
|
<button
|
|
onClick={handleContinue}
|
|
className="mt-6 py-2 px-6 bg-blue-600 hover:bg-blue-700 text-white rounded-md font-medium"
|
|
>
|
|
Continue (Space)
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Hanzi display */}
|
|
<div className="text-center mb-12">
|
|
<div className="text-9xl font-bold mb-4">{currentCard.simplified}</div>
|
|
<p className="text-gray-500 dark:text-gray-400">Select the correct pinyin</p>
|
|
</div>
|
|
|
|
{/* Answer options in 2x2 grid */}
|
|
<div className="grid grid-cols-2 gap-4 max-w-2xl mx-auto">
|
|
{currentCard.options.map((option, index) => (
|
|
<button
|
|
key={index}
|
|
onClick={() => {
|
|
handleSelectAnswer(index)
|
|
// Auto-submit after a brief delay
|
|
setTimeout(() => handleSubmitAnswer(), 100)
|
|
}}
|
|
disabled={showFeedback}
|
|
className={`p-6 text-2xl font-medium rounded-lg transition-all ${
|
|
selectedOption === index && !showFeedback
|
|
? "bg-blue-600 text-white scale-105"
|
|
: showFeedback && option === currentCard.correctPinyin
|
|
? "bg-green-600 text-white"
|
|
: showFeedback && selectedOption === index
|
|
? "bg-red-600 text-white"
|
|
: "bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100"
|
|
} ${showFeedback ? "cursor-not-allowed" : "cursor-pointer"}`}
|
|
>
|
|
<div className="text-sm text-gray-500 dark:text-gray-400 mb-1">{index + 1}</div>
|
|
{option}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Keyboard shortcuts hint */}
|
|
<div className="mt-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
|
<p>Press 1-4 to select • Space to continue</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|