Fix: Use @shared/* alias instead of relative paths for Docker compat
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
133
studio-v2/app/learn/[unitId]/pronounce/page.tsx
Normal file
133
studio-v2/app/learn/[unitId]/pronounce/page.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { AudioButton } from '@/components/learn/AudioButton'
|
||||
import { MicrophoneInput } from '@/components/learn/MicrophoneInput'
|
||||
import { SyllableBow, simpleSyllableSplit } from '@/components/learn/SyllableBow'
|
||||
import { StarRating, accuracyToStars } from '@/components/gamification/StarRating'
|
||||
|
||||
interface QAItem {
|
||||
id: string; question: string; answer: string
|
||||
syllables_en?: string[]; syllables_de?: string[]
|
||||
ipa_en?: string; ipa_de?: string
|
||||
}
|
||||
|
||||
function getApiBase() { return '' }
|
||||
|
||||
export default function PronouncePage() {
|
||||
const { unitId } = useParams<{ unitId: string }>()
|
||||
const router = useRouter()
|
||||
const { isDark } = useTheme()
|
||||
|
||||
const [items, setItems] = useState<QAItem[]>([])
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [stats, setStats] = useState({ correct: 0, incorrect: 0 })
|
||||
const [isComplete, setIsComplete] = useState(false)
|
||||
const [lang, setLang] = useState<'en' | 'de'>('en')
|
||||
|
||||
const glassCard = isDark
|
||||
? 'bg-white/10 backdrop-blur-xl border border-white/10'
|
||||
: 'bg-white/80 backdrop-blur-xl border border-black/5'
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const resp = await fetch(`${getApiBase()}/api/learning-units/${unitId}/qa`)
|
||||
if (resp.ok) { const d = await resp.json(); setItems(d.qa_items || []) }
|
||||
} catch {} finally { setIsLoading(false) }
|
||||
})()
|
||||
}, [unitId])
|
||||
|
||||
const currentItem = items[currentIndex]
|
||||
const currentWord = currentItem ? (lang === 'en' ? currentItem.question : currentItem.answer) : ''
|
||||
const syllables = currentItem
|
||||
? (lang === 'en' ? currentItem.syllables_en : currentItem.syllables_de) || simpleSyllableSplit(currentWord)
|
||||
: []
|
||||
const ipa = currentItem ? (lang === 'en' ? currentItem.ipa_en : currentItem.ipa_de) : ''
|
||||
|
||||
const handleResult = useCallback((transcript: string, correct: boolean) => {
|
||||
setStats(prev => ({
|
||||
correct: prev.correct + (correct ? 1 : 0),
|
||||
incorrect: prev.incorrect + (correct ? 0 : 1),
|
||||
}))
|
||||
|
||||
setTimeout(() => {
|
||||
if (currentIndex + 1 >= items.length) { setIsComplete(true) }
|
||||
else { setCurrentIndex(i => i + 1) }
|
||||
}, correct ? 1000 : 2500)
|
||||
}, [currentIndex, items.length])
|
||||
|
||||
if (isLoading) {
|
||||
return <div className={`min-h-screen flex items-center justify-center ${isDark ? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800' : 'bg-gradient-to-br from-slate-100 via-rose-50 to-red-100'}`}>
|
||||
<div className="w-8 h-8 border-4 border-rose-400 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen flex flex-col ${isDark ? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800' : 'bg-gradient-to-br from-slate-100 via-rose-50 to-red-100'}`}>
|
||||
{/* Header */}
|
||||
<div className={`${glassCard} border-0 border-b`}>
|
||||
<div className="max-w-2xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<button onClick={() => router.push('/learn')} className={`flex items-center gap-2 text-sm ${isDark ? 'text-white/60 hover:text-white' : 'text-slate-500 hover:text-slate-900'}`}>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
|
||||
Zurueck
|
||||
</button>
|
||||
<h1 className={`text-lg font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>Nachsprechen</h1>
|
||||
<button onClick={() => setLang(l => l === 'en' ? 'de' : 'en')} className={`text-xs px-3 py-1.5 rounded-lg ${isDark ? 'bg-white/10 text-white/70' : 'bg-slate-100 text-slate-600'}`}>
|
||||
{lang === 'en' ? 'EN' : 'DE'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="w-full h-1 bg-white/10">
|
||||
<div className="h-full bg-gradient-to-r from-rose-500 to-red-500 transition-all" style={{ width: `${(currentIndex / Math.max(items.length, 1)) * 100}%` }} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex items-center justify-center px-6 py-8">
|
||||
{isComplete ? (
|
||||
<div className={`${glassCard} rounded-3xl p-10 text-center max-w-md w-full`}>
|
||||
<StarRating stars={accuracyToStars(stats.correct, stats.correct + stats.incorrect)} size="lg" animated />
|
||||
<h2 className={`text-2xl font-bold mt-4 mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>Super gemacht!</h2>
|
||||
<p className={`mb-6 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>{stats.correct}/{stats.correct + stats.incorrect} richtig ausgesprochen</p>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => { setCurrentIndex(0); setStats({ correct: 0, incorrect: 0 }); setIsComplete(false) }} className="flex-1 py-3 rounded-xl bg-gradient-to-r from-rose-500 to-red-500 text-white font-medium">Nochmal</button>
|
||||
<button onClick={() => router.push('/learn')} className={`flex-1 py-3 rounded-xl border font-medium ${isDark ? 'border-white/20 text-white/80' : 'border-slate-300 text-slate-700'}`}>Zurueck</button>
|
||||
</div>
|
||||
</div>
|
||||
) : currentItem ? (
|
||||
<div className="w-full max-w-lg space-y-8">
|
||||
{/* Word + Syllables */}
|
||||
<div className={`${glassCard} rounded-3xl p-8 text-center`}>
|
||||
<div className="flex justify-center mb-4">
|
||||
<SyllableBow word={currentWord} syllables={syllables} isDark={isDark} size="lg" />
|
||||
</div>
|
||||
{ipa && <p className={`text-lg ${isDark ? 'text-white/40' : 'text-slate-400'}`}>{ipa}</p>}
|
||||
<div className="flex justify-center gap-3 mt-4">
|
||||
<AudioButton text={currentWord} lang={lang} isDark={isDark} size="lg" />
|
||||
</div>
|
||||
<p className={`text-sm mt-3 ${isDark ? 'text-white/30' : 'text-slate-400'}`}>
|
||||
Hoere zu, dann sprich nach
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Microphone */}
|
||||
<MicrophoneInput
|
||||
expectedText={currentWord}
|
||||
lang={lang}
|
||||
onResult={handleResult}
|
||||
isDark={isDark}
|
||||
/>
|
||||
|
||||
<p className={`text-center text-sm ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
{currentIndex + 1} / {items.length}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user