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:
Benjamin Admin
2026-04-25 16:08:14 +02:00
parent a317bd6164
commit 8efffe8c52
18 changed files with 577 additions and 53 deletions

View File

@@ -1 +1 @@
export * from '../../../shared/types/klausur'
export * from '@shared/types/klausur'

View File

@@ -0,0 +1,137 @@
'use client'
import React, { useState, useEffect, useCallback, useMemo } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { useTheme } from '@/lib/ThemeContext'
import { AudioButton } from '@/components/learn/AudioButton'
import { StarRating, accuracyToStars } from '@/components/gamification/StarRating'
interface QAItem { id: string; question: string; answer: string }
function getApiBase() { return '' }
export default function ListenPage() {
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 [selected, setSelected] = useState<string | null>(null)
const [revealed, setRevealed] = useState(false)
const [stats, setStats] = useState({ correct: 0, incorrect: 0 })
const [isComplete, setIsComplete] = useState(false)
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])
// Generate 4 options (1 correct + 3 distractors)
const options = useMemo(() => {
if (!items.length || currentIndex >= items.length) return []
const correct = items[currentIndex]
const others = items.filter((_, i) => i !== currentIndex)
const shuffled = [...others].sort(() => Math.random() - 0.5).slice(0, 3)
const opts = [...shuffled.map(i => ({ id: i.id, text: i.answer })), { id: correct.id, text: correct.answer }]
return opts.sort(() => Math.random() - 0.5)
}, [items, currentIndex])
const handleSelect = useCallback((optionId: string) => {
if (revealed) return
setSelected(optionId)
setRevealed(true)
const isCorrect = optionId === items[currentIndex].id
setStats(prev => ({
correct: prev.correct + (isCorrect ? 1 : 0),
incorrect: prev.incorrect + (isCorrect ? 0 : 1),
}))
setTimeout(() => {
if (currentIndex + 1 >= items.length) { setIsComplete(true) }
else { setCurrentIndex(i => i + 1) }
setSelected(null)
setRevealed(false)
}, isCorrect ? 800 : 2000)
}, [revealed, items, currentIndex])
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-green-50 to-emerald-100'}`}>
<div className="w-8 h-8 border-4 border-green-400 border-t-transparent rounded-full animate-spin" />
</div>
}
const currentItem = items[currentIndex]
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-green-50 to-emerald-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'}`}>Hoerverstehen</h1>
<span className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{currentIndex + 1}/{items.length}</span>
</div>
</div>
{/* Progress */}
<div className="w-full h-1 bg-white/10">
<div className="h-full bg-gradient-to-r from-green-500 to-emerald-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'}`}>Geschafft!</h2>
<p className={`text-lg mb-6 ${isDark ? 'text-white/70' : 'text-slate-600'}`}>{stats.correct}/{stats.correct + stats.incorrect} richtig</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-green-500 to-emerald-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-6">
{/* Audio prompt */}
<div className={`${glassCard} rounded-3xl p-8 text-center`}>
<p className={`text-sm mb-4 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>Hoere das Wort und waehle die richtige Uebersetzung</p>
<AudioButton text={currentItem.question} lang="en" isDark={isDark} size="lg" />
<p className={`text-sm mt-4 ${isDark ? 'text-white/30' : 'text-slate-400'}`}>Tippe auf den Lautsprecher</p>
</div>
{/* Options */}
<div className="grid grid-cols-2 gap-3">
{options.map(opt => {
const isCorrectAnswer = opt.id === currentItem.id
const isSelected = selected === opt.id
let style = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white border-slate-200 text-slate-900'
if (revealed && isCorrectAnswer) style = isDark ? 'bg-green-500/30 border-green-400 text-green-200' : 'bg-green-50 border-green-500 text-green-800'
if (revealed && isSelected && !isCorrectAnswer) style = isDark ? 'bg-red-500/30 border-red-400 text-red-200' : 'bg-red-50 border-red-500 text-red-800'
return (
<button key={opt.id} onClick={() => handleSelect(opt.id)} disabled={revealed}
className={`p-4 rounded-xl border-2 text-center font-medium transition-all ${style}`}>
{opt.text}
</button>
)
})}
</div>
</div>
) : null}
</div>
</div>
)
}

View File

@@ -0,0 +1,170 @@
'use client'
import React, { useState, useEffect, useCallback, useMemo } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { useTheme } from '@/lib/ThemeContext'
import { StarRating, accuracyToStars } from '@/components/gamification/StarRating'
interface QAItem { id: string; question: string; answer: string }
function getApiBase() { return '' }
export default function MatchPage() {
const { unitId } = useParams<{ unitId: string }>()
const router = useRouter()
const { isDark } = useTheme()
const [allItems, setAllItems] = useState<QAItem[]>([])
const [isLoading, setIsLoading] = useState(true)
const [round, setRound] = useState(0)
const [selectedLeft, setSelectedLeft] = useState<string | null>(null)
const [matched, setMatched] = useState<Set<string>>(new Set())
const [wrongPair, setWrongPair] = useState<string | null>(null)
const [errors, setErrors] = useState(0)
const [isComplete, setIsComplete] = useState(false)
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(); setAllItems(d.qa_items || []) }
} catch {} finally { setIsLoading(false) }
})()
}, [unitId])
// Take 6 items per round
const roundItems = useMemo(() => {
const start = round * 6
return allItems.slice(start, start + 6)
}, [allItems, round])
// Shuffled right column
const shuffledRight = useMemo(() => {
return [...roundItems].sort(() => Math.random() - 0.5)
}, [roundItems])
const handleLeftTap = useCallback((id: string) => {
if (matched.has(id)) return
setSelectedLeft(id === selectedLeft ? null : id)
setWrongPair(null)
}, [selectedLeft, matched])
const handleRightTap = useCallback((id: string) => {
if (!selectedLeft || matched.has(id)) return
if (selectedLeft === id) {
// Correct match
setMatched(prev => new Set([...prev, id]))
setSelectedLeft(null)
// Check if round complete
if (matched.size + 1 >= roundItems.length) {
const nextStart = (round + 1) * 6
if (nextStart >= allItems.length) {
setTimeout(() => setIsComplete(true), 500)
} else {
setTimeout(() => {
setRound(r => r + 1)
setMatched(new Set())
setSelectedLeft(null)
}, 800)
}
}
} else {
// Wrong match
setWrongPair(id)
setErrors(e => e + 1)
setTimeout(() => {
setWrongPair(null)
setSelectedLeft(null)
}, 600)
}
}, [selectedLeft, matched, roundItems, round, allItems])
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-indigo-50 to-violet-100'}`}>
<div className="w-8 h-8 border-4 border-indigo-400 border-t-transparent rounded-full animate-spin" />
</div>
}
const totalPairs = allItems.length
const matchedTotal = round * 6 + matched.size
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-indigo-50 to-violet-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'}`}>Zuordnen</h1>
<span className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{matchedTotal}/{totalPairs}</span>
</div>
</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(totalPairs, totalPairs + errors)} size="lg" animated />
<h2 className={`text-2xl font-bold mt-4 mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>Alle zugeordnet!</h2>
<p className={`mb-6 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>{errors} Fehler</p>
<div className="flex gap-3">
<button onClick={() => { setRound(0); setMatched(new Set()); setErrors(0); setIsComplete(false) }} className="flex-1 py-3 rounded-xl bg-gradient-to-r from-indigo-500 to-violet-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>
) : (
<div className="w-full max-w-2xl grid grid-cols-2 gap-6">
{/* Left column: English */}
<div className="space-y-2">
<p className={`text-xs font-medium text-center mb-2 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>English</p>
{roundItems.map(item => (
<button
key={`l-${item.id}`}
onClick={() => handleLeftTap(item.id)}
disabled={matched.has(item.id)}
className={`w-full p-3 rounded-xl border-2 text-sm font-medium transition-all ${
matched.has(item.id)
? 'opacity-30 border-green-400 bg-green-500/10 cursor-default'
: selectedLeft === item.id
? (isDark ? 'border-blue-400 bg-blue-500/20 text-white' : 'border-blue-500 bg-blue-50 text-blue-900')
: (isDark ? 'border-white/20 bg-white/5 text-white hover:bg-white/10' : 'border-slate-200 bg-white text-slate-900 hover:bg-slate-50')
}`}
>
{item.question}
</button>
))}
</div>
{/* Right column: German (shuffled) */}
<div className="space-y-2">
<p className={`text-xs font-medium text-center mb-2 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>Deutsch</p>
{shuffledRight.map(item => (
<button
key={`r-${item.id}`}
onClick={() => handleRightTap(item.id)}
disabled={matched.has(item.id) || !selectedLeft}
className={`w-full p-3 rounded-xl border-2 text-sm font-medium transition-all ${
matched.has(item.id)
? 'opacity-30 border-green-400 bg-green-500/10 cursor-default'
: wrongPair === item.id
? (isDark ? 'border-red-400 bg-red-500/20 text-red-200 animate-pulse' : 'border-red-500 bg-red-50 text-red-800 animate-pulse')
: (isDark ? 'border-white/20 bg-white/5 text-white hover:bg-white/10' : 'border-slate-200 bg-white text-slate-900 hover:bg-slate-50')
}`}
>
{item.answer}
</button>
))}
</div>
</div>
)}
</div>
</div>
)
}

View 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>
)
}