const { useState, useEffect, useRef, useCallback } = React; const api = { async get(url) { const r = await fetch(url); if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || r.statusText); return r.json(); }, async post(url, data) { const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || r.statusText); return r.json(); }, async put(url, data) { const r = await fetch(url, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || r.statusText); return r.json(); }, }; function wsUrl() { return `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}`; } function shuffleArray(arr) { const a = [...arr]; for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1));[a[i], a[j]] = [a[j], a[i]]; } return a; } function fieldVal(field, fallback) { if (!field) return fallback || ''; if (typeof field === 'string') return field; if (typeof field === 'object' && field.value !== undefined) return field.value; return String(field); } function normalizeText(t) { return (t || '').toLowerCase() .replace(/[äÄ]/g, 'ae').replace(/[öÖ]/g, 'oe').replace(/[üÜ]/g, 'ue').replace(/ß/g, 'ss') .replace(/[^a-z0-9]/g, ''); } function Logo({ size }) { const cls = size === 'lg' ? 'w-24 h-24' : size === 'md' ? 'w-16 h-16' : 'w-10 h-10'; return Quizalarm; } /* ================================================================== */ /* Shared Components */ /* ================================================================== */ function ImageModal({ src, onClose }) { if (!src) return null; return (
); } function Timer({ timeLeft, total }) { if (!total) return null; const pct = Math.max(0, (timeLeft / total) * 100); const color = pct > 50 ? 'bg-green-500' : pct > 25 ? 'bg-yellow-500' : 'bg-red-500'; return (
{Math.ceil(timeLeft)}s
); } function Leaderboard({ entries, highlight, compact }) { if (!entries || !entries.length) return null; const show = compact ? entries.slice(0, 5) : entries; return (

🏆 Leaderboard

{show.map((e, i) => (
{i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${e.rank}.`} {e.name} {e.score}
))}
); } const ANSWER_COLORS = { A: 'answer-a', B: 'answer-b', C: 'answer-c', D: 'answer-d' }; function AnswerGrid({ question, selected, onToggle, disabled, feedback }) { const typ = (question.typ || 'MC').toLowerCase(); const isWF = typ === 'wahr/falsch'; if (typ === 'freitext') { if (feedback) { return (
{feedback.isCorrect ? '✓ ' : '✗ '}{selected[0] || '(keine Antwort)'}
{!feedback.isCorrect && feedback.correctText && (
✓ {feedback.correctText}
)}
); } return ( onToggle([e.target.value])} disabled={disabled} autoFocus /> ); } return (
{question.antworten.map((a) => { const isSelected = selected.includes(a.key); let cls, icon = ''; if (feedback) { const isCorrectAnswer = feedback.correctKeys.includes(a.key); if (isCorrectAnswer) { cls = 'bg-green-600 text-white ring-4 ring-green-400'; icon = '✓ '; } else if (isSelected) { cls = 'bg-red-700 text-white ring-4 ring-red-400'; icon = '✗ '; } else { cls = 'bg-gray-700 text-gray-500 opacity-40'; } cls += ' font-bold py-4 px-6 rounded-xl text-lg text-center cursor-default'; } else { cls = `${ANSWER_COLORS[a.key]} ${isSelected ? 'selected' : ''} ${disabled ? 'disabled' : ''} text-white font-bold py-4 px-6 rounded-xl text-lg cursor-pointer transition-all text-center`; } return ( ); })}
); } function BackButton({ onClick, label }) { return ( ); } /* ================================================================== */ /* Home Page */ /* ================================================================== */ function HomePage({ navigate }) { return (

Quizalarm

Lernen. Quizzen. Wissen.

Admin-Bereich →
); } /* ================================================================== */ /* Join Page */ /* ================================================================== */ function JoinPage({ navigate }) { const [code, setCode] = useState(''); const [name, setName] = useState(''); const [error, setError] = useState(''); const handleJoin = () => { if (!code.trim() || !name.trim()) return setError('Code und Name erforderlich'); navigate('live-play', { code: code.trim().toUpperCase(), name: name.trim() }); }; return (

🎮 Live-Quiz beitreten

setCode(e.target.value)} maxLength={20} /> setName(e.target.value)} maxLength={20} onKeyDown={(e) => e.key === 'Enter' && handleJoin()} /> {error &&

{error}

}
); } /* ================================================================== */ /* Live Play Page */ /* ================================================================== */ function LivePlayPage({ params, navigate }) { const [phase, setPhase] = useState('connecting'); const [session, setSession] = useState(null); const [question, setQuestion] = useState(null); const [qIndex, setQIndex] = useState(0); const [qTotal, setQTotal] = useState(0); const [timeLimit, setTimeLimit] = useState(0); const [timeLeft, setTimeLeft] = useState(0); const [selected, setSelected] = useState([]); const [answered, setAnswered] = useState(false); const [result, setResult] = useState(null); const [leaderboardData, setLeaderboardData] = useState([]); const [error, setError] = useState(''); const [enlargedImg, setEnlargedImg] = useState(null); const wsRef = useRef(null); const timerRef = useRef(null); const phaseRef = useRef(phase); phaseRef.current = phase; useEffect(() => { const ws = new WebSocket(wsUrl()); wsRef.current = ws; ws.onopen = () => ws.send(JSON.stringify({ type: 'join', code: params.code, name: params.name })); ws.onmessage = (e) => { const msg = JSON.parse(e.data); switch (msg.type) { case 'joined': setSession(msg.session); setPhase('waiting'); break; case 'player-joined': case 'player-left': setSession((s) => s ? { ...s, players: msg.players, playerCount: msg.playerCount } : s); break; case 'game-start': setQTotal(msg.totalQuestions); setPhase('playing'); break; case 'question': setQuestion(msg.question); setQIndex(msg.index); setQTotal(msg.total); setTimeLimit(msg.timeLimit); setTimeLeft(msg.timeLimit); setSelected([]); setAnswered(false); setResult(null); setPhase('question'); break; case 'answer-received': setAnswered(true); break; case 'time-up': setAnswered(true); clearInterval(timerRef.current); break; case 'question-result': setResult(msg); setLeaderboardData(msg.leaderboard); setPhase('result'); clearInterval(timerRef.current); break; case 'game-end': setLeaderboardData(msg.leaderboard); setPhase('final'); clearInterval(timerRef.current); break; case 'error': setError(msg.message); setPhase('error'); break; } }; ws.onclose = () => { if (phaseRef.current !== 'final' && phaseRef.current !== 'error') setPhase('disconnected'); }; return () => { clearInterval(timerRef.current); ws.close(); }; }, []); useEffect(() => { clearInterval(timerRef.current); if (phase === 'question' && timeLimit > 0 && !answered) { const start = Date.now(); timerRef.current = setInterval(() => { const left = Math.max(0, timeLimit - (Date.now() - start) / 1000); setTimeLeft(left); if (left <= 0) clearInterval(timerRef.current); }, 100); } }, [phase, timeLimit, answered]); const submitAnswer = () => { if (answered || !wsRef.current) return; wsRef.current.send(JSON.stringify({ type: 'answer', answers: selected })); setAnswered(true); }; const leave = () => { if (wsRef.current) wsRef.current.close(); navigate('home'); }; if (phase === 'error') return (

❌ {error}

); if (phase === 'disconnected') return (

Verbindung verloren

Du kannst mit demselben Namen wieder beitreten.

); if (phase === 'connecting') return

Verbinde...

; if (phase === 'waiting') return (

⏳ Warteraum

{session?.setName}

{session?.playerCount || 0} Teilnehmer

{(session?.players || []).map((n) => ( {n} ))}

Warte auf den Start...

); if (phase === 'question') return (
Frage {qIndex + 1} / {qTotal}
{timeLimit > 0 && }

{question?.frage}

{question?.bild && setEnlargedImg(question.bild)} />} {!answered && } {answered &&

✓ Antwort gesendet — warte auf Ergebnis...

} setEnlargedImg(null)} />
); if (phase === 'result') { const liveFeedback = result ? { correctKeys: result.correctAnswer || [], correctText: question?.typ?.toLowerCase() === 'freitext' ? (result.correctAnswer || [])[0] : null, isCorrect: result.yourResult?.correct, } : null; return (
Frage {qIndex + 1} / {qTotal}

{question?.frage}

{question?.bild && } {question && }
{result?.yourResult?.correct ? '✅ Richtig!' : '❌ Falsch'}

+{result?.yourResult?.points || 0} Punkte

Gesamt: {result?.yourResult?.totalScore || 0}

Warte auf nächste Frage...

); } if (phase === 'final') return (

🏁 Quiz beendet!

); return null; } /* ================================================================== */ /* Solo Select Page */ /* ================================================================== */ function SoloSelectPage({ navigate }) { const [sets, setSets] = useState([]); const [name, setName] = useState(''); const [doShuffle, setDoShuffle] = useState(true); const [error, setError] = useState(''); const [checking, setChecking] = useState(false); const [progressDialog, setProgressDialog] = useState(null); useEffect(() => { api.get('/api/sets').then(setSets).catch((e) => setError(e.message)); }, []); const startSet = async (setId, setName2) => { if (!name.trim()) return setError('Bitte Name eingeben'); setChecking(true); setError(''); try { const progress = await api.get(`/api/progress/${encodeURIComponent(name.trim())}/${setId}`); if (progress.hasProgress) { setProgressDialog({ setId, setName: setName2, ...progress }); } else { navigate('solo-quiz', { setId, setName: setName2, name: name.trim(), shuffle: doShuffle }); } } catch (e) { navigate('solo-quiz', { setId, setName: setName2, name: name.trim(), shuffle: doShuffle }); } setChecking(false); }; const continueSession = () => { navigate('solo-quiz', { setId: progressDialog.setId, setName: progressDialog.setName, name: name.trim(), shuffle: doShuffle, continueSessionId: progressDialog.sessionId, priorAnswers: progressDialog.answers, }); setProgressDialog(null); }; const startFresh = () => { navigate('solo-quiz', { setId: progressDialog.setId, setName: progressDialog.setName, name: name.trim(), shuffle: doShuffle, }); setProgressDialog(null); }; return (
navigate('home')} label="Zurück" />

📚 Solo-Lernen

setName(e.target.value)} /> {error &&

{error}

} {progressDialog && (

📌 Gespeicherter Fortschritt

{progressDialog.setName}: {progressDialog.totalAnswered} / {progressDialog.totalQuestions} Fragen beantwortet

{progressDialog.totalCorrect} davon richtig

)} {!progressDialog && ( <>

Fragenset wählen:

{sets.length === 0 &&

Keine Fragensets konfiguriert.

} {sets.map((s) => ( ))} )}
); } /* ================================================================== */ /* Solo Quiz Page */ /* ================================================================== */ function SoloQuizPage({ params, navigate }) { const [allQuestions, setAllQuestions] = useState([]); const [questions, setQuestions] = useState([]); const [index, setIndex] = useState(0); const [selected, setSelected] = useState([]); const [showFeedback, setShowFeedback] = useState(false); const [isCorrect, setIsCorrect] = useState(false); const [correctKeys, setCorrectKeys] = useState([]); const [correctText, setCorrectText] = useState(''); const [results, setResults] = useState([]); const [priorResults] = useState(params.priorAnswers || []); const [error, setError] = useState(''); const [enlargedImg, setEnlargedImg] = useState(null); const [shuffledAnswers, setShuffledAnswers] = useState([]); const [saving, setSaving] = useState(false); const sessionId = useRef(params.continueSessionId || 'SOLO-' + Date.now().toString(36).toUpperCase()); const priorIds = useRef(new Set((params.priorAnswers || []).map((a) => a.Frage_ID))); useEffect(() => { api.get(`/api/sets/${params.setId}/questions`).then((qs) => { setAllQuestions(qs); let remaining = qs.filter((q) => !priorIds.current.has(q.id)); if (params.shuffle && remaining.length > 0) remaining = shuffleArray(remaining); setQuestions(remaining); }).catch((e) => setError(e.message)); }, []); useEffect(() => { if (questions.length === 0 || index >= questions.length) return; const q = questions[index]; const answers = []; ['A', 'B', 'C', 'D'].forEach((k) => { if (q[`Antwort ${k}`]) answers.push({ key: k, text: q[`Antwort ${k}`] }); }); setShuffledAnswers(shuffleArray(answers)); }, [index, questions]); if (error) return
{error}
; if (!questions.length && allQuestions.length > 0) { const allResults = priorResults.map((a) => ({ Frage_ID: a.Frage_ID, Richtig: a.Richtig, Punkte: a.Punkte })); return (

✅ Alle Fragen bereits beantwortet!

); } if (!questions.length) return
Lade Fragen...
; const q = questions[index]; const typ = fieldVal(q['Typ'], 'MC').toLowerCase(); const sanitized = { frage: q['Frage'] || '(Kein Fragetext)', typ: fieldVal(q['Typ'], 'MC'), bild: q['Bild'] && Array.isArray(q['Bild']) && q['Bild'].length ? q['Bild'][0].url : null, antworten: shuffledAnswers, }; const feedback = showFeedback ? { correctKeys, correctText: typ === 'freitext' ? correctText : null, isCorrect } : null; const totalProgress = priorIds.current.size + index + 1; const totalAll = allQuestions.length; const checkAnswer = () => { const rawCorrect = (q['Richtige Antwort'] || '').trim(); let correctKeysResolved, ok; if (typ === 'freitext') { correctKeysResolved = []; setCorrectText(rawCorrect); ok = normalizeText(selected[0]) === normalizeText(rawCorrect); } else { // Resolve correct answer: could be keys ("A","B") or text ("Wahr","Falsch") const parts = rawCorrect.split(',').map((s) => s.trim()).filter(Boolean); const validKeys = new Set(['A', 'B', 'C', 'D']); const allAreKeys = parts.length > 0 && parts.every((p) => validKeys.has(p.toUpperCase())); if (allAreKeys) { correctKeysResolved = parts.map((p) => p.toUpperCase()); } else { // Match text against answer options correctKeysResolved = []; for (const part of parts) { const partLower = part.toLowerCase().trim(); // Search in all answers (including shuffled ones which keep their original keys) const allAnswers = []; ['A', 'B', 'C', 'D'].forEach((k) => { if (q[`Antwort ${k}`]) allAnswers.push({ key: k, text: q[`Antwort ${k}`] }); }); for (const ans of allAnswers) { if (ans.text.toLowerCase().trim() === partLower) { correctKeysResolved.push(ans.key); break; } } } // Fallback if (correctKeysResolved.length === 0) { correctKeysResolved = parts.map((p) => p.toUpperCase()); } } const correctSet = new Set(correctKeysResolved); const selectedSet = new Set(selected.map((s) => s.toUpperCase())); ok = correctKeysResolved.length === selectedSet.size && correctKeysResolved.every((c) => selectedSet.has(c)); } setCorrectKeys(correctKeysResolved); setIsCorrect(ok); setShowFeedback(true); const answerText = selected.map((key) => { const ans = shuffledAnswers.find((a) => a.key === key); return ans ? ans.text : key; }).join(', '); const answerData = { Nutzername: params.name, Frage_ID: q.id, Fragenset: params.setName, Antwort: answerText, Richtig: ok, Punkte: ok ? 1000 : 0, Session_ID: sessionId.current, Modus: 'Solo', Zeitstempel: new Date().toISOString(), }; setSaving(true); api.post('/api/results/single', answerData) .catch((e) => console.error('Save error:', e)) .finally(() => setSaving(false)); setResults((prev) => [...prev, { Frage_ID: q.id, Richtig: ok, Punkte: ok ? 1000 : 0 }]); }; const nextQuestion = () => { if (index + 1 >= questions.length) { const allResults = [ ...priorResults.map((a) => ({ Frage_ID: a.Frage_ID, Richtig: a.Richtig, Punkte: a.Punkte })), ...results, ]; navigate('solo-result', { results: allResults, setName: params.setName, name: params.name, questions: allQuestions }); return; } setIndex(index + 1); setSelected([]); setShowFeedback(false); setCorrectKeys([]); setCorrectText(''); }; return (
navigate('home')} /> Frage {totalProgress} / {totalAll}

{sanitized.frage}

{sanitized.bild && setEnlargedImg(sanitized.bild)} />} {!showFeedback && ( )} {showFeedback && (
{isCorrect ? '✅ Richtig!' : '❌ Falsch'}
{saving &&

Speichere...

}
)} setEnlargedImg(null)} />
); } /* ================================================================== */ /* Solo Result Page */ /* ================================================================== */ function SoloResultPage({ params, navigate }) { const { results, setName, name, questions } = params; const total = results.length; const correct = results.filter((r) => r.Richtig).length; const pct = total > 0 ? Math.round((correct / total) * 100) : 0; const qMap = {}; (questions || []).forEach((q) => { qMap[q.id] = q; }); const byCategory = {}; results.forEach((r) => { const q = qMap[r.Frage_ID]; const cat = fieldVal(q?.['Kategorie'], 'Ohne Kategorie') || 'Ohne Kategorie'; if (!byCategory[cat]) byCategory[cat] = { total: 0, correct: 0 }; byCategory[cat].total++; if (r.Richtig) byCategory[cat].correct++; }); return (

📊 Ergebnis

{setName} — {name}

= 70 ? 'text-green-400' : pct >= 40 ? 'text-yellow-400' : 'text-red-400'}`}>{pct}%

{correct} / {total} richtig

{Object.keys(byCategory).length > 1 && (

Nach Kategorie:

{Object.entries(byCategory).map(([cat, v]) => (
{cat} = 0.7 ? 'text-green-400' : 'text-red-400'}>{v.correct}/{v.total}
))}
)}
); } /* ================================================================== */ /* Wettkampf Select Page */ /* ================================================================== */ function WettkampfSelectPage({ navigate }) { const [sets, setSets] = useState([]); const [name, setName] = useState(''); const [selectedSet, setSelectedSet] = useState(null); const [totalQuestions, setTotalQuestions] = useState(null); const [error, setError] = useState(''); const [checking, setChecking] = useState(false); const [progressDialog, setProgressDialog] = useState(null); useEffect(() => { api.get('/api/sets').then((allSets) => { // Only show sets with a configured wettkampfCount setSets(allSets.filter((s) => s.wettkampfCount && s.wettkampfCount > 0)); }).catch((e) => setError(e.message)); }, []); const selectSet = async (set) => { setSelectedSet(set); try { const data = await api.get(`/api/sets/${set.id}/count`); setTotalQuestions(data.count); } catch (e) { setError(e.message); } }; const startWettkampf = async () => { if (!name.trim()) return setError('Bitte Name eingeben'); const count = selectedSet.wettkampfCount; setChecking(true); setError(''); try { const progress = await api.get(`/api/wettkampf-progress/${encodeURIComponent(name.trim())}/${selectedSet.id}`); if (progress.hasProgress) { setProgressDialog({ ...progress, count }); } else { navigate('wettkampf-quiz', { setId: selectedSet.id, setName: selectedSet.name, name: name.trim(), questionCount: count }); } } catch (e) { navigate('wettkampf-quiz', { setId: selectedSet.id, setName: selectedSet.name, name: name.trim(), questionCount: count }); } setChecking(false); }; const continueSession = () => { navigate('wettkampf-quiz', { setId: selectedSet.id, setName: selectedSet.name, name: name.trim(), questionCount: selectedSet.wettkampfCount, continueSessionId: progressDialog.sessionId, priorAnswers: progressDialog.answers, }); setProgressDialog(null); }; const startFresh = () => { navigate('wettkampf-quiz', { setId: selectedSet.id, setName: selectedSet.name, name: name.trim(), questionCount: selectedSet.wettkampfCount, }); setProgressDialog(null); }; return (
navigate('home')} label="Zurück" />

⚔️ Wettkampf

setName(e.target.value)} /> {progressDialog && (

📌 Gespeicherter Fortschritt

{selectedSet.name}: {progressDialog.totalAnswered} Fragen beantwortet

)} {!progressDialog && ( <> {!selectedSet ? ( <>

Fragenset wählen:

{sets.length === 0 &&

Keine Wettkampf-Sets konfiguriert. Bitte im Admin-Bereich konfigurieren.

} {sets.map((s) => ( ))} ) : ( <>
Fragenset:

{selectedSet.name}

{selectedSet.wettkampfCount} Fragen{totalQuestions ? ` (von ${totalQuestions} gesamt)` : ''}

{error &&

{error}

} )} )}
); } /* ================================================================== */ /* Wettkampf Quiz Page */ /* ================================================================== */ function WettkampfQuizPage({ params, navigate }) { const [questions, setQuestions] = useState([]); const [allQuestionsRaw, setAllQuestionsRaw] = useState([]); const [index, setIndex] = useState(0); const [answers, setAnswers] = useState({}); // { questionId: { selected: [...], rowId: null } } const [error, setError] = useState(''); const [enlargedImg, setEnlargedImg] = useState(null); const [shuffledAnswersMap, setShuffledAnswersMap] = useState({}); const [saving, setSaving] = useState(false); const [confirming, setConfirming] = useState(false); const sessionId = useRef(params.continueSessionId || 'WK-' + Date.now().toString(36).toUpperCase()); // Type-aware random selection const selectQuestions = useCallback((allQuestions, count) => { const shuffled = shuffleArray(allQuestions); // Detect all unique types dynamically const typeGroups = {}; shuffled.forEach((q) => { const typ = fieldVal(q['Typ'], 'MC'); if (!typeGroups[typ]) typeGroups[typ] = []; typeGroups[typ].push(q); }); const typeKeys = Object.keys(typeGroups); const selected = []; const usedIds = new Set(); // If count >= number of types, pick one of each type first if (typeKeys.length > 1 && count >= typeKeys.length) { for (const typ of typeKeys) { const q = typeGroups[typ][0]; selected.push(q); usedIds.add(q.id); } } // Fill remaining from shuffled pool for (const q of shuffled) { if (selected.length >= count) break; if (!usedIds.has(q.id)) { selected.push(q); usedIds.add(q.id); } } return shuffleArray(selected); // Shuffle final order }, []); useEffect(() => { api.get(`/api/sets/${params.setId}/questions`).then((qs) => { setAllQuestionsRaw(qs); if (params.priorAnswers && params.priorAnswers.length > 0) { // Resuming: find the questions that were already answered const priorIds = new Set(params.priorAnswers.map((a) => a.Frage_ID)); const resumeQuestions = qs.filter((q) => priorIds.has(q.id)); setQuestions(resumeQuestions); // Restore answers state const restored = {}; params.priorAnswers.forEach((a) => { const ansStr = a.Antwort || ''; restored[a.Frage_ID] = { selected: ansStr ? ansStr.split(',').map((s) => s.trim()) : [], rowId: a.rowId, }; }); setAnswers(restored); } else { const picked = selectQuestions(qs, params.questionCount); setQuestions(picked); } }).catch((e) => setError(e.message)); }, []); // Build shuffled answers for each question useEffect(() => { if (questions.length === 0) return; const map = {}; questions.forEach((q) => { const typ = fieldVal(q['Typ'], 'MC').toLowerCase(); if (typ !== 'freitext') { const antworten = []; ['A', 'B', 'C', 'D'].forEach((k) => { if (q[`Antwort ${k}`]) antworten.push({ key: k, text: q[`Antwort ${k}`] }); }); map[q.id] = shuffleArray(antworten); } else { map[q.id] = []; } }); setShuffledAnswersMap(map); }, [questions]); const q = questions[index]; const answeredCount = Object.keys(answers).filter((qid) => { const a = answers[qid]; return a && a.selected && a.selected.length > 0 && (a.selected[0] !== ''); }).length; const allAnswered = answeredCount >= questions.length; const saveAnswer = async (questionId, selected) => { const existing = answers[questionId]; const answerText = selected.join(','); if (existing && existing.rowId) { // Update existing answer try { await api.put('/api/results/update', { sessionId: sessionId.current, frageId: questionId, nutzername: params.name, antwort: answerText, }); } catch (e) { console.error('Update error:', e); } } else { // Create new answer try { setSaving(true); const row = await api.post('/api/results/single', { Nutzername: params.name, Frage_ID: questionId, Fragenset: params.setName, Antwort: answerText, Richtig: false, Punkte: 0, Session_ID: sessionId.current, Modus: 'Wettkampf', Zeitstempel: new Date().toISOString(), }); setAnswers((prev) => ({ ...prev, [questionId]: { selected, rowId: row.id }, })); setSaving(false); return; } catch (e) { console.error('Save error:', e); setSaving(false); } } setAnswers((prev) => ({ ...prev, [questionId]: { ...prev[questionId], selected }, })); }; const handleSelect = (questionId, selected) => { setAnswers((prev) => ({ ...prev, [questionId]: { ...(prev[questionId] || {}), selected }, })); }; const submitCurrent = () => { if (!q) return; const currentAnswer = answers[q.id]; if (currentAnswer && currentAnswer.selected && currentAnswer.selected.length > 0) { saveAnswer(q.id, currentAnswer.selected); } // Move to next unanswered or stay goToNextUnanswered(); }; const goToNextUnanswered = () => { // Find next unanswered starting from current index for (let i = 1; i <= questions.length; i++) { const nextIdx = (index + i) % questions.length; const qId = questions[nextIdx].id; const a = answers[qId]; if (!a || !a.selected || a.selected.length === 0 || a.selected[0] === '') { setIndex(nextIdx); return; } } // All answered; stay on current }; const skipQuestion = () => { // Just go to next question (wrapping) const nextIdx = (index + 1) % questions.length; setIndex(nextIdx); }; const confirmResults = async () => { setConfirming(true); // Calculate scores for all questions const finalResults = []; questions.forEach((question) => { const a = answers[question.id]; if (!a) return; const typ = fieldVal(question['Typ'], 'MC').toLowerCase(); const rawCorrect = (question['Richtige Antwort'] || '').trim(); let ok = false; if (typ === 'freitext') { ok = normalizeText(a.selected[0]) === normalizeText(rawCorrect); } else { // Resolve correct answers const parts = rawCorrect.split(',').map((s) => s.trim()).filter(Boolean); const validKeys = new Set(['A', 'B', 'C', 'D']); const allAreKeys = parts.length > 0 && parts.every((p) => validKeys.has(p.toUpperCase())); let correctKeysResolved; if (allAreKeys) { correctKeysResolved = parts.map((p) => p.toUpperCase()); } else { correctKeysResolved = []; for (const part of parts) { const partLower = part.toLowerCase().trim(); const allAntworten = []; ['A', 'B', 'C', 'D'].forEach((k) => { if (question[`Antwort ${k}`]) allAntworten.push({ key: k, text: question[`Antwort ${k}`] }); }); for (const ans of allAntworten) { if (ans.text.toLowerCase().trim() === partLower) { correctKeysResolved.push(ans.key); break; } } } if (correctKeysResolved.length === 0) correctKeysResolved = parts.map((p) => p.toUpperCase()); } const correctSet = new Set(correctKeysResolved); const selectedSet = new Set(a.selected.map((s) => s.toUpperCase())); ok = correctKeysResolved.length === selectedSet.size && correctKeysResolved.every((c) => selectedSet.has(c)); } finalResults.push({ rowId: a.rowId, questionId: question.id, richtig: ok, punkte: ok ? 1000 : 0, }); }); // Save final scores to backend try { const rowsToUpdate = finalResults.filter((r) => r.rowId); if (rowsToUpdate.length > 0) { await api.put('/api/results/finalize', { results: rowsToUpdate }); } } catch (e) { console.error('Finalize error:', e); } navigate('wettkampf-result', { results: finalResults, questions: questions, answers: answers, setName: params.setName, name: params.name, allQuestionsRaw: allQuestionsRaw, shuffledAnswersMap: shuffledAnswersMap, }); }; if (error) return
{error}
; if (!questions.length) return
Lade Fragen...
; const currentAnswer = answers[q?.id]; const currentSelected = currentAnswer?.selected || []; const typ = fieldVal(q['Typ'], 'MC').toLowerCase(); const isWF = typ === 'wahr/falsch'; const currentShuffledAnswers = shuffledAnswersMap[q?.id] || []; const sanitized = { frage: q['Frage'] || '(Kein Fragetext)', typ: fieldVal(q['Typ'], 'MC'), bild: q['Bild'] && Array.isArray(q['Bild']) && q['Bild'].length ? q['Bild'][0].url : null, antworten: currentShuffledAnswers, }; const hasCurrentAnswer = currentSelected.length > 0 && currentSelected[0] !== ''; return (
{/* Header */}
{ if (confirm('Wettkampf wirklich verlassen? Dein Fortschritt bleibt gespeichert.')) navigate('home'); }} label="Verlassen" /> Frage {index + 1} / {questions.length}
{/* Progress bar */}
{/* Question navigator */}
{questions.map((question, i) => { const a = answers[question.id]; const isAnswered = a && a.selected && a.selected.length > 0 && a.selected[0] !== ''; const isCurrent = i === index; let cls = 'w-9 h-9 rounded-lg text-sm font-bold transition-all '; if (isCurrent) cls += 'bg-amber-500 text-white ring-2 ring-amber-300'; else if (isAnswered) cls += 'bg-emerald-600 text-white'; else cls += 'bg-gray-700 text-gray-400 hover:bg-gray-600'; return ; })}
{/* Status */}
{answeredCount} / {questions.length} beantwortet
{/* Question */}

{sanitized.frage}

{sanitized.bild && setEnlargedImg(sanitized.bild)} />} {/* Answer area — no feedback, just selection */} {typ === 'freitext' ? ( handleSelect(q.id, [e.target.value])} autoFocus /> ) : (
{sanitized.antworten.map((a) => { const isSelected = currentSelected.includes(a.key); const cls = `${ANSWER_COLORS[a.key]} ${isSelected ? 'selected' : ''} text-white font-bold py-4 px-6 rounded-xl text-lg cursor-pointer transition-all text-center`; return ( ); })}
)} {/* Action buttons */}
{hasCurrentAnswer && ( )}
{/* Confirm button */}
{allAnswered ? ( ) : ( )}
setEnlargedImg(null)} />
); } /* ================================================================== */ /* Wettkampf Result Page */ /* ================================================================== */ function WettkampfResultPage({ params, navigate }) { const { results, questions, answers, setName, name, shuffledAnswersMap } = params; const total = results.length; const correct = results.filter((r) => r.richtig).length; const pct = total > 0 ? Math.round((correct / total) * 100) : 0; const qMap = {}; questions.forEach((q) => { qMap[q.id] = q; }); // Separate into wrong and right const wrongResults = results.filter((r) => !r.richtig); const rightResults = results.filter((r) => r.richtig); // Resolve correct answers for a question const resolveCorrectKeys = (question) => { const rawCorrect = (question['Richtige Antwort'] || '').trim(); const typ = fieldVal(question['Typ'], 'MC').toLowerCase(); if (typ === 'freitext') return { keys: [], text: rawCorrect }; const parts = rawCorrect.split(',').map((s) => s.trim()).filter(Boolean); const validKeys = new Set(['A', 'B', 'C', 'D']); const allAreKeys = parts.length > 0 && parts.every((p) => validKeys.has(p.toUpperCase())); if (allAreKeys) return { keys: parts.map((p) => p.toUpperCase()), text: null }; const resolved = []; for (const part of parts) { const partLower = part.toLowerCase().trim(); ['A', 'B', 'C', 'D'].forEach((k) => { if ((question[`Antwort ${k}`] || '').toLowerCase().trim() === partLower) resolved.push(k); }); } return { keys: resolved.length > 0 ? resolved : parts.map((p) => p.toUpperCase()), text: null }; }; const QuestionReview = ({ result }) => { const question = qMap[result.questionId]; if (!question) return null; const typ = fieldVal(question['Typ'], 'MC').toLowerCase(); const userAnswer = answers[result.questionId]; const selected = userAnswer?.selected || []; const { keys: correctKeys, text: correctText } = resolveCorrectKeys(question); const shuffledAntworten = shuffledAnswersMap[question.id] || []; return (

{question['Frage']}

{question['Bild'] && Array.isArray(question['Bild']) && question['Bild'].length > 0 && ( )} {typ === 'freitext' ? (
{result.richtig ? '✓ ' : '✗ '}{selected[0] || '(keine Antwort)'}
{!result.richtig && correctText && (
✓ {correctText}
)}
) : (
{shuffledAntworten.map((a) => { const isSelected = selected.includes(a.key); const isCorrectAnswer = correctKeys.includes(a.key); let cls = 'py-3 px-4 rounded-lg text-center font-bold '; let icon = ''; if (isCorrectAnswer) { cls += 'bg-green-600 text-white ring-2 ring-green-400'; icon = '✓ '; } else if (isSelected) { cls += 'bg-red-700 text-white ring-2 ring-red-400'; icon = '✗ '; } else { cls += 'bg-gray-700 text-gray-500 opacity-40'; } return
{icon}{a.text}
; })}
)}
); }; // Category breakdown const byCategory = {}; results.forEach((r) => { const q = qMap[r.questionId]; const cat = fieldVal(q?.['Kategorie'], 'Ohne Kategorie') || 'Ohne Kategorie'; if (!byCategory[cat]) byCategory[cat] = { total: 0, correct: 0 }; byCategory[cat].total++; if (r.richtig) byCategory[cat].correct++; }); return (
{/* Overall Score */}

