feat: 實現完整語音功能系統與學習模式整合
- 新增 TTS 語音播放和語音辨識功能 - 實現 Azure Speech Services 整合架構 - 建立完整的音頻快取和評估系統 - 整合語音功能到五種學習模式 - 新增語音錄製和發音評分組件 - 優化學習進度和評分機制 - 完成語音功能規格書和測試案例文檔 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
f90286ad88
commit
d5395f5741
|
|
@ -4,6 +4,9 @@ import { useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { Navigation } from '@/components/Navigation'
|
import { Navigation } from '@/components/Navigation'
|
||||||
|
import AudioPlayer from '@/components/AudioPlayer'
|
||||||
|
import VoiceRecorder from '@/components/VoiceRecorder'
|
||||||
|
import LearningComplete from '@/components/LearningComplete'
|
||||||
|
|
||||||
export default function LearnPage() {
|
export default function LearnPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
@ -21,6 +24,7 @@ export default function LearnPage() {
|
||||||
const [showReportModal, setShowReportModal] = useState(false)
|
const [showReportModal, setShowReportModal] = useState(false)
|
||||||
const [reportReason, setReportReason] = useState('')
|
const [reportReason, setReportReason] = useState('')
|
||||||
const [reportingCard, setReportingCard] = useState<any>(null)
|
const [reportingCard, setReportingCard] = useState<any>(null)
|
||||||
|
const [showComplete, setShowComplete] = useState(false)
|
||||||
|
|
||||||
// Mock data with real example images
|
// Mock data with real example images
|
||||||
const cards = [
|
const cards = [
|
||||||
|
|
@ -89,6 +93,9 @@ export default function LearnPage() {
|
||||||
setShowResult(false)
|
setShowResult(false)
|
||||||
setFillAnswer('')
|
setFillAnswer('')
|
||||||
setShowHint(false)
|
setShowHint(false)
|
||||||
|
} else {
|
||||||
|
// Learning session complete
|
||||||
|
setShowComplete(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -104,9 +111,20 @@ export default function LearnPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDifficultyRate = (rating: number) => {
|
const handleDifficultyRate = (rating: number) => {
|
||||||
// Mock rating logic
|
// Update score based on difficulty rating
|
||||||
console.log(`Rated ${rating} for ${currentCard.word}`)
|
console.log(`Rated ${rating} for ${currentCard.word}`)
|
||||||
|
|
||||||
|
// SM-2 Algorithm simulation
|
||||||
|
if (rating >= 4) {
|
||||||
|
setScore({ ...score, correct: score.correct + 1, total: score.total + 1 })
|
||||||
|
} else {
|
||||||
|
setScore({ ...score, total: score.total + 1 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto advance after rating
|
||||||
|
setTimeout(() => {
|
||||||
handleNext()
|
handleNext()
|
||||||
|
}, 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleQuizAnswer = (answer: string) => {
|
const handleQuizAnswer = (answer: string) => {
|
||||||
|
|
@ -119,6 +137,36 @@ export default function LearnPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleFillAnswer = () => {
|
||||||
|
if (fillAnswer.toLowerCase().trim() === currentCard.word.toLowerCase()) {
|
||||||
|
setScore({ ...score, correct: score.correct + 1, total: score.total + 1 })
|
||||||
|
} else {
|
||||||
|
setScore({ ...score, total: score.total + 1 })
|
||||||
|
}
|
||||||
|
setShowResult(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleListeningAnswer = (word: string) => {
|
||||||
|
setSelectedAnswer(word)
|
||||||
|
setShowResult(true)
|
||||||
|
if (word === currentCard.word) {
|
||||||
|
setScore({ ...score, correct: score.correct + 1, total: score.total + 1 })
|
||||||
|
} else {
|
||||||
|
setScore({ ...score, total: score.total + 1 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRestart = () => {
|
||||||
|
setCurrentCardIndex(0)
|
||||||
|
setIsFlipped(false)
|
||||||
|
setSelectedAnswer(null)
|
||||||
|
setShowResult(false)
|
||||||
|
setFillAnswer('')
|
||||||
|
setShowHint(false)
|
||||||
|
setScore({ correct: 0, total: 0 })
|
||||||
|
setShowComplete(false)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
|
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
|
|
@ -132,9 +180,21 @@ export default function LearnPage() {
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex justify-between items-center mb-2">
|
<div className="flex justify-between items-center mb-2">
|
||||||
<span className="text-sm text-gray-600">進度</span>
|
<span className="text-sm text-gray-600">進度</span>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
<span className="text-sm text-gray-600">
|
<span className="text-sm text-gray-600">
|
||||||
{currentCardIndex + 1} / {cards.length}
|
{currentCardIndex + 1} / {cards.length}
|
||||||
</span>
|
</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>
|
||||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||||
<div
|
<div
|
||||||
|
|
@ -245,9 +305,19 @@ export default function LearnPage() {
|
||||||
<div className="text-lg text-gray-600 mb-2">
|
<div className="text-lg text-gray-600 mb-2">
|
||||||
{currentCard.partOfSpeech}
|
{currentCard.partOfSpeech}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center justify-center gap-4 mb-4">
|
||||||
<div className="text-lg text-gray-500">
|
<div className="text-lg text-gray-500">
|
||||||
{currentCard.pronunciation}
|
{currentCard.pronunciation}
|
||||||
</div>
|
</div>
|
||||||
|
<AudioPlayer
|
||||||
|
text={currentCard.word}
|
||||||
|
accent="us"
|
||||||
|
speed={1.0}
|
||||||
|
showAccentSelector={false}
|
||||||
|
showSpeedControl={false}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="mt-8 text-sm text-gray-400">
|
<div className="mt-8 text-sm text-gray-400">
|
||||||
點擊翻轉查看答案
|
點擊翻轉查看答案
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -272,8 +342,16 @@ export default function LearnPage() {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-semibold text-gray-700 mb-1">例句</div>
|
<div className="text-sm font-semibold text-gray-700 mb-1">例句</div>
|
||||||
<div className="text-gray-600">{currentCard.example}</div>
|
<div className="text-gray-600 mb-2">{currentCard.example}</div>
|
||||||
<div className="text-gray-500 text-sm mt-1">{currentCard.exampleTranslation}</div>
|
<div className="text-gray-500 text-sm mb-3">{currentCard.exampleTranslation}</div>
|
||||||
|
<AudioPlayer
|
||||||
|
text={currentCard.example}
|
||||||
|
accent="us"
|
||||||
|
speed={0.8}
|
||||||
|
showAccentSelector={true}
|
||||||
|
showSpeedControl={true}
|
||||||
|
className="mt-2"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-semibold text-gray-700 mb-1">同義詞</div>
|
<div className="text-sm font-semibold text-gray-700 mb-1">同義詞</div>
|
||||||
|
|
@ -342,12 +420,20 @@ export default function LearnPage() {
|
||||||
<div className="bg-white rounded-2xl shadow-xl p-8">
|
<div className="bg-white rounded-2xl shadow-xl p-8">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="text-sm text-gray-600 mb-2">根據定義選擇正確的中文翻譯</div>
|
<div className="text-sm text-gray-600 mb-2">根據定義選擇正確的中文翻譯</div>
|
||||||
<div className="text-xl text-gray-800 leading-relaxed">
|
<div className="text-xl text-gray-800 leading-relaxed mb-3">
|
||||||
{currentCard.definition}
|
{currentCard.definition}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-500 mt-2">
|
<div className="text-sm text-gray-500 mb-3">
|
||||||
({currentCard.partOfSpeech})
|
({currentCard.partOfSpeech})
|
||||||
</div>
|
</div>
|
||||||
|
<AudioPlayer
|
||||||
|
text={currentCard.definition}
|
||||||
|
accent="us"
|
||||||
|
speed={0.9}
|
||||||
|
showAccentSelector={false}
|
||||||
|
showSpeedControl={true}
|
||||||
|
className="mt-2"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|
@ -468,7 +554,7 @@ export default function LearnPage() {
|
||||||
className="w-full px-4 py-3 border-2 border-gray-300 rounded-lg focus:border-primary focus:outline-none text-lg"
|
className="w-full px-4 py-3 border-2 border-gray-300 rounded-lg focus:border-primary focus:outline-none text-lg"
|
||||||
onKeyPress={(e) => {
|
onKeyPress={(e) => {
|
||||||
if (e.key === 'Enter' && fillAnswer) {
|
if (e.key === 'Enter' && fillAnswer) {
|
||||||
setShowResult(true)
|
handleFillAnswer()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -477,7 +563,7 @@ export default function LearnPage() {
|
||||||
{/* Submit Button */}
|
{/* Submit Button */}
|
||||||
{!showResult && (
|
{!showResult && (
|
||||||
<button
|
<button
|
||||||
onClick={() => fillAnswer && setShowResult(true)}
|
onClick={() => fillAnswer && handleFillAnswer()}
|
||||||
disabled={!fillAnswer}
|
disabled={!fillAnswer}
|
||||||
className="w-full py-3 bg-primary text-white rounded-lg font-medium hover:bg-primary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
className="w-full py-3 bg-primary text-white rounded-lg font-medium hover:bg-primary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
|
|
@ -508,8 +594,16 @@ export default function LearnPage() {
|
||||||
)}
|
)}
|
||||||
<div className="mt-3 text-sm text-gray-600">
|
<div className="mt-3 text-sm text-gray-600">
|
||||||
<div className="font-semibold mb-1">完整例句:</div>
|
<div className="font-semibold mb-1">完整例句:</div>
|
||||||
<div>{currentCard.example}</div>
|
<div className="mb-2">{currentCard.example}</div>
|
||||||
<div className="text-gray-500 mt-1">{currentCard.exampleTranslation}</div>
|
<div className="text-gray-500 mb-3">{currentCard.exampleTranslation}</div>
|
||||||
|
<AudioPlayer
|
||||||
|
text={currentCard.example}
|
||||||
|
accent="us"
|
||||||
|
speed={0.8}
|
||||||
|
showAccentSelector={false}
|
||||||
|
showSpeedControl={true}
|
||||||
|
className="mt-2"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -539,28 +633,20 @@ export default function LearnPage() {
|
||||||
<div className="mb-6 text-center">
|
<div className="mb-6 text-center">
|
||||||
<div className="text-sm text-gray-600 mb-4">聽音頻,選擇正確的單字</div>
|
<div className="text-sm text-gray-600 mb-4">聽音頻,選擇正確的單字</div>
|
||||||
|
|
||||||
{/* Audio Play Button */}
|
{/* Audio Player */}
|
||||||
<button
|
<div className="flex flex-col items-center mb-6">
|
||||||
onClick={() => {
|
<AudioPlayer
|
||||||
setAudioPlaying(true)
|
text={currentCard.word}
|
||||||
// Simulate audio playing
|
accent="us"
|
||||||
setTimeout(() => setAudioPlaying(false), 2000)
|
speed={1.0}
|
||||||
}}
|
showAccentSelector={true}
|
||||||
className="mx-auto mb-6 p-8 bg-gray-100 rounded-full hover:bg-gray-200 transition-colors"
|
showSpeedControl={true}
|
||||||
>
|
className="mb-4"
|
||||||
{audioPlaying ? (
|
/>
|
||||||
<svg className="w-16 h-16 text-primary animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<div className="text-sm text-gray-500">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
|
聽發音,然後選擇正確的單字
|
||||||
</svg>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<svg className="w-16 h-16 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="text-sm text-gray-500">點擊播放按鈕聽發音</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Word Options */}
|
{/* Word Options */}
|
||||||
|
|
@ -568,7 +654,7 @@ export default function LearnPage() {
|
||||||
{[currentCard.word, 'determine', 'achieve', 'consider'].map((word) => (
|
{[currentCard.word, 'determine', 'achieve', 'consider'].map((word) => (
|
||||||
<button
|
<button
|
||||||
key={word}
|
key={word}
|
||||||
onClick={() => !showResult && handleQuizAnswer(word)}
|
onClick={() => !showResult && handleListeningAnswer(word)}
|
||||||
disabled={showResult}
|
disabled={showResult}
|
||||||
className={`p-4 text-lg font-medium rounded-lg border-2 transition-all ${
|
className={`p-4 text-lg font-medium rounded-lg border-2 transition-all ${
|
||||||
showResult && word === currentCard.word
|
showResult && word === currentCard.word
|
||||||
|
|
@ -640,60 +726,42 @@ export default function LearnPage() {
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<span className="font-semibold text-lg">{currentCard.word}</span>
|
<span className="font-semibold text-lg">{currentCard.word}</span>
|
||||||
<span className="text-gray-500">{currentCard.pronunciation}</span>
|
<span className="text-gray-500">{currentCard.pronunciation}</span>
|
||||||
<button className="text-primary hover:text-primary-hover">
|
<AudioPlayer
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
text={currentCard.word}
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
|
accent="us"
|
||||||
</svg>
|
speed={1.0}
|
||||||
</button>
|
showAccentSelector={false}
|
||||||
|
showSpeedControl={false}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="text-sm text-gray-600 mb-2">完整例句發音:</div>
|
||||||
|
<AudioPlayer
|
||||||
|
text={currentCard.example}
|
||||||
|
accent="us"
|
||||||
|
speed={0.8}
|
||||||
|
showAccentSelector={true}
|
||||||
|
showSpeedControl={true}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recording Button */}
|
{/* Voice Recorder */}
|
||||||
<div className="text-center">
|
<VoiceRecorder
|
||||||
<button
|
targetText={currentCard.example}
|
||||||
onClick={() => {
|
onScoreReceived={(score) => {
|
||||||
setIsRecording(!isRecording)
|
console.log('Pronunciation score:', score);
|
||||||
if (!isRecording) {
|
setShowResult(true);
|
||||||
// Start recording
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsRecording(false)
|
|
||||||
setShowResult(true)
|
|
||||||
}, 3000)
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
className={`p-6 rounded-full transition-all ${
|
onRecordingComplete={(audioBlob) => {
|
||||||
isRecording
|
console.log('Recording completed:', audioBlob);
|
||||||
? 'bg-red-500 hover:bg-red-600 animate-pulse'
|
}}
|
||||||
: 'bg-primary hover:bg-primary-hover'
|
maxDuration={30}
|
||||||
}`}
|
userLevel="B1"
|
||||||
>
|
className="mt-4"
|
||||||
{isRecording ? (
|
/>
|
||||||
<svg className="w-12 h-12 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<svg className="w-12 h-12 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<div className="mt-3 text-sm text-gray-600">
|
|
||||||
{isRecording ? '錄音中... 點擊停止' : '點擊開始錄音'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Result Display */}
|
|
||||||
{showResult && (
|
|
||||||
<div className="mt-6 p-4 bg-green-50 border-2 border-green-500 rounded-lg">
|
|
||||||
<div className="text-green-700 font-semibold mb-2">
|
|
||||||
✓ 完成口說練習!
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-600">
|
|
||||||
提示:持續練習可以提高發音準確度和流暢度
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -835,6 +903,16 @@ export default function LearnPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Learning Complete Modal */}
|
||||||
|
{showComplete && (
|
||||||
|
<LearningComplete
|
||||||
|
score={score}
|
||||||
|
mode={mode}
|
||||||
|
onRestart={handleRestart}
|
||||||
|
onBackToDashboard={() => router.push('/dashboard')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,322 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { Play, Pause, Volume2, VolumeX, Settings } from 'lucide-react';
|
||||||
|
|
||||||
|
export interface AudioPlayerProps {
|
||||||
|
text: string;
|
||||||
|
audioUrl?: string;
|
||||||
|
accent?: 'us' | 'uk';
|
||||||
|
speed?: number;
|
||||||
|
autoPlay?: boolean;
|
||||||
|
showAccentSelector?: boolean;
|
||||||
|
showSpeedControl?: boolean;
|
||||||
|
onPlayStart?: () => void;
|
||||||
|
onPlayEnd?: () => void;
|
||||||
|
onError?: (error: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TTSResponse {
|
||||||
|
audioUrl: string;
|
||||||
|
duration: number;
|
||||||
|
cacheHit: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AudioPlayer({
|
||||||
|
text,
|
||||||
|
audioUrl: providedAudioUrl,
|
||||||
|
accent = 'us',
|
||||||
|
speed = 1.0,
|
||||||
|
autoPlay = false,
|
||||||
|
showAccentSelector = true,
|
||||||
|
showSpeedControl = true,
|
||||||
|
onPlayStart,
|
||||||
|
onPlayEnd,
|
||||||
|
onError,
|
||||||
|
className = ''
|
||||||
|
}: AudioPlayerProps) {
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
|
const [volume, setVolume] = useState(1);
|
||||||
|
const [currentAccent, setCurrentAccent] = useState<'us' | 'uk'>(accent);
|
||||||
|
const [currentSpeed, setCurrentSpeed] = useState(speed);
|
||||||
|
const [audioUrl, setAudioUrl] = useState<string | null>(providedAudioUrl || null);
|
||||||
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
|
||||||
|
// 生成音頻
|
||||||
|
const generateAudio = async (textToSpeak: string, accent: 'us' | 'uk', speed: number) => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const response = await fetch('/api/audio/tts', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token') || ''}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
text: textToSpeak,
|
||||||
|
accent: accent,
|
||||||
|
speed: speed,
|
||||||
|
voice: ''
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: TTSResponse = await response.json();
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
throw new Error(data.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
setAudioUrl(data.audioUrl);
|
||||||
|
return data.audioUrl;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Failed to generate audio';
|
||||||
|
setError(errorMessage);
|
||||||
|
onError?.(errorMessage);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 播放音頻
|
||||||
|
const playAudio = async () => {
|
||||||
|
if (!text) {
|
||||||
|
setError('No text to play');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let urlToPlay = audioUrl;
|
||||||
|
|
||||||
|
// 如果沒有音頻 URL,先生成
|
||||||
|
if (!urlToPlay) {
|
||||||
|
urlToPlay = await generateAudio(text, currentAccent, currentSpeed);
|
||||||
|
if (!urlToPlay) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (!audio) return;
|
||||||
|
|
||||||
|
audio.src = urlToPlay;
|
||||||
|
audio.playbackRate = currentSpeed;
|
||||||
|
audio.volume = isMuted ? 0 : volume;
|
||||||
|
|
||||||
|
await audio.play();
|
||||||
|
setIsPlaying(true);
|
||||||
|
onPlayStart?.();
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Failed to play audio';
|
||||||
|
setError(errorMessage);
|
||||||
|
onError?.(errorMessage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 暫停音頻
|
||||||
|
const pauseAudio = () => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (audio) {
|
||||||
|
audio.pause();
|
||||||
|
setIsPlaying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 切換播放/暫停
|
||||||
|
const togglePlayPause = () => {
|
||||||
|
if (isPlaying) {
|
||||||
|
pauseAudio();
|
||||||
|
} else {
|
||||||
|
playAudio();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 處理音頻事件
|
||||||
|
const handleAudioEnd = () => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
onPlayEnd?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAudioError = () => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
const errorMessage = 'Audio playback error';
|
||||||
|
setError(errorMessage);
|
||||||
|
onError?.(errorMessage);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 切換口音
|
||||||
|
const handleAccentChange = async (newAccent: 'us' | 'uk') => {
|
||||||
|
if (newAccent === currentAccent) return;
|
||||||
|
|
||||||
|
setCurrentAccent(newAccent);
|
||||||
|
setAudioUrl(null); // 清除現有音頻,強制重新生成
|
||||||
|
|
||||||
|
// 如果正在播放,停止並重新生成
|
||||||
|
if (isPlaying) {
|
||||||
|
pauseAudio();
|
||||||
|
await generateAudio(text, newAccent, currentSpeed);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 切換速度
|
||||||
|
const handleSpeedChange = async (newSpeed: number) => {
|
||||||
|
if (newSpeed === currentSpeed) return;
|
||||||
|
|
||||||
|
setCurrentSpeed(newSpeed);
|
||||||
|
|
||||||
|
// 如果音頻正在播放,直接調整播放速度
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (audio && isPlaying) {
|
||||||
|
audio.playbackRate = newSpeed;
|
||||||
|
} else {
|
||||||
|
// 否則清除音頻,重新生成
|
||||||
|
setAudioUrl(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 音量控制
|
||||||
|
const handleVolumeChange = (newVolume: number) => {
|
||||||
|
setVolume(newVolume);
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (audio) {
|
||||||
|
audio.volume = isMuted ? 0 : newVolume;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMute = () => {
|
||||||
|
const newMuted = !isMuted;
|
||||||
|
setIsMuted(newMuted);
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (audio) {
|
||||||
|
audio.volume = newMuted ? 0 : volume;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 自動播放
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoPlay && text && !audioUrl) {
|
||||||
|
generateAudio(text, currentAccent, currentSpeed);
|
||||||
|
}
|
||||||
|
}, [autoPlay, text]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`audio-player flex items-center gap-2 ${className}`}>
|
||||||
|
{/* 隱藏的音頻元素 */}
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
onEnded={handleAudioEnd}
|
||||||
|
onError={handleAudioError}
|
||||||
|
preload="none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 播放/暫停按鈕 */}
|
||||||
|
<button
|
||||||
|
onClick={togglePlayPause}
|
||||||
|
disabled={isLoading || !text}
|
||||||
|
className={`
|
||||||
|
flex items-center justify-center w-10 h-10 rounded-full transition-colors
|
||||||
|
${isLoading || !text
|
||||||
|
? 'bg-gray-300 cursor-not-allowed'
|
||||||
|
: 'bg-blue-600 hover:bg-blue-700 text-white'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
title={isPlaying ? 'Pause' : 'Play'}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="animate-spin w-4 h-4 border-2 border-white border-t-transparent rounded-full" />
|
||||||
|
) : isPlaying ? (
|
||||||
|
<Pause size={20} />
|
||||||
|
) : (
|
||||||
|
<Play size={20} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 口音選擇器 */}
|
||||||
|
{showAccentSelector && (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => handleAccentChange('us')}
|
||||||
|
className={`
|
||||||
|
px-2 py-1 text-xs rounded transition-colors
|
||||||
|
${currentAccent === 'us'
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
US
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleAccentChange('uk')}
|
||||||
|
className={`
|
||||||
|
px-2 py-1 text-xs rounded transition-colors
|
||||||
|
${currentAccent === 'uk'
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
UK
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 速度控制 */}
|
||||||
|
{showSpeedControl && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-xs text-gray-600">Speed:</span>
|
||||||
|
<select
|
||||||
|
value={currentSpeed}
|
||||||
|
onChange={(e) => handleSpeedChange(parseFloat(e.target.value))}
|
||||||
|
className="text-xs border border-gray-300 rounded px-1 py-0.5"
|
||||||
|
>
|
||||||
|
<option value={0.5}>0.5x</option>
|
||||||
|
<option value={0.75}>0.75x</option>
|
||||||
|
<option value={1.0}>1x</option>
|
||||||
|
<option value={1.25}>1.25x</option>
|
||||||
|
<option value={1.5}>1.5x</option>
|
||||||
|
<option value={2.0}>2x</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 音量控制 */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={toggleMute}
|
||||||
|
className="p-1 text-gray-600 hover:text-gray-800"
|
||||||
|
title={isMuted ? 'Unmute' : 'Mute'}
|
||||||
|
>
|
||||||
|
{isMuted ? <VolumeX size={16} /> : <Volume2 size={16} />}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.1}
|
||||||
|
value={isMuted ? 0 : volume}
|
||||||
|
onChange={(e) => handleVolumeChange(parseFloat(e.target.value))}
|
||||||
|
className="w-16 h-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 錯誤顯示 */}
|
||||||
|
{error && (
|
||||||
|
<div className="text-xs text-red-600 bg-red-50 px-2 py-1 rounded">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { flashcardsService, type CreateFlashcardRequest, type CardSet } from '@/lib/services/flashcards'
|
import { flashcardsService, type CreateFlashcardRequest, type CardSet } from '@/lib/services/flashcards'
|
||||||
|
import AudioPlayer from './AudioPlayer'
|
||||||
|
|
||||||
interface FlashcardFormProps {
|
interface FlashcardFormProps {
|
||||||
cardSets: CardSet[]
|
cardSets: CardSet[]
|
||||||
|
|
@ -154,14 +155,28 @@ export function FlashcardForm({ cardSets, initialData, isEdit = false, onSuccess
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
英文單字 *
|
英文單字 *
|
||||||
</label>
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.english}
|
value={formData.english}
|
||||||
onChange={(e) => handleChange('english', e.target.value)}
|
onChange={(e) => handleChange('english', e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
|
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||||
placeholder="例如:negotiate"
|
placeholder="例如:negotiate"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
{formData.english && (
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<AudioPlayer
|
||||||
|
text={formData.english}
|
||||||
|
accent="us"
|
||||||
|
speed={1.0}
|
||||||
|
showAccentSelector={true}
|
||||||
|
showSpeedControl={false}
|
||||||
|
className="w-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 中文翻譯 */}
|
{/* 中文翻譯 */}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
interface LearningCompleteProps {
|
||||||
|
score: {
|
||||||
|
correct: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
mode: string;
|
||||||
|
onRestart?: () => void;
|
||||||
|
onBackToDashboard?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LearningComplete({
|
||||||
|
score,
|
||||||
|
mode,
|
||||||
|
onRestart,
|
||||||
|
onBackToDashboard
|
||||||
|
}: LearningCompleteProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const percentage = score.total > 0 ? Math.round((score.correct / score.total) * 100) : 0;
|
||||||
|
|
||||||
|
const getGradeEmoji = (percentage: number) => {
|
||||||
|
if (percentage >= 90) return '🏆';
|
||||||
|
if (percentage >= 80) return '🎉';
|
||||||
|
if (percentage >= 70) return '👍';
|
||||||
|
if (percentage >= 60) return '😊';
|
||||||
|
return '💪';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGradeMessage = (percentage: number) => {
|
||||||
|
if (percentage >= 90) return '太棒了!你是學習高手!';
|
||||||
|
if (percentage >= 80) return '做得很好!繼續保持!';
|
||||||
|
if (percentage >= 70) return '不錯的表現!';
|
||||||
|
if (percentage >= 60) return '還不錯,繼續努力!';
|
||||||
|
return '加油!多練習會更好的!';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getModeDisplayName = (mode: string) => {
|
||||||
|
switch (mode) {
|
||||||
|
case 'flip': return '翻卡模式';
|
||||||
|
case 'quiz': return '選擇題模式';
|
||||||
|
case 'fill': return '填空題模式';
|
||||||
|
case 'listening': return '聽力測試模式';
|
||||||
|
case 'speaking': return '口說練習模式';
|
||||||
|
default: return '學習模式';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||||
|
<div className="bg-white rounded-2xl shadow-2xl max-w-md w-full p-8 text-center">
|
||||||
|
{/* Celebration Icon */}
|
||||||
|
<div className="text-6xl mb-4">
|
||||||
|
{getGradeEmoji(percentage)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||||
|
學習完成!
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Mode */}
|
||||||
|
<div className="text-sm text-gray-600 mb-6">
|
||||||
|
{getModeDisplayName(mode)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Score Display */}
|
||||||
|
<div className="bg-gray-50 rounded-xl p-6 mb-6">
|
||||||
|
<div className="text-4xl font-bold text-blue-600 mb-2">
|
||||||
|
{percentage}%
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-600 mb-3">
|
||||||
|
正確率
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
答對 <span className="font-semibold text-green-600">{score.correct}</span> 題,
|
||||||
|
共 <span className="font-semibold">{score.total}</span> 題
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Encouragement Message */}
|
||||||
|
<div className="text-gray-700 mb-8">
|
||||||
|
{getGradeMessage(percentage)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{onRestart && (
|
||||||
|
<button
|
||||||
|
onClick={onRestart}
|
||||||
|
className="w-full py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
再練習一次
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onBackToDashboard?.();
|
||||||
|
router.push('/dashboard');
|
||||||
|
}}
|
||||||
|
className="w-full py-3 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
回到首頁
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/flashcards')}
|
||||||
|
className="w-full py-3 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
管理詞卡
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tips */}
|
||||||
|
<div className="mt-6 text-xs text-gray-500">
|
||||||
|
💡 提示:定期複習可以提高記憶效果
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,366 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
|
import { Mic, Square, Play, Upload } from 'lucide-react';
|
||||||
|
|
||||||
|
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;
|
||||||
|
onScoreReceived?: (score: PronunciationScore) => void;
|
||||||
|
onRecordingComplete?: (audioBlob: Blob) => void;
|
||||||
|
maxDuration?: number;
|
||||||
|
userLevel?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VoiceRecorder({
|
||||||
|
targetText,
|
||||||
|
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<Blob | null>(null);
|
||||||
|
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||||
|
const [score, setScore] = useState<PronunciationScore | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||||
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
|
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(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 (
|
||||||
|
<div className={`voice-recorder p-6 border-2 border-dashed border-gray-300 rounded-xl ${className}`}>
|
||||||
|
{/* 隱藏的音頻元素 */}
|
||||||
|
<audio ref={audioRef} />
|
||||||
|
|
||||||
|
{/* 目標文字顯示 */}
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-2">請朗讀以下內容:</h3>
|
||||||
|
<p className="text-2xl font-medium text-gray-800 p-4 bg-blue-50 rounded-lg">
|
||||||
|
{targetText}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 錄音控制區 */}
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
{/* 錄音按鈕 */}
|
||||||
|
<button
|
||||||
|
onClick={isRecording ? stopRecording : startRecording}
|
||||||
|
disabled={isProcessing}
|
||||||
|
className={`
|
||||||
|
w-20 h-20 rounded-full flex items-center justify-center transition-all
|
||||||
|
${isRecording
|
||||||
|
? 'bg-red-500 hover:bg-red-600 animate-pulse'
|
||||||
|
: 'bg-blue-500 hover:bg-blue-600'
|
||||||
|
}
|
||||||
|
${isProcessing ? 'opacity-50 cursor-not-allowed' : ''}
|
||||||
|
text-white shadow-lg
|
||||||
|
`}
|
||||||
|
title={isRecording ? 'Stop Recording' : 'Start Recording'}
|
||||||
|
>
|
||||||
|
{isRecording ? <Square size={32} /> : <Mic size={32} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 錄音狀態 */}
|
||||||
|
{isRecording && (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-red-600 font-semibold">
|
||||||
|
🔴 錄音中...
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
{formatTime(recordingTime)} / {formatTime(maxDuration)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 播放和評估按鈕 */}
|
||||||
|
{audioBlob && !isRecording && (
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={playRecording}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Play size={16} />
|
||||||
|
播放錄音
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={evaluatePronunciation}
|
||||||
|
disabled={isProcessing}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Upload size={16} />
|
||||||
|
{isProcessing ? '評估中...' : '評估發音'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 處理狀態 */}
|
||||||
|
{isProcessing && (
|
||||||
|
<div className="flex items-center gap-2 text-blue-600">
|
||||||
|
<div className="animate-spin w-4 h-4 border-2 border-blue-600 border-t-transparent rounded-full" />
|
||||||
|
正在評估您的發音...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 錯誤顯示 */}
|
||||||
|
{error && (
|
||||||
|
<div className="text-red-600 bg-red-50 p-3 rounded-lg text-center max-w-md">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 評分結果 */}
|
||||||
|
{score && (
|
||||||
|
<div className="score-display w-full max-w-md mx-auto mt-4 p-4 bg-white border rounded-lg shadow">
|
||||||
|
{/* 總分 */}
|
||||||
|
<div className="text-center mb-4">
|
||||||
|
<div className={`text-4xl font-bold ${getScoreColor(score.overall)}`}>
|
||||||
|
{score.overall}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600">總體評分</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 詳細評分 */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 mb-4 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>準確度:</span>
|
||||||
|
<span className={getScoreColor(score.accuracy)}>{score.accuracy.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>流暢度:</span>
|
||||||
|
<span className={getScoreColor(score.fluency)}>{score.fluency.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>完整度:</span>
|
||||||
|
<span className={getScoreColor(score.completeness)}>{score.completeness.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>音調:</span>
|
||||||
|
<span className={getScoreColor(score.prosody)}>{score.prosody.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 改進建議 */}
|
||||||
|
{score.suggestions.length > 0 && (
|
||||||
|
<div className="suggestions">
|
||||||
|
<h4 className="font-semibold mb-2 text-gray-800">💡 改進建議:</h4>
|
||||||
|
<ul className="text-sm text-gray-700 space-y-1">
|
||||||
|
{score.suggestions.map((suggestion, index) => (
|
||||||
|
<li key={index} className="flex items-start gap-2">
|
||||||
|
<span className="text-blue-500">•</span>
|
||||||
|
{suggestion}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,227 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef, useCallback } from 'react';
|
||||||
|
|
||||||
|
export interface TTSRequest {
|
||||||
|
text: string;
|
||||||
|
accent?: 'us' | 'uk';
|
||||||
|
speed?: number;
|
||||||
|
voice?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TTSResponse {
|
||||||
|
audioUrl: string;
|
||||||
|
duration: number;
|
||||||
|
cacheHit: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AudioState {
|
||||||
|
isPlaying: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
currentAudio: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAudio() {
|
||||||
|
const [state, setState] = useState<AudioState>({
|
||||||
|
isPlaying: false,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
currentAudio: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
const currentRequestRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
// 更新狀態的輔助函數
|
||||||
|
const updateState = useCallback((updates: Partial<AudioState>) => {
|
||||||
|
setState(prev => ({ ...prev, ...updates }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 生成音頻
|
||||||
|
const generateAudio = useCallback(async (request: TTSRequest): Promise<string | null> => {
|
||||||
|
try {
|
||||||
|
// 取消之前的請求
|
||||||
|
if (currentRequestRef.current) {
|
||||||
|
currentRequestRef.current.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
currentRequestRef.current = controller;
|
||||||
|
|
||||||
|
updateState({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('Authentication required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('/api/audio/tts', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
text: request.text,
|
||||||
|
accent: request.accent || 'us',
|
||||||
|
speed: request.speed || 1.0,
|
||||||
|
voice: request.voice || ''
|
||||||
|
}),
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: TTSResponse = await response.json();
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
throw new Error(data.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateState({ currentAudio: data.audioUrl });
|
||||||
|
return data.audioUrl;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
return null; // 請求被取消
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Failed to generate audio';
|
||||||
|
updateState({ error: errorMessage });
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
updateState({ isLoading: false });
|
||||||
|
currentRequestRef.current = null;
|
||||||
|
}
|
||||||
|
}, [updateState]);
|
||||||
|
|
||||||
|
// 播放音頻
|
||||||
|
const playAudio = useCallback(async (audioUrl?: string, request?: TTSRequest) => {
|
||||||
|
try {
|
||||||
|
let urlToPlay = audioUrl;
|
||||||
|
|
||||||
|
// 如果沒有提供 URL,嘗試生成
|
||||||
|
if (!urlToPlay && request) {
|
||||||
|
urlToPlay = await generateAudio(request);
|
||||||
|
if (!urlToPlay) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!urlToPlay) {
|
||||||
|
updateState({ error: 'No audio URL provided' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 創建新的音頻元素或使用現有的
|
||||||
|
let audio = audioRef.current;
|
||||||
|
if (!audio) {
|
||||||
|
audio = new Audio();
|
||||||
|
audioRef.current = audio;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 設置音頻事件監聽器
|
||||||
|
const handleEnded = () => {
|
||||||
|
updateState({ isPlaying: false });
|
||||||
|
audio?.removeEventListener('ended', handleEnded);
|
||||||
|
audio?.removeEventListener('error', handleError);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleError = () => {
|
||||||
|
updateState({ isPlaying: false, error: 'Audio playback failed' });
|
||||||
|
audio?.removeEventListener('ended', handleEnded);
|
||||||
|
audio?.removeEventListener('error', handleError);
|
||||||
|
};
|
||||||
|
|
||||||
|
audio.addEventListener('ended', handleEnded);
|
||||||
|
audio.addEventListener('error', handleError);
|
||||||
|
|
||||||
|
// 設置音頻源並播放
|
||||||
|
audio.src = urlToPlay;
|
||||||
|
await audio.play();
|
||||||
|
|
||||||
|
updateState({ isPlaying: true, error: null });
|
||||||
|
return true;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Failed to play audio';
|
||||||
|
updateState({ error: errorMessage, isPlaying: false });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [generateAudio, updateState]);
|
||||||
|
|
||||||
|
// 暫停音頻
|
||||||
|
const pauseAudio = useCallback(() => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (audio) {
|
||||||
|
audio.pause();
|
||||||
|
updateState({ isPlaying: false });
|
||||||
|
}
|
||||||
|
}, [updateState]);
|
||||||
|
|
||||||
|
// 停止音頻
|
||||||
|
const stopAudio = useCallback(() => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (audio) {
|
||||||
|
audio.pause();
|
||||||
|
audio.currentTime = 0;
|
||||||
|
updateState({ isPlaying: false });
|
||||||
|
}
|
||||||
|
}, [updateState]);
|
||||||
|
|
||||||
|
// 切換播放/暫停
|
||||||
|
const togglePlayPause = useCallback(async (audioUrl?: string, request?: TTSRequest) => {
|
||||||
|
if (state.isPlaying) {
|
||||||
|
pauseAudio();
|
||||||
|
} else {
|
||||||
|
await playAudio(audioUrl, request);
|
||||||
|
}
|
||||||
|
}, [state.isPlaying, playAudio, pauseAudio]);
|
||||||
|
|
||||||
|
// 設置音量
|
||||||
|
const setVolume = useCallback((volume: number) => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (audio) {
|
||||||
|
audio.volume = Math.max(0, Math.min(1, volume));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 設置播放速度
|
||||||
|
const setPlaybackRate = useCallback((rate: number) => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (audio) {
|
||||||
|
audio.playbackRate = Math.max(0.25, Math.min(4, rate));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 清除錯誤
|
||||||
|
const clearError = useCallback(() => {
|
||||||
|
updateState({ error: null });
|
||||||
|
}, [updateState]);
|
||||||
|
|
||||||
|
// 清理函數
|
||||||
|
const cleanup = useCallback(() => {
|
||||||
|
if (currentRequestRef.current) {
|
||||||
|
currentRequestRef.current.abort();
|
||||||
|
}
|
||||||
|
stopAudio();
|
||||||
|
}, [stopAudio]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 狀態
|
||||||
|
...state,
|
||||||
|
|
||||||
|
// 操作方法
|
||||||
|
generateAudio,
|
||||||
|
playAudio,
|
||||||
|
pauseAudio,
|
||||||
|
stopAudio,
|
||||||
|
togglePlayPause,
|
||||||
|
setVolume,
|
||||||
|
setPlaybackRate,
|
||||||
|
clearError,
|
||||||
|
cleanup
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
"@types/react": "^19.1.13",
|
"@types/react": "^19.1.13",
|
||||||
"@types/react-dom": "^19.1.9",
|
"@types/react-dom": "^19.1.9",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
|
"lucide-react": "^0.544.0",
|
||||||
"next": "^15.5.3",
|
"next": "^15.5.3",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
|
|
@ -1922,6 +1923,15 @@
|
||||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide-react": {
|
||||||
|
"version": "0.544.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.544.0.tgz",
|
||||||
|
"integrity": "sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.19",
|
"version": "0.30.19",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@
|
||||||
"@types/react": "^19.1.13",
|
"@types/react": "^19.1.13",
|
||||||
"@types/react-dom": "^19.1.9",
|
"@types/react-dom": "^19.1.9",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
|
"lucide-react": "^0.544.0",
|
||||||
"next": "^15.5.3",
|
"next": "^15.5.3",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue