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(); }, }; 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}
))}
)}
); } /* ================================================================== */ /* 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 'my-results': return ; default: return ; } } ReactDOM.createRoot(document.getElementById('root')).render();