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; } /* ================================================================== */ /* Shared Components */ /* ================================================================== */ function ImageModal({ src, onClose }) { if (!src) return null; return (
Vergroesserung
); } 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 }) { const typ = (question.typ || 'MC').toLowerCase(); const isWF = typ === 'wahr/falsch'; if (typ === 'freitext') { return ( onToggle([e.target.value])} disabled={disabled} autoFocus /> ); } return (
{question.antworten.map((a) => { const active = selected.includes(a.key); const cls = `${ANSWER_COLORS[a.key]} ${active ? 'selected' : ''} ${disabled ? 'disabled' : ''} text-white font-bold py-4 px-6 rounded-xl text-lg cursor-pointer transition-all text-center`; return ( ); })}
); } /* ================================================================== */ /* Home Page */ /* ================================================================== */ function HomePage({ navigate }) { return (
Quizalarm

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={6} /> 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 elapsed = (Date.now() - start) / 1000; const left = Math.max(0, timeLimit - elapsed); 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); }; if (phase === 'error') return (

❌ {error}

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

Verbindung verloren...

); 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') return (
{result?.yourResult?.correct ? '✅ Richtig!' : '❌ Falsch'}

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

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

{result?.correctAnswer && (

Richtige Antwort: {result.correctAnswer.join(', ')}

)}

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(''); useEffect(() => { api.get('/api/sets').then(setSets).catch((e) => setError(e.message)); }, []); const start = (setId, setName2) => { if (!name.trim()) return setError('Bitte Name eingeben'); navigate('solo-quiz', { setId, setName: setName2, name: name.trim(), shuffle: doShuffle }); }; return (

📚 Solo-Lernen

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

{error}

}

Fragenset wählen:

{sets.length === 0 &&

Keine Fragensets konfiguriert. Bitte im Admin-Bereich einrichten.

} {sets.map((s) => ( ))}
); } /* ================================================================== */ /* Solo Quiz Page */ /* ================================================================== */ function SoloQuizPage({ params, navigate }) { const [questions, setQuestions] = useState([]); const [index, setIndex] = useState(0); const [selected, setSelected] = useState([]); const [showFeedback, setShowFeedback] = useState(false); const [isCorrect, setIsCorrect] = useState(false); const [correctAnswer, setCorrectAnswer] = useState([]); const [results, setResults] = useState([]); const [error, setError] = useState(''); const [enlargedImg, setEnlargedImg] = useState(null); const [shuffledAnswers, setShuffledAnswers] = useState([]); const sessionId = useRef('SOLO-' + Date.now().toString(36).toUpperCase()); useEffect(() => { api.get(`/api/sets/${params.setId}/questions`).then((qs) => { if (params.shuffle) { for (let i = qs.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [qs[i], qs[j]] = [qs[j], qs[i]]; } } setQuestions(qs); }).catch((e) => setError(e.message)); }, []); // Shuffle answers when question changes 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) return
Lade Fragen...
; const q = questions[index]; const typ = (q['Typ'] || 'MC').toLowerCase(); const sanitized = { frage: q['Frage'] || q['frage'] || '(Kein Fragetext)', typ: q['Typ'] || q['typ'] || 'MC', bild: q['Bild'] && Array.isArray(q['Bild']) && q['Bild'].length ? q['Bild'][0].url : null, antworten: shuffledAnswers, }; const normalizeText = (t) => (t || '').toLowerCase().replace(/[äÄ]/g, 'ae').replace(/[öÖ]/g, 'oe').replace(/[üÜ]/g, 'ue').replace(/ß/g, 'ss').replace(/[^a-z0-9]/g, ''); const checkAnswer = () => { const rawCorrect = (q['Richtige Antwort'] || '').trim(); let correct, ok; if (typ === 'freitext') { correct = [rawCorrect]; ok = normalizeText(selected[0]) === normalizeText(rawCorrect); } else { correct = rawCorrect.split(',').map((s) => s.trim().toUpperCase()).filter(Boolean); const correctSet = new Set(correct); const selectedSet = new Set(selected.map((s) => s.toUpperCase())); ok = correct.length === selectedSet.size && correct.every((c) => selectedSet.has(c)); } setCorrectAnswer(correct); setIsCorrect(ok); setShowFeedback(true); const newResults = [...results, { Nutzername: params.name, Frage_ID: q.id, Fragenset: params.setName, Antwort: selected.join(','), Richtig: ok, Punkte: ok ? 1000 : 0, Session_ID: sessionId.current, Modus: 'Solo', Zeitstempel: new Date().toISOString(), }]; setResults(newResults); }; const nextQuestion = () => { if (index + 1 >= questions.length) { api.post('/api/results', { answers: results }).catch((e) => console.error('Save error:', e)); navigate('solo-result', { results, setName: params.setName, name: params.name, questions }); return; } setIndex(index + 1); setSelected([]); setShowFeedback(false); }; return (
Frage {index + 1} / {questions.length}

{sanitized.frage}

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

Richtig: {correctAnswer.join(', ')}

}
)} 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 byCategory = {}; results.forEach((r, i) => { const q = questions[i]; const cat = (q && (q['Kategorie'] || q['kategorie'])) || 'Ohne Kategorie'; const catName = typeof cat === 'object' && cat.value ? cat.value : cat; if (!byCategory[catName]) byCategory[catName] = { total: 0, correct: 0 }; byCategory[catName].total++; if (r.Richtig) byCategory[catName].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: r['Modus'], fragenset: r['Fragenset'], date: r['Zeitstempel'], answers: [] }; sessions[sid].answers.push(r); }); } return (

📊 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 correct = s.answers.filter((a) => a['Richtig']).length; const points = s.answers.reduce((sum, a) => sum + (a['Punkte'] || 0), 0); return (
{s.modus} {s.fragenset}
{s.date ? new Date(s.date).toLocaleDateString('de') : ''}

{correct}/{total} richtig — {points} 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();