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}`; } 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); } /* ================================================================== */ /* 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 (
Quizalarm

Quizalarm Admin

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 [newSetWettkampfCount, setNewSetWettkampfCount] = useState(''); const [newSetColor, setNewSetColor] = useState('#d97706'); const [newSetTimer, setNewSetTimer] = useState(''); const [msg, setMsg] = useState(''); const [debugResult, setDebugResult] = useState(null); const addSet = () => { if (!newSetName || !newSetTableId) return; const wkCount = parseInt(newSetWettkampfCount) || null; const wkTimer = parseInt(newSetTimer) || null; const updated = { ...config, sets: [...(config.sets || []), { id: Date.now().toString(36), name: newSetName, tableId: parseInt(newSetTableId), wettkampfCount: wkCount, color: newSetColor, wettkampfTimer: wkTimer }], }; setConfig(updated); saveConfig(updated); setNewSetName(''); setNewSetTableId(''); setNewSetWettkampfCount(''); setNewSetColor('#d97706'); setNewSetTimer(''); setMsg('Fragenset hinzugefügt!'); setTimeout(() => setMsg(''), 3000); }; const removeSet = (id) => { const updated = { ...config, sets: config.sets.filter((s) => s.id !== id) }; setConfig(updated); saveConfig(updated); }; const updateSetWettkampfCount = (setId, val) => { const wkCount = parseInt(val) || null; const updated = { ...config, sets: config.sets.map((s) => s.id === setId ? { ...s, wettkampfCount: wkCount } : s) }; setConfig(updated); saveConfig(updated); }; const updateSetColor = (setId, val) => { const updated = { ...config, sets: config.sets.map((s) => s.id === setId ? { ...s, color: val } : s) }; setConfig(updated); saveConfig(updated); }; const updateSetTimer = (setId, val) => { const wkTimer = parseInt(val) || null; const updated = { ...config, sets: config.sets.map((s) => s.id === setId ? { ...s, wettkampfTimer: wkTimer } : s) }; setConfig(updated); saveConfig(updated); }; const updateAnswersTable = (val) => { const updated = { ...config, answersTableId: val ? parseInt(val) : null }; setConfig(updated); saveConfig(updated); }; const testConnection = async () => { try { setDebugResult('Teste...'); const result = await api.get('/api/admin/debug-baserow'); setDebugResult(JSON.stringify(result, null, 2)); } catch (e) { setDebugResult('Fehler: ' + e.message); } }; return (

📋 Fragensets

{(config.sets || []).length === 0 &&

Noch keine Fragensets konfiguriert.

} {(config.sets || []).map((s) => (
{s.name} (Table ID: {s.tableId})
🎨 updateSetColor(s.id, e.target.value)} className="w-8 h-8 border rounded cursor-pointer" style={{ padding: 0 }} />
⚔️ Fragen: updateSetWettkampfCount(s.id, e.target.value)} />
⏱ Timer (Min): updateSetTimer(s.id, e.target.value)} />
))}
setNewSetName(e.target.value)} /> setNewSetTableId(e.target.value)} /> setNewSetColor(e.target.value)} className="w-10 h-10 border rounded cursor-pointer" style={{ padding: 0 }} />
setNewSetWettkampfCount(e.target.value)} /> setNewSetTimer(e.target.value)} />
{msg &&

{msg}

}

💾 Antworten-Tabelle

Table ID: updateAnswersTable(e.target.value)} placeholder="Table ID" />

🔧 Verbindungstest

{debugResult && (
{debugResult}
)}
); } /* ================================================================== */ /* Stats Page */ /* ================================================================== */ function StatsPage() { const [data, setData] = useState(null); const [filter, setFilter] = useState(''); const [err, setErr] = useState(''); useEffect(() => { api.get('/api/admin/stats').then(setData).catch((e) => setErr(e.message)); }, []); if (err) return

{err}

; if (!data) return

Lade Statistiken...

; if (!data.answers.length) return

Noch keine Ergebnisse vorhanden.

; const sessions = {}; data.answers.forEach((a) => { const sid = a['Session_ID'] || 'Unbekannt'; if (!sessions[sid]) sessions[sid] = { modus: fieldVal(a['Modus'], ''), fragenset: fieldVal(a['Fragenset'], ''), date: a['Zeitstempel'], answers: [] }; sessions[sid].answers.push(a); }); 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++; const richtig = a['Richtig']; if (richtig === true || richtig === 'true' || richtig === 1) users[u].correct++; users[u].points += Number(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.toLocaleString('de')}

📁 Sessions

{Object.entries(sessions).reverse().map(([sid, s]) => { const total = s.answers.length; const correct = s.answers.filter((a) => { const v = a['Richtig']; return v === true || v === 'true' || v === 1; }).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 — with custom code + reconnect */ /* ================================================================== */ 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 [customCode, setCustomCode] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); // Reconnect state const [reconnectCode, setReconnectCode] = useState(''); const [reconnectError, setReconnectError] = useState(''); const [reconnecting, setReconnecting] = 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 }, customCode: customCode.trim() || null, })); }; ws.onmessage = (e) => { const msg = JSON.parse(e.data); if (msg.type === 'session-created') { ws.onmessage = null; onSessionCreated(msg.code, ws, { code: msg.code, setName: msg.setName, questionCount: msg.questionCount, options: msg.options, players: [], playerCount: 0, presentToken: msg.presentToken, }); } else if (msg.type === 'error') { setError(msg.message); setLoading(false); ws.close(); } }; ws.onerror = () => { setError('WebSocket Verbindungsfehler'); setLoading(false); }; }; const reconnect = () => { const code = reconnectCode.trim().replace(/[^a-zA-Z0-9]/g, '').toUpperCase(); if (!code) return setReconnectError('Bitte Code eingeben'); setReconnecting(true); setReconnectError(''); const ws = new WebSocket(wsUrl()); ws.onopen = () => { ws.send(JSON.stringify({ type: 'admin-join', token, code })); }; ws.onmessage = (e) => { const msg = JSON.parse(e.data); if (msg.type === 'admin-joined') { ws.onmessage = null; onSessionCreated(msg.session.code, ws, msg.session); } else if (msg.type === 'error') { setReconnectError(msg.message); setReconnecting(false); ws.close(); } }; ws.onerror = () => { setReconnectError('Verbindungsfehler'); setReconnecting(false); }; }; return (
{/* Create new session */}

🎮 Neue Live-Session

setCustomCode(e.target.value)} maxLength={20} />

Nur Buchstaben und Zahlen. Mind. 3 Zeichen. Leer = zufällig generiert.

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

{error}

}
{/* Reconnect to existing session */}

🔄 Bestehende Session übernehmen

Falls die Verbindung unterbrochen wurde, kannst du dich hier wieder mit einer laufenden Session verbinden.

setReconnectCode(e.target.value)} maxLength={20} onKeyDown={(e) => e.key === 'Enter' && reconnect()} /> {reconnectError &&

{reconnectError}

}
); } /* ================================================================== */ /* Live Control Page */ /* ================================================================== */ function LiveControlPage({ token, code, ws, initialSession }) { const [session, setSession] = useState(initialSession || null); const [phase, setPhase] = useState('waiting'); const [answerCount, setAnswerCount] = useState(0); const [result, setResult] = useState(null); const [leaderboardData, setLeaderboardData] = useState([]); const refreshRef = useRef(null); useEffect(() => { if (!ws) return; ws.onmessage = (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, players: [], playerCount: 0, presentToken: msg.presentToken, }); setPhase('waiting'); break; case 'admin-joined': setSession(msg.session); setLeaderboardData(msg.session.leaderboard || []); if (msg.session.status === 'waiting') setPhase('waiting'); else if (msg.session.status === 'ended') setPhase('ended'); else if (msg.session.status === 'showing-result') setPhase('result'); else setPhase('question'); break; case 'player-joined': case 'player-left': setSession((s) => { const base = s || { code }; return { ...base, players: msg.players, playerCount: msg.playerCount }; }); 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; case 'error': console.error('Admin error:', msg.message); break; } }; ws.send(JSON.stringify({ type: 'admin-join', token, code })); refreshRef.current = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'admin-join', token, code })); } }, 5000); return () => { clearInterval(refreshRef.current); ws.onmessage = null; }; }, [ws, code, token]); useEffect(() => { if (phase !== 'waiting') clearInterval(refreshRef.current); }, [phase]); const start = () => ws.send(JSON.stringify({ type: 'admin-start', token, code })); const showResultAction = () => ws.send(JSON.stringify({ type: 'admin-show-result', 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 presentToken = session?.presentToken || ''; const presentUrl = `${location.origin}/present?pt=${encodeURIComponent(presentToken)}#${code}`; const playerCount = session?.players?.length || session?.playerCount || 0; return (

Session-Code

{code}

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

📺 Präsentations-Fenster öffnen

Teilnehmer ({playerCount})

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

Noch keine Teilnehmer...

}
{phase === 'waiting' && ( )} {phase === 'question' && (

Antworten: {answerCount} / {playerCount}

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

Ergebnis

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

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

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

🏁 Quiz beendet

Ergebnisse wurden gespeichert.

)} {leaderboardData.length > 0 && (

🏆 Leaderboard

{leaderboardData.map((e, i) => (
{i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${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); const [liveInitialSession, setLiveInitialSession] = 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, sessionData) => { setLiveCode(code); setLiveWs(ws); setLiveInitialSession(sessionData); 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();