const { useState, useEffect, useRef, useCallback } = React; const api = { token: null, async get(url) { const r = await fetch(url, { headers: { 'X-Admin-Token': this.token } }); 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', 'X-Admin-Token': this.token }, 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}`; } /* ================================================================== */ /* Login */ /* ================================================================== */ function LoginPage({ onLogin }) { const [pw, setPw] = useState(''); const [err, setErr] = useState(''); const submit = async () => { try { const res = await api.post('/api/admin/login', { password: pw }); api.token = res.token; onLogin(res.token); } catch (e) { setErr(e.message); } }; return (

🔐 Admin-Login

setPw(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && submit()} autoFocus /> {err &&

{err}

}
); } /* ================================================================== */ /* Config Page */ /* ================================================================== */ function ConfigPage({ config, setConfig, saveConfig }) { const [newSetName, setNewSetName] = useState(''); const [newSetTableId, setNewSetTableId] = useState(''); const [msg, setMsg] = useState(''); const addSet = () => { if (!newSetName || !newSetTableId) return; const updated = { ...config, sets: [...(config.sets || []), { id: Date.now().toString(36), name: newSetName, tableId: parseInt(newSetTableId) }], }; setConfig(updated); saveConfig(updated); setNewSetName(''); setNewSetTableId(''); setMsg('Fragenset hinzugefügt'); setTimeout(() => setMsg(''), 2000); }; const removeSet = (id) => { const updated = { ...config, sets: config.sets.filter((s) => s.id !== id) }; setConfig(updated); saveConfig(updated); }; const updateAnswersTable = (val) => { const updated = { ...config, answersTableId: val ? parseInt(val) : null }; setConfig(updated); saveConfig(updated); }; return (

📋 Fragensets

{(config.sets || []).map((s) => (
{s.name} (Table ID: {s.tableId})
))}
setNewSetName(e.target.value)} /> setNewSetTableId(e.target.value)} />
{msg &&

{msg}

}

💾 Antworten-Tabelle

Table ID: updateAnswersTable(e.target.value)} placeholder="Table ID" />
); } /* ================================================================== */ /* Stats Page */ /* ================================================================== */ function StatsPage() { const [data, setData] = useState(null); const [filter, setFilter] = useState(''); useEffect(() => { api.get('/api/admin/stats').then(setData).catch(console.error); }, []); if (!data) return

Lade Statistiken...

; if (!data.answers.length) return

Noch keine Ergebnisse vorhanden.

; // Group by session const sessions = {}; data.answers.forEach((a) => { const sid = a['Session_ID'] || 'Unbekannt'; if (!sessions[sid]) sessions[sid] = { modus: a['Modus'], fragenset: a['Fragenset'], date: a['Zeitstempel'], answers: [] }; sessions[sid].answers.push(a); }); // Group by user const users = {}; data.answers.forEach((a) => { const u = a['Nutzername'] || 'Unbekannt'; if (!users[u]) users[u] = { total: 0, correct: 0, points: 0 }; users[u].total++; if (a['Richtig']) users[u].correct++; users[u].points += a['Punkte'] || 0; }); return (

📊 Übersicht

{Object.keys(sessions).length}
Sessions
{Object.keys(users).length}
Teilnehmer
{data.answers.length}
Antworten

👤 Pro Teilnehmer

setFilter(e.target.value)} />
{Object.entries(users) .filter(([u]) => !filter || u.toLowerCase().includes(filter.toLowerCase())) .sort(([, a], [, b]) => b.points - a.points) .map(([u, v]) => ( ))}
NameRichtigGesamtQuotePunkte
{u} {v.correct} {v.total} {Math.round((v.correct / v.total) * 100)}% {v.points}

📁 Sessions

