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 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})
))}
{msg &&
{msg}
}
🔧 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 ;
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
📁 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
{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 (
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 (
{tabs.map((t) => (
))}
{tab === 'config' && }
{tab === 'live' && }
{tab === 'live-control' && }
{tab === 'stats' && }
);
}
ReactDOM.createRoot(document.getElementById('root')).render();