- {/* 分段式進度條 */}
-
- {segments.map((segment, index) => {
- // 計算當前段落的完成比例
- const segmentProgress = segment.plannedTests > 0 ? segment.completedTests / segment.plannedTests : 0
-
- return (
-
- {/* 背景(未完成部分) */}
-
-
- {/* 已完成部分 */}
-
-
- {/* 分界線(右邊界) */}
- {index < segments.length - 1 && (
-
- )}
-
- )
- })}
-
-
- {/* 詞卡標誌點 */}
-
- {segments.map((segment, index) => {
- // 標誌點位置(在每個詞卡段落的中心)
- const markerPosition = segment.position + (segment.widthPercentage / 2)
-
- return (
-
-
0
- ? 'bg-blue-500'
- : 'bg-gray-400'
- }`}
- onMouseMove={(e) => handleMouseMove(e, segment.word)}
- onMouseLeave={handleMouseLeave}
- title={segment.word}
- />
-
- )
- })}
-
-
- {/* Tooltip */}
- {hoveredWord && (
-
- )}
-
- {/* 進度統計 */}
-
-
- 詞卡: {progress.cards.filter(c => c.isCompleted).length} / {progress.cards.length}
-
-
- 測驗: {progress.completedTests} / {progress.totalTests}
- ({Math.round((progress.completedTests / progress.totalTests) * 100)}%)
-
-
-
- )
-}
\ No newline at end of file
diff --git a/frontend/components/VoiceRecorder.tsx b/frontend/components/VoiceRecorder.tsx
deleted file mode 100644
index 5a8c15f..0000000
--- a/frontend/components/VoiceRecorder.tsx
+++ /dev/null
@@ -1,396 +0,0 @@
-'use client';
-
-import { useState, useRef, useCallback, useEffect } from 'react';
-import { Mic, Square, Play, Upload } from 'lucide-react';
-import AudioPlayer from './AudioPlayer';
-
-export interface PronunciationScore {
- overall: number;
- accuracy: number;
- fluency: number;
- completeness: number;
- prosody: number;
- phonemes: PhonemeScore[];
- suggestions: string[];
-}
-
-export interface PhonemeScore {
- phoneme: string;
- score: number;
- suggestion?: string;
-}
-
-export interface VoiceRecorderProps {
- targetText: string;
- targetTranslation?: string;
- exampleImage?: string;
- instructionText?: string;
- onScoreReceived?: (score: PronunciationScore) => void;
- onRecordingComplete?: (audioBlob: Blob) => void;
- maxDuration?: number;
- userLevel?: string;
- className?: string;
-}
-
-export default function VoiceRecorder({
- targetText,
- targetTranslation,
- exampleImage,
- instructionText,
- onScoreReceived,
- onRecordingComplete,
- maxDuration = 30, // 30 seconds default
- userLevel = 'B1',
- className = ''
-}: VoiceRecorderProps) {
- const [isRecording, setIsRecording] = useState(false);
- const [isProcessing, setIsProcessing] = useState(false);
- const [recordingTime, setRecordingTime] = useState(0);
- const [audioBlob, setAudioBlob] = useState
(null);
- const [audioUrl, setAudioUrl] = useState(null);
- const [score, setScore] = useState(null);
- const [error, setError] = useState(null);
-
- const mediaRecorderRef = useRef(null);
- const streamRef = useRef(null);
- const timerRef = useRef(null);
- const audioRef = useRef(null);
-
- // 檢查瀏覽器支援
- const checkBrowserSupport = () => {
- if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
- setError('Your browser does not support audio recording');
- return false;
- }
- return true;
- };
-
- // 開始錄音
- const startRecording = useCallback(async () => {
- if (!checkBrowserSupport()) return;
-
- try {
- setError(null);
- setScore(null);
- setAudioBlob(null);
- setAudioUrl(null);
-
- // 請求麥克風權限
- const stream = await navigator.mediaDevices.getUserMedia({
- audio: {
- echoCancellation: true,
- noiseSuppression: true,
- sampleRate: 16000
- }
- });
-
- streamRef.current = stream;
-
- // 設置 MediaRecorder
- const mediaRecorder = new MediaRecorder(stream, {
- mimeType: 'audio/webm;codecs=opus'
- });
-
- const audioChunks: Blob[] = [];
-
- mediaRecorder.ondataavailable = (event) => {
- if (event.data.size > 0) {
- audioChunks.push(event.data);
- }
- };
-
- mediaRecorder.onstop = () => {
- const blob = new Blob(audioChunks, { type: 'audio/webm' });
- setAudioBlob(blob);
- setAudioUrl(URL.createObjectURL(blob));
- onRecordingComplete?.(blob);
-
- // 停止所有音軌
- stream.getTracks().forEach(track => track.stop());
- };
-
- mediaRecorderRef.current = mediaRecorder;
- mediaRecorder.start();
- setIsRecording(true);
- setRecordingTime(0);
-
- // 開始計時
- timerRef.current = setInterval(() => {
- setRecordingTime(prev => {
- const newTime = prev + 1;
- if (newTime >= maxDuration) {
- stopRecording();
- }
- return newTime;
- });
- }, 1000);
-
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : 'Failed to start recording';
- setError(errorMessage);
- console.error('Recording error:', error);
- }
- }, [maxDuration, onRecordingComplete]);
-
- // 停止錄音
- const stopRecording = useCallback(() => {
- if (mediaRecorderRef.current && isRecording) {
- mediaRecorderRef.current.stop();
- setIsRecording(false);
-
- if (timerRef.current) {
- clearInterval(timerRef.current);
- timerRef.current = null;
- }
-
- if (streamRef.current) {
- streamRef.current.getTracks().forEach(track => track.stop());
- streamRef.current = null;
- }
- }
- }, [isRecording]);
-
- // 播放錄音
- const playRecording = useCallback(() => {
- if (audioUrl && audioRef.current) {
- audioRef.current.src = audioUrl;
- audioRef.current.play();
- }
- }, [audioUrl]);
-
- // 評估發音
- const evaluatePronunciation = useCallback(async () => {
- if (!audioBlob || !targetText) {
- setError('No audio to evaluate');
- return;
- }
-
- try {
- setIsProcessing(true);
- setError(null);
-
- const formData = new FormData();
- formData.append('audioFile', audioBlob, 'recording.webm');
- formData.append('targetText', targetText);
- formData.append('userLevel', userLevel);
-
- const token = localStorage.getItem('token');
- if (!token) {
- throw new Error('Authentication required');
- }
-
- const response = await fetch('/api/audio/pronunciation/evaluate', {
- method: 'POST',
- headers: {
- 'Authorization': `Bearer ${token}`
- },
- body: formData
- });
-
- if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`);
- }
-
- const result = await response.json();
-
- if (result.error) {
- throw new Error(result.error);
- }
-
- setScore(result);
- onScoreReceived?.(result);
-
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : 'Failed to evaluate pronunciation';
- setError(errorMessage);
- } finally {
- setIsProcessing(false);
- }
- }, [audioBlob, targetText, userLevel, onScoreReceived]);
-
- // 格式化時間
- const formatTime = (seconds: number) => {
- const mins = Math.floor(seconds / 60);
- const secs = seconds % 60;
- return `${mins}:${secs.toString().padStart(2, '0')}`;
- };
-
- // 獲取評分顏色
- const getScoreColor = (score: number) => {
- if (score >= 90) return 'text-green-600';
- if (score >= 80) return 'text-blue-600';
- if (score >= 70) return 'text-yellow-600';
- if (score >= 60) return 'text-orange-600';
- return 'text-red-600';
- };
-
- // 清理資源
- useEffect(() => {
- return () => {
- if (timerRef.current) {
- clearInterval(timerRef.current);
- }
- if (streamRef.current) {
- streamRef.current.getTracks().forEach(track => track.stop());
- }
- if (audioUrl) {
- URL.revokeObjectURL(audioUrl);
- }
- };
- }, [audioUrl]);
-
- return (
-
- {/* 隱藏的音頻元素 */}
-
-
-
-
- {/* 目標文字顯示 */}
-
-
-
-
-
{targetText}
- {targetTranslation && (
-
{targetTranslation}
- )}
-
-
-
-
-
-
- {/* Instruction Text */}
- {instructionText && (
-
-
- {instructionText}
-
-
- )}
-
- {/* 錄音控制區 */}
-
-
- {/* 錄音按鈕 */}
-
-
- {/* 錄音狀態 */}
- {isRecording && (
-
-
- 🔴 錄音中...
-
-
- {formatTime(recordingTime)} / {formatTime(maxDuration)}
-
-
- )}
-
- {/* 播放和評估按鈕 */}
- {audioBlob && !isRecording && (
-
-
-
-
- )}
-
- {/* 處理狀態 */}
- {isProcessing && (
-
- )}
-
- {/* 錯誤顯示 */}
- {error && (
-
- {error}
-
- )}
-
- {/* 評分結果 */}
- {score && (
-
- {/* 總分 */}
-
-
- {score.overall}
-
-
總體評分
-
-
- {/* 詳細評分 */}
-
-
- 準確度:
- {score.accuracy.toFixed(1)}
-
-
- 流暢度:
- {score.fluency.toFixed(1)}
-
-
- 完整度:
- {score.completeness.toFixed(1)}
-
-
- 音調:
- {score.prosody.toFixed(1)}
-
-
-
- {/* 改進建議 */}
- {score.suggestions.length > 0 && (
-
-
💡 改進建議:
-
- {score.suggestions.map((suggestion, index) => (
- -
- •
- {suggestion}
-
- ))}
-
-
- )}
-
- )}
-
-
-
- );
-}
\ No newline at end of file
diff --git a/frontend/components/FlashcardForm.tsx b/frontend/components/flashcards/FlashcardForm.tsx
similarity index 100%
rename from frontend/components/FlashcardForm.tsx
rename to frontend/components/flashcards/FlashcardForm.tsx
diff --git a/frontend/components/LearningComplete.tsx b/frontend/components/flashcards/LearningComplete.tsx
similarity index 100%
rename from frontend/components/LearningComplete.tsx
rename to frontend/components/flashcards/LearningComplete.tsx
diff --git a/frontend/components/ClickableTextV2.tsx b/frontend/components/generate/ClickableTextV2.tsx
similarity index 100%
rename from frontend/components/ClickableTextV2.tsx
rename to frontend/components/generate/ClickableTextV2.tsx
diff --git a/frontend/components/media/AudioPlayer.tsx b/frontend/components/media/AudioPlayer.tsx
new file mode 100644
index 0000000..4981f19
--- /dev/null
+++ b/frontend/components/media/AudioPlayer.tsx
@@ -0,0 +1,91 @@
+import React, { useState, useRef } from 'react'
+import { Play, Pause, Volume2 } from 'lucide-react'
+
+interface AudioPlayerProps {
+ text: string
+ className?: string
+ autoPlay?: boolean
+ voice?: 'us' | 'uk'
+ speed?: number
+}
+
+export default function AudioPlayer({
+ text,
+ className = '',
+ autoPlay = false,
+ voice = 'us',
+ speed = 1.0
+}: AudioPlayerProps) {
+ const [isPlaying, setIsPlaying] = useState(false)
+ const [isLoading, setIsLoading] = useState(false)
+ const audioRef = useRef(null)
+
+ const handlePlay = async () => {
+ if (!text.trim()) return
+
+ try {
+ setIsLoading(true)
+
+ // 簡單的TTS模擬 - 實際應該調用TTS API
+ const utterance = new SpeechSynthesisUtterance(text)
+ utterance.lang = voice === 'us' ? 'en-US' : 'en-GB'
+ utterance.rate = speed
+
+ utterance.onstart = () => {
+ setIsPlaying(true)
+ setIsLoading(false)
+ }
+
+ utterance.onend = () => {
+ setIsPlaying(false)
+ }
+
+ utterance.onerror = () => {
+ setIsPlaying(false)
+ setIsLoading(false)
+ }
+
+ window.speechSynthesis.speak(utterance)
+
+ } catch (error) {
+ console.error('TTS Error:', error)
+ setIsLoading(false)
+ setIsPlaying(false)
+ }
+ }
+
+ const handleStop = () => {
+ window.speechSynthesis.cancel()
+ setIsPlaying(false)
+ }
+
+ return (
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/components/review/ReviewRunner.tsx b/frontend/components/review/ReviewRunner.tsx
index 102816d..fdad04a 100644
--- a/frontend/components/review/ReviewRunner.tsx
+++ b/frontend/components/review/ReviewRunner.tsx
@@ -312,7 +312,7 @@ export const ReviewRunner: React.FC = ({ className }) => {
translation: currentCard.translation || '',
exampleTranslation: currentCard.translation || '',
pronunciation: currentCard.pronunciation,
- difficultyLevel: currentCard.difficultyLevel || 'A2',
+ cefr: currentCard.cefr || 'A1',
exampleImage: currentCard.exampleImage,
synonyms: currentCard.synonyms || []
}
diff --git a/frontend/components/review/review-tests/FlipMemoryTest.tsx b/frontend/components/review/review-tests/FlipMemoryTest.tsx
index ffb42d7..54f9f7d 100644
--- a/frontend/components/review/review-tests/FlipMemoryTest.tsx
+++ b/frontend/components/review/review-tests/FlipMemoryTest.tsx
@@ -1,5 +1,5 @@
import { useState, useRef, useEffect, memo, useCallback } from 'react'
-import AudioPlayer from '@/components/AudioPlayer'
+import AudioPlayer from '@/components/media/AudioPlayer'
import {
ErrorReportButton,
TestHeader,
@@ -95,7 +95,7 @@ const FlipMemoryTestComponent: React.FC = ({
@@ -132,7 +132,7 @@ const FlipMemoryTestComponent: React.FC
= ({
diff --git a/frontend/components/review/review-tests/SentenceListeningTest.tsx b/frontend/components/review/review-tests/SentenceListeningTest.tsx
index 26fc891..530b929 100644
--- a/frontend/components/review/review-tests/SentenceListeningTest.tsx
+++ b/frontend/components/review/review-tests/SentenceListeningTest.tsx
@@ -1,5 +1,5 @@
import React, { useState, useCallback, useMemo, memo } from 'react'
-import AudioPlayer from '@/components/AudioPlayer'
+import AudioPlayer from '@/components/media/AudioPlayer'
import {
TestResultDisplay,
ListeningTestContainer,
diff --git a/frontend/components/review/review-tests/VocabListeningTest.tsx b/frontend/components/review/review-tests/VocabListeningTest.tsx
index 16f354d..fab6bfe 100644
--- a/frontend/components/review/review-tests/VocabListeningTest.tsx
+++ b/frontend/components/review/review-tests/VocabListeningTest.tsx
@@ -1,5 +1,5 @@
import React, { useState, useCallback, useMemo, memo } from 'react'
-import AudioPlayer from '@/components/AudioPlayer'
+import AudioPlayer from '@/components/media/AudioPlayer'
import {
TestResultDisplay,
ListeningTestContainer,
diff --git a/frontend/components/review/shared/TestResultDisplay.tsx b/frontend/components/review/shared/TestResultDisplay.tsx
index 9a05e43..1105b93 100644
--- a/frontend/components/review/shared/TestResultDisplay.tsx
+++ b/frontend/components/review/shared/TestResultDisplay.tsx
@@ -1,5 +1,5 @@
import React, { memo } from 'react'
-import AudioPlayer from '@/components/AudioPlayer'
+import AudioPlayer from '@/components/media/AudioPlayer'
interface TestResultDisplayProps {
isCorrect: boolean
diff --git a/frontend/components/Navigation.tsx b/frontend/components/shared/Navigation.tsx
similarity index 100%
rename from frontend/components/Navigation.tsx
rename to frontend/components/shared/Navigation.tsx
diff --git a/frontend/components/ProtectedRoute.tsx b/frontend/components/shared/ProtectedRoute.tsx
similarity index 100%
rename from frontend/components/ProtectedRoute.tsx
rename to frontend/components/shared/ProtectedRoute.tsx
diff --git a/frontend/components/Toast.tsx b/frontend/components/shared/Toast.tsx
similarity index 100%
rename from frontend/components/Toast.tsx
rename to frontend/components/shared/Toast.tsx