Files
breakpilot-lehrer/studio-v2/app/learn/[unitId]/match/page.tsx
Benjamin Admin 1a272371f4 Add image preview under selected word in Match exercise
When user clicks an EN word, the corresponding image (Wikipedia
photo or emoji) appears below the match grid. Emoji shown as
large text (6xl), Wikipedia photos as max-h 160px image.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 07:52:16 +02:00

220 lines
10 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'
import { ExerciseLayout } from '@/components/learn/ExerciseLayout'
interface QAItem {
id: string; question: string; answer: string
translations?: Record<string, any>
image_url?: string
}
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 [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(() => allItems.slice(round * 6, round * 6 + 6), [allItems, round])
const shuffledRight = useMemo(() => [...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) {
setMatched(prev => new Set([...prev, id]))
if (failedIds.has(id)) setRetryCorrect(c => c + 1)
else setFirstTryCorrect(c => c + 1)
setSelectedLeft(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 {
setWrongPair(id)
setErrors(e => e + 1)
setFailedIds(prev => new Set([...prev, selectedLeft]))
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)
}
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
// 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 (
<ExerciseLayout
title={t('match')}
exerciseType="match"
onBack={() => router.push('/learn')}
progress={{ current: matchedTotal, total: totalPairs }}
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 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>
{/* 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>
{/* Image preview for selected word */}
{selectedLeft && (() => {
const item = roundItems.find(i => i.id === selectedLeft)
if (!item?.image_url) return null
const isEmoji = item.image_url.length <= 4
return (
<div className={`mt-4 rounded-2xl overflow-hidden flex items-center justify-center ${
isDark ? 'bg-white/5' : 'bg-slate-50'
}`} style={{ minHeight: isEmoji ? 80 : 120 }}>
{isEmoji ? (
<span className="text-6xl">{item.image_url}</span>
) : (
<img
src={item.image_url}
alt={item.question}
className="max-h-[160px] object-contain rounded-xl"
/>
)}
</div>
)
})()}
)}
</ExerciseLayout>
)
}