⚔️ Wettkampf-Ergebnis

{setName} — {name}

= 70 ? 'text-green-400' : pct >= 40 ? 'text-yellow-400' : 'text-red-400'}`}>{pct}%

{correct} / {total} richtig

{/* Category breakdown */} {Object.keys(byCategory).length > 1 && (

Nach Kategorie:

{Object.entries(byCategory).map(([cat, v]) => (
{cat} = 0.7 ? 'text-green-400' : 'text-red-400'}>{v.correct}/{v.total}
))}
)} {/* Wrong answers */} {wrongResults.length > 0 && (

❌ Falsch beantwortet ({wrongResults.length})

{wrongResults.map((r) => )}
)} {/* Right answers */} {rightResults.length > 0 && (

✅ Richtig beantwortet ({rightResults.length})

{rightResults.map((r) => )}
)}
); } /* ================================================================== */ /* My Results Page */ /* ================================================================== */ function MyResultsPage({ navigate }) { const [name, setName] = useState(''); const [results, setResults] = useState(null); const [error, setError] = useState(''); const search = () => { if (!name.trim()) return; setError(''); api.get(`/api/results/${encodeURIComponent(name.trim())}`) .then(setResults).catch((e) => setError(e.message)); }; const sessions = {}; if (results) { results.forEach((r) => { const sid = r['Session_ID'] || 'Unbekannt'; if (!sessions[sid]) sessions[sid] = { modus: fieldVal(r['Modus'], ''), fragenset: r['Fragenset'], date: r['Zeitstempel'], answers: [] }; sessions[sid].answers.push(r); }); } return (
navigate('home')} label="Zurück" />

📊 Meine Ergebnisse

setName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && search()} /> {error &&

{error}

}
{results && results.length === 0 &&

Keine Ergebnisse gefunden.

} {results && results.length > 0 && (
{Object.entries(sessions).reverse().map(([sid, s]) => { const total = s.answers.length; const cor = s.answers.filter((a) => { const v = a['Richtig']; return v === true || v === 'true' || v === 1; }).length; const points = s.answers.reduce((sum, a) => sum + (Number(a['Punkte']) || 0), 0); return (
{s.modus} {s.fragenset}
{s.date ? new Date(s.date).toLocaleDateString('de') : ''}

{cor}/{total} richtig — {points.toLocaleString('de')} Punkte

); })}
)}
); } /* ================================================================== */ /* App Router */ /* ================================================================== */ function App() { const [page, setPage] = useState('home'); const [params, setParams] = useState({}); const navigate = useCallback((p, par = {}) => { setPage(p); setParams(par); window.scrollTo(0, 0); }, []); switch (page) { case 'home': return ; case 'join': return ; case 'live-play': return ; case 'solo-select': return ; case 'solo-quiz': return ; case 'solo-result': return ; case 'wettkampf-select': return ; case 'wettkampf-quiz': return ; case 'wettkampf-result': return ; case 'my-results': return ; default: return ; } } ReactDOM.createRoot(document.getElementById('root')).render();