Redesign: 2/3 + 1/3 layout for exercises with native helper panel
ExerciseLayout.tsx: Reusable layout component for all exercises. - Left 2/3: Standard exercise area (EN + DE) - Right 1/3: Native language helper (explanation + word list) - Only shows right panel for non-DE/EN speakers - Explanation card describes what the child should do - Column headers are trilingual (TR · English · Deutsch) Match page rebuilt using ExerciseLayout: - EN+DE cards in 2/3 left area with equal height + audio - Native words in 1/3 right panel with audio buttons - Highlights native word when EN word is selected - Progress bar with count, score counter ExerciseLayout can be reused for flashcards, quiz, type, etc. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import { useTheme } from '@/lib/ThemeContext'
|
||||
import { useNativeLanguage } from '@/lib/useNativeLanguage'
|
||||
import { StarRating, accuracyToStars } from '@/components/gamification/StarRating'
|
||||
import { AudioButton } from '@/components/learn/AudioButton'
|
||||
import { ExerciseLayout } from '@/components/learn/ExerciseLayout'
|
||||
|
||||
interface QAItem {
|
||||
id: string; question: string; answer: string
|
||||
@@ -30,7 +31,6 @@ export default function MatchPage() {
|
||||
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
|
||||
@@ -46,78 +46,38 @@ export default function MatchPage() {
|
||||
})()
|
||||
}, [unitId])
|
||||
|
||||
const roundItems = useMemo(() => {
|
||||
const start = round * 6
|
||||
return allItems.slice(start, start + 6)
|
||||
}, [allItems, round])
|
||||
const roundItems = useMemo(() => allItems.slice(round * 6, round * 6 + 6), [allItems, round])
|
||||
const shuffledRight = useMemo(() => [...roundItems].sort(() => Math.random() - 0.5), [roundItems])
|
||||
|
||||
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)
|
||||
const handleLeftTap = useCallback((id: string) => {
|
||||
if (matched.has(id)) return
|
||||
setSelectedLeft(id === selectedLeft ? null : 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])
|
||||
}, [selectedLeft, matched])
|
||||
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
setTimeout(() => { setWrongPair(null); setSelectedLeft(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)
|
||||
setRound(0); setMatched(new Set()); setFirstTryCorrect(0); setRetryCorrect(0)
|
||||
setErrors(0); setFailedIds(new Set()); setIsComplete(false); setSelectedLeft(null)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
@@ -130,169 +90,107 @@ export default function MatchPage() {
|
||||
const matchedTotal = round * 6 + matched.size
|
||||
const isPerfect = isComplete && errors === 0
|
||||
|
||||
// Native helper panel: list of words in native language
|
||||
const nativePanel = (
|
||||
<div className={`${glassCard} rounded-2xl p-4`}>
|
||||
<p className={`text-xs font-medium mb-3 ${isDark ? 'text-cyan-300/70' : 'text-cyan-600'}`}>
|
||||
{nativeLang.toUpperCase()} · {t('english')} · {t('german')}
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{roundItems.map(item => {
|
||||
const native = wordInNative(item.translations)
|
||||
const isSelected = selectedLeft === item.id
|
||||
const isMatched = matched.has(item.id)
|
||||
return (
|
||||
<div key={`n-${item.id}`}
|
||||
className={`flex items-center gap-2 px-3 py-2.5 rounded-lg border text-sm transition-all ${
|
||||
isMatched
|
||||
? 'opacity-30 border-green-400/30 bg-green-500/5'
|
||||
: isSelected
|
||||
? (isDark ? 'border-cyan-400/50 bg-cyan-500/10 text-cyan-200' : 'border-cyan-400 bg-cyan-50 text-cyan-800')
|
||||
: (isDark ? 'border-white/10 text-white/50' : 'border-slate-200 text-slate-500')
|
||||
}`}>
|
||||
<span className="flex-1 truncate">{native || '—'}</span>
|
||||
{native && !isMatched && (
|
||||
<AudioButton text={native} lang={nativeLang as 'en' | 'de'} isDark={isDark} size="sm" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
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-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
<span className="text-green-400">✓{firstTryCorrect}</span>{' '}
|
||||
<span className="text-yellow-400">↻{retryCorrect}</span>{' '}
|
||||
<span className="text-red-400">✗{errors}</span>
|
||||
<ExerciseLayout
|
||||
title={t('match')}
|
||||
onBack={() => router.push('/learn')}
|
||||
progress={{ current: matchedTotal, total: totalPairs }}
|
||||
exerciseExplanation={undefined}
|
||||
nativeHelper={nativePanel}
|
||||
score={
|
||||
<div className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
<span className="text-green-400">✓{firstTryCorrect}</span>{' '}
|
||||
<span className="text-yellow-400">↻{retryCorrect}</span>{' '}
|
||||
<span className="text-red-400">✗{errors}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{isComplete ? (
|
||||
<div className={`${glassCard} rounded-3xl p-10 text-center max-w-md mx-auto`}>
|
||||
<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.</div>
|
||||
<div><span className="text-red-400 font-bold text-lg">{errors}</span><br/>{t('errors')}</div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* Progress bar with count */}
|
||||
<div className="max-w-3xl mx-auto w-full px-6 pt-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`flex-1 h-2 rounded-full ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}>
|
||||
<div className="h-full rounded-full bg-gradient-to-r from-indigo-500 to-violet-500 transition-all" style={{ width: `${(matchedTotal / Math.max(totalPairs, 1)) * 100}%` }} />
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Left: English */}
|
||||
<div className="space-y-2">
|
||||
<p className={`text-xs font-medium text-center mb-2 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
{t('english')} / English
|
||||
</p>
|
||||
{roundItems.map(item => (
|
||||
<div key={`l-${item.id}`}
|
||||
className={`flex items-center gap-2 p-3 min-h-[48px] rounded-xl border-2 text-sm font-medium transition-all ${
|
||||
matched.has(item.id) ? 'opacity-30 border-green-400 bg-green-500/10'
|
||||
: 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' : 'border-slate-200 bg-white text-slate-900')
|
||||
}`}>
|
||||
<button onClick={() => handleLeftTap(item.id)} disabled={matched.has(item.id)} className="flex-1 text-left">{item.question}</button>
|
||||
{!matched.has(item.id) && <AudioButton text={item.question} lang="en" isDark={isDark} size="sm" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span className={`text-xs font-medium tabular-nums ${isDark ? 'text-white/50' : 'text-slate-400'}`}>
|
||||
{matchedTotal}/{totalPairs}
|
||||
</span>
|
||||
</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}
|
||||
{/* Right: German */}
|
||||
<div className="space-y-2">
|
||||
<p className={`text-xs font-medium text-center mb-2 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
{t('german')} / Deutsch
|
||||
</p>
|
||||
{shuffledRight.map(item => (
|
||||
<div key={`r-${item.id}`}
|
||||
className={`flex items-center gap-2 p-3 min-h-[48px] 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">{item.answer}</button>
|
||||
{!matched.has(item.id) && <AudioButton text={item.answer} lang="de" isDark={isDark} size="sm" />}
|
||||
</div>
|
||||
))}
|
||||
</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 => (
|
||||
<div
|
||||
key={`l-${item.id}`}
|
||||
className={`flex items-center gap-2 p-3 min-h-[48px] rounded-xl border-2 text-sm font-medium transition-all ${
|
||||
matched.has(item.id)
|
||||
? 'opacity-30 border-green-400 bg-green-500/10'
|
||||
: 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' : 'border-slate-200 bg-white text-slate-900')
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => handleLeftTap(item)}
|
||||
disabled={matched.has(item.id)}
|
||||
className="flex-1 text-left hover:opacity-80"
|
||||
>
|
||||
{item.question}
|
||||
</button>
|
||||
{!matched.has(item.id) && (
|
||||
<AudioButton text={item.question} lang="en" isDark={isDark} size="sm" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</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 min-h-[48px] 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 min-h-[48px] 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>
|
||||
</>
|
||||
</ExerciseLayout>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user