Files
breakpilot-lehrer/studio-v2/app/learn/[unitId]/match/page.tsx
Benjamin Admin a1664ab12c Redesign Match exercise: 3 columns, audio, scoring, native language
Major improvements:
1. Third column shows native language translation (TR/AR/UK/RU)
2. Clicking EN word flashes native translation briefly (2s overlay)
3. German column has audio button on each word (speaker icon)
4. Native column has audio button for each translation
5. Scoring: tracks first-try correct vs retry vs errors separately
6. Full points only for error-free completion
7. "Nochmal" button always available to repeat the unit
8. Header shows live score: green/yellow/red counters
9. All buttons use translation system (t('back'), t('match'), etc.)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 18:03:35 +02:00

278 lines
12 KiB
TypeScript

'use client'
import React, { useState, useEffect, useCallback, useMemo } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { useTheme } from '@/lib/ThemeContext'
import { useNativeLanguage } from '@/lib/useNativeLanguage'
import { StarRating, accuracyToStars } from '@/components/gamification/StarRating'
import { AudioButton } from '@/components/learn/AudioButton'
interface QAItem {
id: string; question: string; answer: string
translations?: Record<string, any>
}
function getApiBase() { return '' }
export default function MatchPage() {
const { unitId } = useParams<{ unitId: string }>()
const router = useRouter()
const { isDark } = useTheme()
const { t, wordInNative, nativeLang, isThirdLanguage } = useNativeLanguage()
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 [firstTryCorrect, setFirstTryCorrect] = useState(0)
const [retryCorrect, setRetryCorrect] = useState(0)
const [errors, setErrors] = useState(0)
const [failedIds, setFailedIds] = useState<Set<string>>(new Set())
const [flashNative, setFlashNative] = useState<string | null>(null)
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])
const roundItems = useMemo(() => {
const start = round * 6
return allItems.slice(start, start + 6)
}, [allItems, round])
const shuffledRight = useMemo(() => {
return [...roundItems].sort(() => Math.random() - 0.5)
}, [roundItems])
const handleLeftTap = useCallback((item: QAItem) => {
if (matched.has(item.id)) return
setSelectedLeft(item.id === selectedLeft ? null : item.id)
setWrongPair(null)
// Flash native translation briefly
if (isThirdLanguage && item.id !== selectedLeft) {
const native = wordInNative(item.translations)
if (native) {
setFlashNative(native)
setTimeout(() => setFlashNative(null), 2000)
}
}
}, [selectedLeft, matched, isThirdLanguage, wordInNative])
const handleRightTap = useCallback((id: string) => {
if (!selectedLeft || matched.has(id)) return
if (selectedLeft === id) {
// Correct
setMatched(prev => new Set([...prev, id]))
if (failedIds.has(id)) {
setRetryCorrect(c => c + 1)
} else {
setFirstTryCorrect(c => c + 1)
}
setSelectedLeft(null)
setFlashNative(null)
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
setWrongPair(id)
setErrors(e => e + 1)
setFailedIds(prev => new Set([...prev, selectedLeft]))
setTimeout(() => {
setWrongPair(null)
setSelectedLeft(null)
setFlashNative(null)
}, 600)
}
}, [selectedLeft, matched, roundItems, round, allItems, failedIds])
const restart = () => {
setRound(0)
setMatched(new Set())
setFirstTryCorrect(0)
setRetryCorrect(0)
setErrors(0)
setFailedIds(new Set())
setIsComplete(false)
setSelectedLeft(null)
}
if (isLoading) {
return <div className="flex items-center justify-center py-20">
<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
const isPerfect = isComplete && errors === 0
return (
<>
{/* Header */}
<div className={`${glassCard} border-0 border-b`}>
<div className="max-w-3xl 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>
{t('back')}
</button>
<h1 className={`text-lg font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>{t('match')}</h1>
<div className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
{matchedTotal}/{totalPairs} · <span className="text-green-400">{firstTryCorrect}</span>/<span className="text-yellow-400">{retryCorrect}</span>/<span className="text-red-400">{errors}</span>
</div>
</div>
</div>
{/* Native flash overlay */}
{flashNative && (
<div className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-50 pointer-events-none">
<div className={`px-8 py-4 rounded-2xl text-2xl font-bold animate-pulse ${isDark ? 'bg-cyan-500/30 text-cyan-200 backdrop-blur-xl' : 'bg-cyan-100 text-cyan-800'}`}>
{flashNative}
</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={isPerfect ? 3 : accuracyToStars(firstTryCorrect, totalPairs)} size="lg" animated />
<h2 className={`text-2xl font-bold mt-4 mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
{isPerfect ? t('well_done') : t('all_matched')}
</h2>
<div className={`flex justify-center gap-6 mb-4 text-sm ${isDark ? 'text-white/70' : 'text-slate-600'}`}>
<div><span className="text-green-400 font-bold text-lg">{firstTryCorrect}</span><br/>{t('correct')}</div>
<div><span className="text-yellow-400 font-bold text-lg">{retryCorrect}</span><br/>2. Versuch</div>
<div><span className="text-red-400 font-bold text-lg">{errors}</span><br/>{t('errors')}</div>
</div>
{!isPerfect && (
<p className={`text-xs mb-4 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
{isThirdLanguage
? (nativeLang === 'tr' ? 'Tam puan icin hatasiz tamamlayin' : 'Fuer volle Punkte fehlerfrei abschliessen')
: 'Fuer volle Punkte fehlerfrei abschliessen'}
</p>
)}
<div className="flex gap-3">
<button onClick={restart} className="flex-1 py-3 rounded-xl bg-gradient-to-r from-indigo-500 to-violet-500 text-white font-medium">
{t('again')}
</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'}`}>
{t('back')}
</button>
</div>
</div>
) : (
<div className="w-full max-w-3xl">
{/* Column headers */}
<div className={`grid ${isThirdLanguage ? 'grid-cols-3' : 'grid-cols-2'} gap-4 mb-3`}>
<p className={`text-xs font-medium text-center ${isDark ? 'text-white/40' : 'text-slate-400'}`}>{t('english')}</p>
<p className={`text-xs font-medium text-center ${isDark ? 'text-white/40' : 'text-slate-400'}`}>{t('german')}</p>
{isThirdLanguage && (
<p className={`text-xs font-medium text-center ${isDark ? 'text-cyan-300/50' : 'text-cyan-600'}`}>{nativeLang.toUpperCase()}</p>
)}
</div>
{/* Grid */}
<div className={`grid ${isThirdLanguage ? 'grid-cols-3' : 'grid-cols-2'} gap-4`}>
{/* Left: English (click to select, no sound) */}
<div className="space-y-2">
{roundItems.map(item => (
<button
key={`l-${item.id}`}
onClick={() => handleLeftTap(item)}
disabled={matched.has(item.id)}
className={`w-full p-3 rounded-xl border-2 text-sm font-medium transition-all text-left ${
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>
{/* Middle: German (click to match + sound) */}
<div className="space-y-2">
{shuffledRight.map(item => (
<div
key={`r-${item.id}`}
className={`flex items-center gap-2 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'
: 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' : 'border-slate-200 bg-white text-slate-900')
}`}
>
<button
onClick={() => handleRightTap(item.id)}
disabled={matched.has(item.id) || !selectedLeft}
className="flex-1 text-left hover:opacity-80"
>
{item.answer}
</button>
{!matched.has(item.id) && (
<AudioButton text={item.answer} lang="de" isDark={isDark} size="sm" />
)}
</div>
))}
</div>
{/* Right: Native language (display only + sound) */}
{isThirdLanguage && (
<div className="space-y-2">
{roundItems.map(item => {
const native = wordInNative(item.translations)
return (
<div
key={`n-${item.id}`}
className={`flex items-center gap-2 p-3 rounded-xl border text-sm transition-all ${
matched.has(item.id)
? 'opacity-30 border-green-400/30 bg-green-500/5'
: selectedLeft === item.id
? (isDark ? 'border-cyan-400/50 bg-cyan-500/10 text-cyan-200' : 'border-cyan-500/50 bg-cyan-50 text-cyan-800')
: (isDark ? 'border-white/10 bg-white/3 text-white/50' : 'border-slate-100 bg-slate-50/50 text-slate-400')
}`}
>
<span className="flex-1">{native || '—'}</span>
{native && !matched.has(item.id) && (
<AudioButton text={native} lang={nativeLang as 'en' | 'de'} isDark={isDark} size="sm" />
)}
</div>
)
})}
</div>
)}
</div>
</div>
)}
</div>
</>
)
}