{Object.entries(sessions).reverse().map(([sid, s]) => { const total = s.answers.length; const correct = s.answers.filter((a) => a['Richtig']).length; const uniqueUsers = new Set(s.answers.map((a) => a['Nutzername'])).size; return (
{s.modus} {s.fragenset} {s.date ? new Date(s.date).toLocaleString('de') : ''}
{uniqueUsers} Teilnehmer — {correct}/{total} richtig gesamt
); })}
); } /* ================================================================== */ /* Live Setup Page */ /* ================================================================== */ function LiveSetupPage({ config, token, onSessionCreated }) { const [setId, setSetId] = useState(''); const [shuffle, setShuffle] = useState(true); const [scoring, setScoring] = useState('binary'); const [timeLimit, setTimeLimit] = useState(30); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const create = () => { if (!setId) return setError('Bitte ein Fragenset waehlen'); setLoading(true); setError(''); const ws = new WebSocket(wsUrl()); ws.onopen = () => { ws.send(JSON.stringify({ type: 'admin-create', token, setId, options: { shuffle, scoring, timeLimit } })); }; ws.onmessage = (e) => { const msg = JSON.parse(e.data); if (msg.type === 'session-created') { onSessionCreated(msg.code, ws); } else if (msg.type === 'error') { setError(msg.message); setLoading(false); ws.close(); } }; ws.onerror = () => { setError('WebSocket Fehler'); setLoading(false); }; }; return (

🎮 Live-Session erstellen

{scoring === 'time' && (
setTimeLimit(parseInt(e.target.value) || 30)} min={5} max={120} />
)} {error &&

{error}

}
); } /* ================================================================== */ /* Live Control Page */ /* ================================================================== */ function LiveControlPage({ token, code, ws }) { const [session, setSession] = useState(null); const [phase, setPhase] = useState('waiting'); const [answerCount, setAnswerCount] = useState(0); const [result, setResult] = useState(null); const [leaderboardData, setLeaderboardData] = useState([]); useEffect(() => { if (!ws) return; const handler = (e) => { const msg = JSON.parse(e.data); switch (msg.type) { case 'session-created': setSession({ code: msg.code, setName: msg.setName, questionCount: msg.questionCount, options: msg.options }); break; case 'admin-joined': setSession(msg.session); if (msg.session.status === 'waiting') setPhase('waiting'); else if (msg.session.status === 'ended') setPhase('ended'); else setPhase('active'); setLeaderboardData(msg.session.leaderboard); break; case 'player-joined': case 'player-left': setSession((s) => s ? { ...s, players: msg.players, playerCount: msg.playerCount } : s); break; case 'game-start': setPhase('active'); break; case 'question': setPhase('question'); setAnswerCount(0); setResult(null); break; case 'answer-count': setAnswerCount(msg.count); break; case 'question-result': setResult(msg); setLeaderboardData(msg.leaderboard); setPhase('result'); break; case 'game-end': setLeaderboardData(msg.leaderboard); setPhase('ended'); break; } }; ws.addEventListener('message', handler); return () => ws.removeEventListener('message', handler); }, [ws]); const start = () => ws.send(JSON.stringify({ type: 'admin-start', token, code })); const next = () => ws.send(JSON.stringify({ type: 'admin-next', token, code })); const end = () => ws.send(JSON.stringify({ type: 'admin-end', token, code })); const showResult = () => { // Force show result by telling server (same as next but for current question) // We can reuse admin-next if status is 'active' → actually we need showResult // For now, the master clicks "weiter" after everyone answered or time is up }; const presentUrl = `${location.origin}/present#${code}`; return (

Session-Code

{code}

{session?.setName} — {session?.questionCount || '?'} Fragen

📺 Präsentations-Fenster öffnen

Teilnehmer ({session?.players?.length || 0})

{(session?.players || []).map((n) => {n})}
{phase === 'waiting' && ( )} {phase === 'question' && (

Antworten: {answerCount} / {session?.players?.length || 0}

)} {phase === 'result' && (

Ergebnis

{result?.stats && (
{Object.entries(result.stats).map(([k, v]) => (
{k}: {v}
))}
)}

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

)} {phase === 'ended' && (

🏁 Quiz beendet

)} {leaderboardData.length > 0 && (

🏆 Leaderboard

{leaderboardData.map((e) => (
{e.rank}. {e.name}{e.score}
))}
)} {phase !== 'ended' && phase !== 'waiting' && ( )}
); } /* ================================================================== */ /* Admin App */ /* ================================================================== */ function AdminApp() { const [token, setToken] = useState(null); const [tab, setTab] = useState('config'); const [config, setConfig] = useState({ sets: [], answersTableId: null }); const [liveCode, setLiveCode] = useState(null); const [liveWs, setLiveWs] = useState(null); useEffect(() => { if (token) { api.token = token; api.get('/api/admin/config').then(setConfig).catch(console.error); } }, [token]); const saveConfig = async (c) => { try { await api.post('/api/admin/config', c); } catch (e) { console.error(e); } }; const handleSessionCreated = (code, ws) => { setLiveCode(code); setLiveWs(ws); setTab('live-control'); }; if (!token) return ; const tabs = [ { id: 'config', label: '⚙️ Konfiguration' }, { id: 'live', label: '🎮 Live-Session' }, { id: 'stats', label: '📊 Statistiken' }, ]; if (liveCode) tabs.splice(2, 0, { id: 'live-control', label: '🎛️ Steuerung' }); return (

⚡ Quizalarm Admin

← Zur App
{tabs.map((t) => ( ))}
{tab === 'config' && } {tab === 'live' && } {tab === 'live-control' && } {tab === 'stats' && }
); } ReactDOM.createRoot(document.getElementById('root')).render();