dramaling-vocab-learning/frontend/app/learn/page.tsx

1577 lines
60 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, useRef, useLayoutEffect } from 'react'
import { useRouter } from 'next/navigation'
import { Navigation } from '@/components/Navigation'
import AudioPlayer from '@/components/AudioPlayer'
import VoiceRecorder from '@/components/VoiceRecorder'
import LearningComplete from '@/components/LearningComplete'
import ReviewTypeIndicator from '@/components/review/ReviewTypeIndicator'
import MasteryIndicator from '@/components/review/MasteryIndicator'
import { flashcardsService, type Flashcard } from '@/lib/services/flashcards'
import { calculateCurrentMastery, getReviewTypesByDifficulty, isA1Learner } from '@/lib/utils/masteryCalculator'
// 擴展的Flashcard接口包含智能複習需要的欄位
interface ExtendedFlashcard extends Flashcard {
userLevel?: number; // 學習者程度 (1-100)
wordLevel?: number; // 詞彙難度 (1-100)
nextReviewDate?: string; // 下次復習日期
currentInterval?: number; // 當前間隔天數
isOverdue?: boolean; // 是否逾期
overdueDays?: number; // 逾期天數
baseMasteryLevel?: number; // 基礎熟悉度
lastReviewDate?: string; // 最後復習日期
synonyms?: string[]; // 同義詞 (暫時保留mock格式)
difficulty?: string; // CEFR等級 (暫時保留mock格式)
exampleImage?: string; // 例句圖片 (暫時保留mock格式)
}
export default function LearnPage() {
const router = useRouter()
const [mounted, setMounted] = useState(false)
// 智能複習狀態
const [currentCard, setCurrentCard] = useState<ExtendedFlashcard | null>(null)
const [dueCards, setDueCards] = useState<ExtendedFlashcard[]>([])
const [currentCardIndex, setCurrentCardIndex] = useState(0)
const [isLoadingCard, setIsLoadingCard] = useState(false)
// 複習模式狀態 (系統自動選擇)
const [mode, setMode] = useState<'flip-memory' | 'vocab-choice' | 'vocab-listening' | 'sentence-listening' | 'sentence-fill' | 'sentence-reorder' | 'sentence-speaking'>('flip-memory')
const [isAutoSelecting, setIsAutoSelecting] = useState(true)
// 答題狀態
const [score, setScore] = useState({ correct: 0, total: 0 })
const [selectedAnswer, setSelectedAnswer] = useState<string | null>(null)
const [showResult, setShowResult] = useState(false)
const [fillAnswer, setFillAnswer] = useState('')
const [showHint, setShowHint] = useState(false)
const [isFlipped, setIsFlipped] = useState(false)
// UI狀態
const [modalImage, setModalImage] = useState<string | null>(null)
const [showReportModal, setShowReportModal] = useState(false)
const [reportReason, setReportReason] = useState('')
const [reportingCard, setReportingCard] = useState<any>(null)
const [showComplete, setShowComplete] = useState(false)
const [cardHeight, setCardHeight] = useState<number>(400)
// 題型特定狀態
const [quizOptions, setQuizOptions] = useState<string[]>([])
const [sentenceOptions, setSentenceOptions] = useState<string[]>([])
// 例句重組狀態
const [shuffledWords, setShuffledWords] = useState<string[]>([])
const [arrangedWords, setArrangedWords] = useState<string[]>([])
const [reorderResult, setReorderResult] = useState<boolean | null>(null)
// Refs for measuring card content heights
const cardFrontRef = useRef<HTMLDivElement>(null)
const cardBackRef = useRef<HTMLDivElement>(null)
const cardContainerRef = useRef<HTMLDivElement>(null)
// Calculate optimal card height based on content (only when card changes)
const calculateCardHeight = () => {
if (!cardFrontRef.current || !cardBackRef.current) return 400;
// Get the scroll heights to measure actual content
const frontHeight = cardFrontRef.current.scrollHeight;
const backHeight = cardBackRef.current.scrollHeight;
console.log('Heights calculated:', { frontHeight, backHeight }); // Debug log
// Use the maximum height with padding
const maxHeight = Math.max(frontHeight, backHeight);
const paddedHeight = maxHeight + 40; // Add padding for visual spacing
// Ensure minimum height for visual consistency
return Math.max(paddedHeight, 450);
};
// Update card height only when card content changes (not on flip)
useLayoutEffect(() => {
if (mounted && cardFrontRef.current && cardBackRef.current) {
// Wait for DOM to be fully rendered
const timer = setTimeout(() => {
const newHeight = calculateCardHeight();
setCardHeight(newHeight);
}, 50);
return () => clearTimeout(timer);
}
}, [currentCardIndex, mounted]);
// Client-side mounting
useEffect(() => {
setMounted(true)
loadDueCards() // 載入到期詞卡
}, [])
// 載入到期詞卡列表
const loadDueCards = async () => {
try {
setIsLoadingCard(true)
// 暫時使用mock data等後端API就緒後替換
const mockDueCards: ExtendedFlashcard[] = [
{
id: '1',
word: 'brought',
partOfSpeech: 'verb',
pronunciation: '/brɔːt/',
translation: '提出、帶來',
definition: 'Past tense of bring; to mention or introduce a topic in conversation',
example: 'He brought this thing up during our meeting and no one agreed.',
exampleTranslation: '他在我們的會議中提出了這件事,但沒有人同意。',
masteryLevel: 65,
timesReviewed: 3,
isFavorite: false,
nextReviewDate: new Date().toISOString().split('T')[0], // 今天到期
difficultyLevel: 'B1',
createdAt: new Date().toISOString(),
// 智能複習欄位
userLevel: 60, // 學習者程度
wordLevel: 70, // 詞彙難度 (困難詞彙)
baseMasteryLevel: 75,
lastReviewDate: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), // 2天前
exampleImages: [],
hasExampleImage: true,
primaryImageUrl: '/images/examples/bring_up.png',
synonyms: ['mentioned', 'raised', 'introduced'],
difficulty: 'B1',
exampleImage: '/images/examples/bring_up.png'
},
{
id: '2',
word: 'simple',
partOfSpeech: 'adjective',
pronunciation: '/ˈsɪmpəl/',
translation: '簡單的',
definition: 'Easy to understand or do; not complex',
example: 'This is a simple task that anyone can complete.',
exampleTranslation: '這是一個任何人都能完成的簡單任務。',
masteryLevel: 45,
timesReviewed: 1,
isFavorite: false,
nextReviewDate: new Date().toISOString().split('T')[0],
difficultyLevel: 'A2',
createdAt: new Date().toISOString(),
// 智能複習欄位 - A1學習者
userLevel: 15, // A1學習者
wordLevel: 25,
baseMasteryLevel: 50,
lastReviewDate: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(),
exampleImages: [],
hasExampleImage: false,
synonyms: ['easy', 'basic', 'straightforward'],
difficulty: 'A2',
exampleImage: '/images/examples/simple.png'
}
];
setDueCards(mockDueCards);
if (mockDueCards.length > 0) {
await loadNextCardWithAutoMode(0);
}
} catch (error) {
console.error('載入到期詞卡失敗:', error);
} finally {
setIsLoadingCard(false);
}
}
// 智能載入下一張卡片並自動選擇模式
const loadNextCardWithAutoMode = async (cardIndex: number) => {
try {
setIsAutoSelecting(true);
const card = dueCards[cardIndex];
if (!card) {
setShowComplete(true);
return;
}
setCurrentCard(card);
setCurrentCardIndex(cardIndex);
// 系統自動選擇最適合的複習模式
const selectedMode = await selectOptimalReviewMode(card);
setMode(selectedMode);
// 重置所有答題狀態
resetAllStates();
} catch (error) {
console.error('載入卡片失敗:', error);
} finally {
setIsAutoSelecting(false);
}
}
// 系統自動選擇最適合的複習模式
const selectOptimalReviewMode = async (card: ExtendedFlashcard): Promise<typeof mode> => {
// 暫時使用前端邏輯後續整合後端API
const userLevel = card.userLevel || 50;
const wordLevel = card.wordLevel || 50;
const availableModes = getReviewTypesByDifficulty(userLevel, wordLevel);
// 映射到實際的模式名稱
const modeMapping: { [key: string]: typeof mode } = {
'flip-memory': 'flip-memory',
'vocab-choice': 'vocab-choice',
'vocab-listening': 'vocab-listening',
'sentence-fill': 'sentence-fill',
'sentence-reorder': 'sentence-reorder',
'sentence-speaking': 'sentence-speaking',
'sentence-listening': 'sentence-listening'
};
// 選擇第一個可用模式 (後續會整合智能避重邏輯)
const selectedType = availableModes[0] || 'flip-memory';
return modeMapping[selectedType] || 'flip-memory';
}
// 重置所有答題狀態
const resetAllStates = () => {
setIsFlipped(false);
setSelectedAnswer(null);
setShowResult(false);
setFillAnswer('');
setShowHint(false);
setShuffledWords([]);
setArrangedWords([]);
setReorderResult(null);
setQuizOptions([]);
}
// Quiz options generation
useEffect(() => {
if (!currentCard) return;
const currentWord = currentCard.word;
// Generate quiz options with current word and other words
const otherWords = dueCards
.filter(card => card.id !== currentCard.id)
.map(card => card.word);
// If we don't have enough words in the deck, add some default options
const additionalOptions = ['determine', 'achieve', 'consider', 'negotiate', 'establish', 'maintain'];
const allOtherWords = [...otherWords, ...additionalOptions];
// Take 3 other words (avoiding duplicates)
const selectedOtherWords: string[] = [];
for (const word of allOtherWords) {
if (selectedOtherWords.length >= 3) break;
if (word !== currentWord && !selectedOtherWords.includes(word)) {
selectedOtherWords.push(word);
}
}
// Ensure we have exactly 4 options: current word + 3 others
const options = [currentWord, ...selectedOtherWords].sort(() => Math.random() - 0.5);
setQuizOptions(options);
// Reset quiz state when card changes
setSelectedAnswer(null);
setShowResult(false);
}, [currentCard, dueCards])
// Sentence options generation for sentence listening
useEffect(() => {
if (!currentCard || mode !== 'sentence-listening') return;
const currentSentence = currentCard.example;
// Generate sentence options with current sentence and other sentences
const otherSentences = dueCards
.filter(card => card.id !== currentCard.id)
.map(card => card.example);
// Add some default sentence options if not enough
const additionalSentences = [
'I think this is a good opportunity for us.',
'She decided to take a different approach.',
'They managed to solve the problem quickly.',
'We need to consider all possible solutions.'
];
const allOtherSentences = [...otherSentences, ...additionalSentences];
// Take 3 other sentences (avoiding duplicates)
const selectedOtherSentences: string[] = [];
for (const sentence of allOtherSentences) {
if (selectedOtherSentences.length >= 3) break;
if (sentence !== currentSentence && !selectedOtherSentences.includes(sentence)) {
selectedOtherSentences.push(sentence);
}
}
// Ensure we have exactly 4 options: current sentence + 3 others
const options = [currentSentence, ...selectedOtherSentences].sort(() => Math.random() - 0.5);
setSentenceOptions(options);
}, [currentCard, dueCards, mode])
// Initialize sentence reorder when card changes or mode switches to sentence-reorder
useEffect(() => {
if (mode === 'sentence-reorder' && currentCard) {
const words = currentCard.example.split(/\s+/).filter(word => word.length > 0)
const shuffled = [...words].sort(() => Math.random() - 0.5)
setShuffledWords(shuffled)
setArrangedWords([])
setReorderResult(null)
}
}, [currentCard, mode])
// Sentence reorder handlers
const handleWordClick = (word: string) => {
// Move word from shuffled to arranged
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 handleCheckReorderAnswer = async () => {
if (!currentCard) return;
const userSentence = arrangedWords.join(' ')
const correctSentence = currentCard.example
const isCorrect = userSentence.toLowerCase().trim() === correctSentence.toLowerCase().trim()
setReorderResult(isCorrect)
// 更新分數
setScore(prev => ({
correct: isCorrect ? prev.correct + 1 : prev.correct,
total: prev.total + 1
}))
// 提交復習結果
await submitReviewResult(isCorrect, userSentence);
}
const handleResetReorder = () => {
if (!currentCard) return;
const words = currentCard.example.split(/\s+/).filter(word => word.length > 0)
const shuffled = [...words].sort(() => Math.random() - 0.5)
setShuffledWords(shuffled)
setArrangedWords([])
setReorderResult(null)
}
const handleFlip = () => {
setIsFlipped(!isFlipped)
}
const handleNext = async () => {
if (currentCardIndex < dueCards.length - 1) {
await loadNextCardWithAutoMode(currentCardIndex + 1);
} else {
setShowComplete(true);
}
}
const handlePrevious = async () => {
if (currentCardIndex > 0) {
await loadNextCardWithAutoMode(currentCardIndex - 1);
}
}
const handleQuizAnswer = async (answer: string) => {
if (showResult || !currentCard) return
setSelectedAnswer(answer)
setShowResult(true)
const isCorrect = answer === currentCard.word
setScore(prev => ({
correct: isCorrect ? prev.correct + 1 : prev.correct,
total: prev.total + 1
}))
// 提交復習結果到後端
await submitReviewResult(isCorrect, answer);
}
// 提交復習結果
const submitReviewResult = async (isCorrect: boolean, userAnswer?: string, confidenceLevel?: number) => {
if (!currentCard) return;
try {
const result = await flashcardsService.submitReview(currentCard.id, {
isCorrect,
confidenceLevel,
questionType: mode,
userAnswer,
timeTaken: Date.now() - (currentCard.startTime || Date.now())
});
if (result.success && result.data) {
// 更新卡片的熟悉度等資訊
setCurrentCard(prev => prev ? {
...prev,
masteryLevel: result.data!.masteryLevel,
nextReviewDate: result.data!.nextReviewDate
} : null);
}
} catch (error) {
console.error('提交復習結果失敗:', error);
}
}
const handleFillAnswer = async () => {
if (showResult || !currentCard) return
setShowResult(true)
const isCorrect = fillAnswer.toLowerCase().trim() === currentCard.word.toLowerCase()
setScore(prev => ({
correct: isCorrect ? prev.correct + 1 : prev.correct,
total: prev.total + 1
}))
// 提交復習結果
await submitReviewResult(isCorrect, fillAnswer);
}
const handleListeningAnswer = async (answer: string) => {
if (showResult || !currentCard) return
setSelectedAnswer(answer)
setShowResult(true)
const isCorrect = answer === currentCard.word
setScore(prev => ({
correct: isCorrect ? prev.correct + 1 : prev.correct,
total: prev.total + 1
}))
// 提交復習結果
await submitReviewResult(isCorrect, answer);
}
const handleSpeakingAnswer = async (transcript: string) => {
if (!currentCard) return
setShowResult(true)
const isCorrect = transcript.toLowerCase().includes(currentCard.word.toLowerCase())
setScore(prev => ({
correct: isCorrect ? prev.correct + 1 : prev.correct,
total: prev.total + 1
}))
// 提交復習結果
await submitReviewResult(isCorrect, transcript);
}
const handleSentenceListeningAnswer = async (answer: string) => {
if (showResult || !currentCard) return
setSelectedAnswer(answer)
setShowResult(true)
const isCorrect = answer === currentCard.example
setScore(prev => ({
correct: isCorrect ? prev.correct + 1 : prev.correct,
total: prev.total + 1
}))
// 提交復習結果
await submitReviewResult(isCorrect, answer);
}
const handleReportSubmit = () => {
console.log('Report submitted:', {
card: reportingCard,
reason: reportReason
})
setShowReportModal(false)
setReportReason('')
setReportingCard(null)
}
const handleRestart = async () => {
setScore({ correct: 0, total: 0 })
setShowComplete(false)
await loadDueCards(); // 重新載入到期詞卡
}
// Show loading screen until mounted or while loading cards
if (!mounted || isLoadingCard || !currentCard) {
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">
{isAutoSelecting ? '系統正在選擇最適合的複習方式...' : '載入中...'}
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
{/* Navigation */}
<Navigation
showExitLearning={true}
onExitLearning={() => router.push('/dashboard')}
/>
<div className="max-w-4xl mx-auto px-4 py-8">
{/* Progress Bar */}
<div className="mb-8">
<div className="flex justify-between items-center mb-2">
<span className="text-sm text-gray-600"></span>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">
{currentCardIndex + 1} / {dueCards.length}
</span>
<div className="text-sm">
<span className="text-green-600 font-semibold">{score.correct}</span>
<span className="text-gray-500">/</span>
<span className="text-gray-600">{score.total}</span>
{score.total > 0 && (
<span className="text-blue-600 ml-2">
({Math.round((score.correct / score.total) * 100)}%)
</span>
)}
</div>
</div>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all"
style={{ width: `${((currentCardIndex + 1) / dueCards.length) * 100}%` }}
></div>
</div>
</div>
{/* Current Card Mastery Level */}
{currentCard.baseMasteryLevel && currentCard.lastReviewDate && (
<div className="mb-4">
<MasteryIndicator
level={calculateCurrentMastery(currentCard.baseMasteryLevel, currentCard.lastReviewDate)}
baseMasteryLevel={currentCard.baseMasteryLevel}
size="medium"
showPercentage={true}
/>
</div>
)}
{/* System Auto-Selected Review Type Indicator */}
<ReviewTypeIndicator
currentMode={mode}
userLevel={currentCard?.userLevel}
wordLevel={currentCard?.wordLevel}
/>
{mode === 'flip-memory' ? (
/* Flip Card Mode */
<div className="relative">
{/* Error Report Button for Flip Mode */}
<div className="flex justify-end mb-2">
<button
onClick={() => {
setReportingCard(currentCard)
setShowReportModal(true)
}}
className="px-3 py-2 rounded-md transition-colors text-gray-600 hover:text-gray-900"
>
🚩
</button>
</div>
<div
ref={cardContainerRef}
className="card-container"
onClick={handleFlip}
style={{ height: `${cardHeight}px` }}
>
<div className={`card ${isFlipped ? 'flipped' : ''}`}>
{/* Front */}
<div className="card-front">
<div
ref={cardFrontRef}
className="bg-white rounded-xl shadow-lg cursor-pointer hover:shadow-xl transition-shadow p-8"
>
{/* Title and Instructions */}
<div className="flex justify-between items-start mb-6">
<h2 className="text-2xl font-bold text-gray-900">
</h2>
<span className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
{currentCard.difficulty}
</span>
</div>
{/* Instructions Test Action */}
<p className="text-lg text-gray-700 mb-6 text-left">
</p>
{/* Word Display */}
<div className="flex-1 flex items-center justify-center mt-6">
<div className="bg-gray-50 rounded-lg p-8 w-full text-center">
<h3 className="text-4xl font-bold text-gray-900 mb-6">
{currentCard.word}
</h3>
<div className="flex items-center justify-center gap-3">
<span className="text-lg text-gray-500">
{currentCard.pronunciation}
</span>
<AudioPlayer text={currentCard.word} />
</div>
</div>
</div>
</div>
</div>
{/* Back */}
<div className="card-back">
<div
ref={cardBackRef}
className="bg-white rounded-xl shadow-lg cursor-pointer hover:shadow-xl transition-shadow"
>
{/* Content Sections */}
<div className="space-y-4">
{/* Definition */}
<div className="bg-gray-50 rounded-lg p-4">
<h3 className="font-semibold text-gray-900 mb-2 text-left"></h3>
<p className="text-gray-700 text-left">{currentCard.definition}</p>
</div>
{/* Example */}
<div className="bg-gray-50 rounded-lg p-4">
<h3 className="font-semibold text-gray-900 mb-2 text-left"></h3>
<div className="relative">
<p className="text-gray-700 italic mb-2 text-left pr-12">"{currentCard.example}"</p>
<div className="absolute bottom-0 right-0">
<AudioPlayer text={currentCard.example} />
</div>
</div>
<p className="text-gray-600 text-sm text-left">"{currentCard.exampleTranslation}"</p>
</div>
{/* Synonyms */}
<div className="bg-gray-50 rounded-lg p-4">
<h3 className="font-semibold text-gray-900 mb-2 text-left"></h3>
<div className="flex flex-wrap gap-2">
{currentCard.synonyms.map((synonym, index) => (
<span
key={index}
className="bg-white text-gray-700 px-3 py-1 rounded-full text-sm border border-gray-200"
>
{synonym}
</span>
))}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Navigation */}
<div className="flex gap-4 mt-6">
<button
onClick={handlePrevious}
disabled={currentCardIndex === 0}
className="flex-1 py-3 bg-gray-500 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-600 transition-colors font-medium"
>
</button>
<button
onClick={handleNext}
className="flex-1 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors font-medium"
>
{currentCardIndex === dueCards.length - 1 ? '完成' : '下一張'}
</button>
</div>
</div>
) : mode === 'vocab-choice' ? (
/* Vocab Choice Mode - 詞彙選擇 */
<div className="relative">
{/* Error Report Button for Quiz Mode */}
<div className="flex justify-end mb-2">
<button
onClick={() => {
setReportingCard(currentCard)
setShowReportModal(true)
}}
className="px-3 py-2 rounded-md transition-colors text-gray-600 hover:text-gray-900"
>
🚩
</button>
</div>
<div className="bg-white rounded-xl shadow-lg p-8">
{/* Title in top-left */}
<div className="flex justify-between items-start mb-6">
<h2 className="text-2xl font-bold text-gray-900">
</h2>
<span className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
{currentCard.difficulty}
</span>
</div>
{/* Instructions Test Action */}
<p className="text-lg text-gray-700 mb-6 text-left">
</p>
<div className="text-center mb-8">
<div className="bg-gray-50 rounded-lg p-4 mb-6">
<h3 className="font-semibold text-gray-900 mb-2 text-left"></h3>
<p className="text-gray-700 text-left">{currentCard.definition}</p>
</div>
</div>
<div className="space-y-3 mb-6">
{quizOptions.map((option, idx) => (
<button
key={idx}
onClick={() => !showResult && handleQuizAnswer(option)}
disabled={showResult}
className={`w-full p-4 text-left rounded-lg border-2 transition-all ${
showResult
? option === currentCard.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={`p-6 rounded-lg w-full mb-6 ${
selectedAnswer === currentCard.word
? 'bg-green-50 border border-green-200'
: 'bg-red-50 border border-red-200'
}`}>
<p className={`font-semibold text-left text-xl mb-4 ${
selectedAnswer === currentCard.word
? 'text-green-700'
: 'text-red-700'
}`}>
{selectedAnswer === currentCard.word ? '正確!' : '錯誤!'}
</p>
{selectedAnswer !== currentCard.word && (
<div className="mb-4">
<p className="text-gray-700 text-left">
<strong className="text-lg">{currentCard.word}</strong>
</p>
</div>
)}
<div className="space-y-3">
<div className="text-left">
<div className="flex items-center text-gray-600">
<strong></strong>
<span className="mx-2">{currentCard.pronunciation}</span>
<AudioPlayer text={currentCard.word} />
</div>
</div>
</div>
</div>
)}
</div>
{/* Navigation */}
<div className="flex gap-4 mt-6">
<button
onClick={handlePrevious}
disabled={currentCardIndex === 0}
className="flex-1 py-3 bg-gray-500 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-600 transition-colors font-medium"
>
</button>
<button
onClick={handleNext}
className="flex-1 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors font-medium"
>
{currentCardIndex === dueCards.length - 1 ? '完成' : '下一張'}
</button>
</div>
</div>
) : mode === 'sentence-fill' ? (
/* Fill in the Blank Mode - 填空題 */
<div className="relative">
{/* Error Report Button for Fill Mode */}
<div className="flex justify-end mb-2">
<button
onClick={() => {
setReportingCard(currentCard)
setShowReportModal(true)
}}
className="px-3 py-2 rounded-md transition-colors text-gray-600 hover:text-gray-900"
>
🚩
</button>
</div>
<div className="bg-white rounded-xl shadow-lg p-8">
{/* Title in top-left */}
<div className="flex justify-between items-start mb-6">
<h2 className="text-2xl font-bold text-gray-900">
</h2>
<span className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
{currentCard.difficulty}
</span>
</div>
{/* Example Image */}
{currentCard.exampleImage && (
<div className="mb-6">
<div className="bg-gray-50 rounded-lg p-4">
<img
src={currentCard.exampleImage}
alt="Example illustration"
className="w-full max-w-md mx-auto rounded-lg cursor-pointer"
onClick={() => setModalImage(currentCard.exampleImage)}
/>
</div>
</div>
)}
{/* Instructions Test Action */}
<p className="text-lg text-gray-700 mb-6 text-left">
</p>
{/* Example Sentence with Blanks */}
<div className="mb-6">
<div className="bg-gray-50 rounded-lg p-6">
<div className="text-lg text-gray-700 leading-relaxed">
{currentCard.example.split(new RegExp(`(${currentCard.word})`, 'gi')).map((part, index) => {
const isTargetWord = part.toLowerCase() === currentCard.word.toLowerCase();
return isTargetWord ? (
<span key={index} className="relative inline-block mx-1">
<input
type="text"
value={fillAnswer}
onChange={(e) => setFillAnswer(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !showResult && fillAnswer.trim()) {
handleFillAnswer()
}
}}
placeholder=""
disabled={showResult}
className={`inline-block px-2 py-1 text-center bg-transparent focus:outline-none disabled:bg-gray-100 ${
fillAnswer
? 'border-b-2 border-blue-500'
: 'border-b-2 border-dashed border-gray-400 focus:border-blue-400 focus:border-solid'
}`}
style={{ width: `${Math.max(100, Math.max(currentCard.word.length * 12, fillAnswer.length * 12 + 20))}px` }}
/>
{!fillAnswer && (
<span
className="absolute inset-0 flex items-center justify-center text-gray-400 pointer-events-none"
style={{ paddingBottom: '8px' }}
>
____
</span>
)}
</span>
) : (
<span key={index}>{part}</span>
);
})}
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex gap-3 mb-4">
{!showResult && fillAnswer.trim() && (
<button
onClick={handleFillAnswer}
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>
{/* Hint Section */}
{showHint && (
<div className="mb-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<h4 className="font-semibold text-yellow-800 mb-2"></h4>
<p className="text-yellow-800">{currentCard.definition}</p>
</div>
)}
{showResult && (
<div className={`mt-6 p-6 rounded-lg w-full ${
fillAnswer.toLowerCase().trim() === currentCard.word.toLowerCase()
? 'bg-green-50 border border-green-200'
: 'bg-red-50 border border-red-200'
}`}>
<p className={`font-semibold text-left text-xl mb-4 ${
fillAnswer.toLowerCase().trim() === currentCard.word.toLowerCase()
? 'text-green-700'
: 'text-red-700'
}`}>
{fillAnswer.toLowerCase().trim() === currentCard.word.toLowerCase() ? '正確!' : '錯誤!'}
</p>
{fillAnswer.toLowerCase().trim() !== currentCard.word.toLowerCase() && (
<div className="mb-4">
<p className="text-gray-700 text-left">
<strong className="text-lg">{currentCard.word}</strong>
</p>
</div>
)}
<div className="space-y-3">
<div className="text-left">
<p className="text-gray-600">
<span className="mx-2">{currentCard.pronunciation}</span>
<AudioPlayer text={currentCard.word} />
</p>
</div>
<div className="text-left">
</div>
</div>
</div>
)}
</div>
{/* Navigation */}
<div className="flex gap-4 mt-6">
<button
onClick={handlePrevious}
disabled={currentCardIndex === 0}
className="flex-1 py-3 bg-gray-500 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-600 transition-colors font-medium"
>
</button>
<button
onClick={handleNext}
className="flex-1 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors font-medium"
>
{currentCardIndex === dueCards.length - 1 ? '完成' : '下一張'}
</button>
</div>
</div>
) : mode === 'vocab-listening' ? (
/* Listening Test Mode - 聽力測試 */
<div className="relative">
{/* Error Report Button for Listening Mode */}
<div className="flex justify-end mb-2">
<button
onClick={() => {
setReportingCard(currentCard)
setShowReportModal(true)
}}
className="px-3 py-2 rounded-md transition-colors text-gray-600 hover:text-gray-900"
>
🚩
</button>
</div>
<div className="bg-white rounded-xl shadow-lg p-8">
{/* Title in top-left */}
<div className="flex justify-between items-start mb-6">
<h2 className="text-2xl font-bold text-gray-900">
()
</h2>
<span className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
{currentCard.difficulty}
</span>
</div>
{/* Instructions Test Action */}
<p className="text-lg text-gray-700 mb-6 text-left">
</p>
{/* Content Sections */}
<div className="space-y-4 mb-8">
{/* Audio */}
<div className="bg-gray-50 rounded-lg p-4">
<h3 className="font-semibold text-gray-900 mb-2 text-left"></h3>
<div className="flex items-center gap-3">
<span className="text-gray-700">{currentCard.pronunciation}</span>
<AudioPlayer text={currentCard.word} />
</div>
</div>
</div>
{/* Word Options */}
<div className="grid grid-cols-2 gap-3 mb-6">
{[currentCard.word, 'determine', 'achieve', 'consider'].map((word) => (
<button
key={word}
onClick={() => !showResult && handleListeningAnswer(word)}
disabled={showResult}
className={`p-4 text-center rounded-lg border-2 transition-all ${
showResult
? word === currentCard.word
? 'border-green-500 bg-green-50 text-green-700'
: word === 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'
}`}
>
{word}
</button>
))}
</div>
{showResult && (
<div className={`p-6 rounded-lg w-full mb-6 ${
selectedAnswer === currentCard.word
? 'bg-green-50 border border-green-200'
: 'bg-red-50 border border-red-200'
}`}>
<p className={`font-semibold text-left text-xl mb-4 ${
selectedAnswer === currentCard.word
? 'text-green-700'
: 'text-red-700'
}`}>
{selectedAnswer === currentCard.word ? '正確!' : '錯誤!'}
</p>
{selectedAnswer !== currentCard.word && (
<div className="mb-4">
<p className="text-gray-700 text-left">
<strong className="text-lg">{currentCard.word}</strong>
</p>
<p className="text-gray-600 text-left mt-1">
{currentCard.pronunciation}
</p>
</div>
)}
</div>
)}
</div>
{/* Navigation */}
<div className="flex gap-4 mt-6">
<button
onClick={handlePrevious}
disabled={currentCardIndex === 0}
className="flex-1 py-3 bg-gray-500 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-600 transition-colors font-medium"
>
</button>
<button
onClick={handleNext}
className="flex-1 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors font-medium"
>
{currentCardIndex === dueCards.length - 1 ? '完成' : '下一張'}
</button>
</div>
</div>
) : mode === 'sentence-speaking' ? (
/* Speaking Test Mode - 口說測試 */
<div className="relative">
{/* Error Report Button for Speaking Mode */}
<div className="flex justify-end mb-2">
<button
onClick={() => {
setReportingCard(currentCard)
setShowReportModal(true)
}}
className="px-3 py-2 rounded-md transition-colors text-gray-600 hover:text-gray-900"
>
🚩
</button>
</div>
<div className="bg-white rounded-xl shadow-lg p-8">
{/* Title in top-left */}
<div className="flex justify-between items-start mb-6">
<h2 className="text-2xl font-bold text-gray-900">
</h2>
<span className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
{currentCard.difficulty}
</span>
</div>
<div className="w-full">
<VoiceRecorder
targetText={currentCard.example}
targetTranslation={currentCard.exampleTranslation}
exampleImage={currentCard.exampleImage}
instructionText="請看例句圖片並大聲說出完整的例句:"
onRecordingComplete={() => {
// 簡化處理:直接顯示結果
handleSpeakingAnswer(currentCard.example)
}}
/>
</div>
{showResult && (
<div className="mt-6 p-6 rounded-lg bg-blue-50 border border-blue-200 w-full">
<p className="text-blue-700 text-left text-xl font-semibold mb-2">
</p>
<p className="text-gray-600 text-left">
...
</p>
</div>
)}
</div>
{/* Navigation */}
<div className="flex gap-4 mt-6">
<button
onClick={handlePrevious}
disabled={currentCardIndex === 0}
className="flex-1 py-3 bg-gray-500 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-600 transition-colors font-medium"
>
</button>
<button
onClick={handleNext}
className="flex-1 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors font-medium"
>
{currentCardIndex === dueCards.length - 1 ? '完成' : '下一張'}
</button>
</div>
</div>
) : mode === 'sentence-listening' ? (
/* Sentence Listening Test Mode - 例句聽力題 */
<div className="relative">
{/* Error Report Button */}
<div className="flex justify-end mb-2">
<button
onClick={() => {
setReportingCard(currentCard)
setShowReportModal(true)
}}
className="px-3 py-2 rounded-md transition-colors text-gray-600 hover:text-gray-900"
>
🚩
</button>
</div>
<div className="bg-white rounded-xl shadow-lg p-8">
{/* Title in top-left */}
<div className="flex justify-between items-start mb-6">
<h2 className="text-2xl font-bold text-gray-900">
</h2>
<span className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
{currentCard.difficulty}
</span>
</div>
{/* Instructions Test Action */}
<p className="text-lg text-gray-700 mb-6 text-left">
</p>
<div className="text-center mb-8">
<div className="mb-6">
<AudioPlayer text={currentCard.example} />
<p className="text-sm text-gray-500 mt-2">
</p>
</div>
</div>
<div className="grid grid-cols-1 gap-3 mb-6">
{sentenceOptions.map((sentence, idx) => (
<button
key={idx}
onClick={() => !showResult && handleSentenceListeningAnswer(sentence)}
disabled={showResult}
className={`w-full p-4 text-left rounded-lg border-2 transition-all ${
showResult
? sentence === currentCard.example
? 'border-green-500 bg-green-50 text-green-700'
: sentence === 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'
}`}
>
<div className="text-sm text-gray-600 mb-1"> {String.fromCharCode(65 + idx)}:</div>
<div className="text-base">{sentence}</div>
</button>
))}
</div>
{showResult && (
<div className={`p-6 rounded-lg w-full mb-6 ${
selectedAnswer === currentCard.example
? 'bg-green-50 border border-green-200'
: 'bg-red-50 border border-red-200'
}`}>
<p className={`font-semibold text-left text-xl mb-4 ${
selectedAnswer === currentCard.example
? 'text-green-700'
: 'text-red-700'
}`}>
{selectedAnswer === currentCard.example ? '正確!' : '錯誤!'}
</p>
{selectedAnswer !== currentCard.example && (
<div className="mb-4">
<p className="text-gray-700 text-left">
<strong className="text-lg">"{currentCard.example}"</strong>
</p>
<p className="text-gray-600 text-left mt-1">
{currentCard.exampleTranslation}
</p>
</div>
)}
</div>
)}
</div>
{/* Navigation */}
<div className="flex gap-4 mt-6">
<button
onClick={handlePrevious}
disabled={currentCardIndex === 0}
className="flex-1 py-3 bg-gray-500 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-600 transition-colors font-medium"
>
</button>
<button
onClick={handleNext}
className="flex-1 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors font-medium"
>
{currentCardIndex === dueCards.length - 1 ? '完成' : '下一張'}
</button>
</div>
</div>
) : mode === 'sentence-reorder' ? (
/* Sentence Reorder Mode - 例句重組題 */
<div className="relative">
{/* Error Report Button */}
<div className="flex justify-end mb-2">
<button
onClick={() => {
setReportingCard(currentCard)
setShowReportModal(true)
}}
className="px-3 py-2 rounded-md transition-colors text-gray-600 hover:text-gray-900"
>
🚩
</button>
</div>
<div className="bg-white rounded-xl shadow-lg p-8">
{/* Title in top-left */}
<div className="flex justify-between items-start mb-6">
<h2 className="text-2xl font-bold text-gray-900">
</h2>
<span className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
{currentCard.difficulty}
</span>
</div>
{/* Example Image */}
{currentCard.exampleImage && (
<div className="mb-6">
<div className="bg-gray-50 rounded-lg p-4">
<img
src={currentCard.exampleImage}
alt="Example illustration"
className="w-full max-w-md mx-auto rounded-lg cursor-pointer"
onClick={() => setModalImage(currentCard.exampleImage)}
/>
</div>
</div>
)}
{/* Arranged Sentence Area */}
<div className="mb-6">
<h3 className="text-lg font-semibold text-gray-900 mb-3 text-left"></h3>
<div className="relative min-h-[120px] bg-gray-50 rounded-lg p-4 border-2 border-dashed border-gray-300">
{arrangedWords.length === 0 ? (
<div className="absolute inset-0 flex items-center justify-center text-gray-400 text-lg">
</div>
) : (
<div className="flex flex-wrap gap-2">
{arrangedWords.map((word, index) => (
<div
key={`arranged-${index}`}
className="inline-flex items-center bg-blue-100 text-blue-800 px-3 py-2 rounded-full text-lg font-medium cursor-pointer hover:bg-blue-200 transition-colors"
onClick={() => handleRemoveFromArranged(word)}
>
{word}
<span className="ml-2 text-blue-600 hover:text-blue-800">×</span>
</div>
))}
</div>
)}
</div>
</div>
{/* Instructions Test Action */}
<p className="text-lg text-gray-700 mb-6 text-left">
</p>
{/* Shuffled Words */}
<div className="mb-6">
<h3 className="text-lg font-semibold text-gray-900 mb-3 text-left"></h3>
<div className="bg-white border border-gray-200 rounded-lg p-4 min-h-[80px]">
{shuffledWords.length === 0 ? (
<div className="text-center text-gray-400">
使
</div>
) : (
<div className="flex flex-wrap gap-2">
{shuffledWords.map((word, index) => (
<button
key={`shuffled-${index}`}
onClick={() => handleWordClick(word)}
className="bg-gray-100 text-gray-800 px-3 py-2 rounded-full text-lg font-medium cursor-pointer hover:bg-gray-200 active:bg-gray-300 transition-colors select-none"
>
{word}
</button>
))}
</div>
)}
</div>
</div>
{/* Control Buttons */}
<div className="flex gap-3 mb-6">
{arrangedWords.length > 0 && (
<button
onClick={handleCheckReorderAnswer}
className="px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors"
>
</button>
)}
<button
onClick={handleResetReorder}
className="px-6 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors"
>
</button>
</div>
{/* Result Feedback */}
{reorderResult !== null && (
<div className={`p-6 rounded-lg w-full mb-6 ${
reorderResult
? 'bg-green-50 border border-green-200'
: 'bg-red-50 border border-red-200'
}`}>
<p className={`font-semibold text-left text-xl mb-4 ${
reorderResult ? 'text-green-700' : 'text-red-700'
}`}>
{reorderResult ? '正確!' : '錯誤!'}
</p>
{!reorderResult && (
<div className="mb-4">
<p className="text-gray-700 text-left">
<strong className="text-lg">"{currentCard.example}"</strong>
</p>
</div>
)}
<div className="space-y-3">
<div className="text-left">
<p className="text-gray-600">
<strong></strong>{currentCard.exampleTranslation}
</p>
</div>
</div>
</div>
)}
</div>
{/* Navigation */}
<div className="flex gap-4 mt-6">
<button
onClick={handlePrevious}
disabled={currentCardIndex === 0}
className="flex-1 py-3 bg-gray-500 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-600 transition-colors font-medium"
>
</button>
<button
onClick={handleNext}
className="flex-1 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors font-medium"
>
{currentCardIndex === dueCards.length - 1 ? '完成' : '下一張'}
</button>
</div>
</div>
) : null}
{/* Report Modal */}
{showReportModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<h3 className="text-lg font-semibold mb-4"></h3>
<div className="mb-4">
<p className="text-sm text-gray-600 mb-2">
{reportingCard?.word}
</p>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<select
value={reportReason}
onChange={(e) => setReportReason(e.target.value)}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
>
<option value=""></option>
<option value="translation"></option>
<option value="definition"></option>
<option value="pronunciation"></option>
<option value="example"></option>
<option value="image"></option>
<option value="other"></option>
</select>
</div>
<div className="flex gap-2">
<button
onClick={() => setShowReportModal(false)}
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50"
>
</button>
<button
onClick={handleReportSubmit}
disabled={!reportReason}
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
</div>
</div>
</div>
)}
{/* Image Modal */}
{modalImage && (
<div
className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50"
onClick={() => setModalImage(null)}
>
<div className="relative max-w-4xl max-h-[90vh] mx-4">
<img
src={modalImage}
alt="放大圖片"
className="max-w-full max-h-full rounded-lg"
/>
<button
onClick={() => setModalImage(null)}
className="absolute top-4 right-4 bg-black bg-opacity-50 text-white p-2 rounded-full hover:bg-opacity-75"
>
</button>
</div>
</div>
)}
{/* Complete Modal */}
{showComplete && (
<LearningComplete
score={score}
mode={mode}
onRestart={handleRestart}
onBackToDashboard={() => router.push('/dashboard')}
/>
)}
</div>
<style jsx>{`
.card-container {
perspective: 1000px;
transition: height 0.3s ease;
overflow: visible;
position: relative;
}
.card {
position: relative;
width: 100%;
height: 100%;
text-align: center;
transition: transform 0.6s ease;
transform-style: preserve-3d;
}
.card.flipped {
transform: rotateY(180deg);
}
.card-front, .card-back {
position: absolute;
width: 100%;
height: 100%;
backface-visibility: hidden;
display: flex;
align-items: stretch;
justify-content: center;
top: 0;
left: 0;
}
.card-back {
transform: rotateY(180deg);
}
.card-front > div {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
padding: 2rem;
box-sizing: border-box;
}
.card-back > div {
width: 100%;
padding: 1.5rem;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: flex-start;
}
`}</style>
</div>
)
}