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 (
);
}
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 (
);
}
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
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 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 ;
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();