477 lines
23 KiB
JavaScript
477 lines
23 KiB
JavaScript
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 (
|
|
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
|
<div className="bg-white rounded-2xl shadow-lg p-8 w-full max-w-sm">
|
|
<h1 className="text-2xl font-bold mb-6 text-center">🔐 Admin-Login</h1>
|
|
<input type="password" className="w-full p-3 border rounded-lg mb-3 text-center" placeholder="Passwort" value={pw} onChange={(e) => setPw(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && submit()} autoFocus />
|
|
{err && <p className="text-red-500 text-sm mb-3 text-center">{err}</p>}
|
|
<button onClick={submit} className="w-full py-3 bg-indigo-600 text-white rounded-lg font-bold hover:bg-indigo-700">Anmelden</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ================================================================== */
|
|
/* 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 (
|
|
<div className="space-y-6">
|
|
<div className="bg-white rounded-xl shadow p-6">
|
|
<h2 className="text-xl font-bold mb-4">📋 Fragensets</h2>
|
|
{(config.sets || []).map((s) => (
|
|
<div key={s.id} className="flex items-center justify-between py-2 border-b last:border-0">
|
|
<div><span className="font-semibold">{s.name}</span> <span className="text-gray-400 text-sm">(Table ID: {s.tableId})</span></div>
|
|
<button onClick={() => removeSet(s.id)} className="text-red-500 hover:text-red-700 text-sm">Entfernen</button>
|
|
</div>
|
|
))}
|
|
<div className="mt-4 flex gap-2 flex-wrap">
|
|
<input className="flex-1 min-w-0 p-2 border rounded-lg" placeholder="Name" value={newSetName} onChange={(e) => setNewSetName(e.target.value)} />
|
|
<input className="w-32 p-2 border rounded-lg" placeholder="Table ID" type="number" value={newSetTableId} onChange={(e) => setNewSetTableId(e.target.value)} />
|
|
<button onClick={addSet} className="px-4 py-2 bg-indigo-600 text-white rounded-lg font-bold hover:bg-indigo-700">+</button>
|
|
</div>
|
|
{msg && <p className="text-green-600 text-sm mt-2">{msg}</p>}
|
|
</div>
|
|
<div className="bg-white rounded-xl shadow p-6">
|
|
<h2 className="text-xl font-bold mb-4">💾 Antworten-Tabelle</h2>
|
|
<div className="flex gap-2 items-center">
|
|
<span className="text-gray-600">Table ID:</span>
|
|
<input className="w-32 p-2 border rounded-lg" type="number" value={config.answersTableId || ''} onChange={(e) => updateAnswersTable(e.target.value)} placeholder="Table ID" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ================================================================== */
|
|
/* 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 <p className="text-center py-8">Lade Statistiken...</p>;
|
|
if (!data.answers.length) return <p className="text-center py-8 text-gray-500">Noch keine Ergebnisse vorhanden.</p>;
|
|
|
|
// 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 (
|
|
<div className="space-y-6">
|
|
<div className="bg-white rounded-xl shadow p-6">
|
|
<h2 className="text-xl font-bold mb-4">📊 Übersicht</h2>
|
|
<div className="grid grid-cols-3 gap-4 text-center">
|
|
<div className="bg-indigo-50 rounded-lg p-4">
|
|
<div className="text-2xl font-bold text-indigo-600">{Object.keys(sessions).length}</div>
|
|
<div className="text-sm text-gray-500">Sessions</div>
|
|
</div>
|
|
<div className="bg-emerald-50 rounded-lg p-4">
|
|
<div className="text-2xl font-bold text-emerald-600">{Object.keys(users).length}</div>
|
|
<div className="text-sm text-gray-500">Teilnehmer</div>
|
|
</div>
|
|
<div className="bg-amber-50 rounded-lg p-4">
|
|
<div className="text-2xl font-bold text-amber-600">{data.answers.length}</div>
|
|
<div className="text-sm text-gray-500">Antworten</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white rounded-xl shadow p-6">
|
|
<h3 className="text-lg font-bold mb-3">👤 Pro Teilnehmer</h3>
|
|
<input className="w-full p-2 border rounded-lg mb-3" placeholder="Filtern..." value={filter} onChange={(e) => setFilter(e.target.value)} />
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead><tr className="border-b"><th className="text-left py-2">Name</th><th>Richtig</th><th>Gesamt</th><th>Quote</th><th>Punkte</th></tr></thead>
|
|
<tbody>
|
|
{Object.entries(users)
|
|
.filter(([u]) => !filter || u.toLowerCase().includes(filter.toLowerCase()))
|
|
.sort(([, a], [, b]) => b.points - a.points)
|
|
.map(([u, v]) => (
|
|
<tr key={u} className="border-b last:border-0">
|
|
<td className="py-2 font-medium">{u}</td>
|
|
<td className="text-center text-green-600">{v.correct}</td>
|
|
<td className="text-center">{v.total}</td>
|
|
<td className="text-center">{Math.round((v.correct / v.total) * 100)}%</td>
|
|
<td className="text-center font-bold">{v.points}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white rounded-xl shadow p-6">
|
|
<h3 className="text-lg font-bold mb-3">📁 Sessions</h3>
|
|
{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 (
|
|
<div key={sid} className="border-b last:border-0 py-3">
|
|
<div className="flex justify-between">
|
|
<span><span className={`text-xs px-2 py-0.5 rounded-full text-white ${s.modus === 'Live' ? 'bg-indigo-600' : 'bg-emerald-600'}`}>{s.modus}</span> <strong>{s.fragenset}</strong></span>
|
|
<span className="text-sm text-gray-400">{s.date ? new Date(s.date).toLocaleString('de') : ''}</span>
|
|
</div>
|
|
<div className="text-sm text-gray-500 mt-1">{uniqueUsers} Teilnehmer — {correct}/{total} richtig gesamt</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ================================================================== */
|
|
/* 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 (
|
|
<div className="bg-white rounded-xl shadow p-6 max-w-md mx-auto">
|
|
<h2 className="text-xl font-bold mb-4">🎮 Live-Session erstellen</h2>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium mb-1">Fragenset</label>
|
|
<select className="w-full p-3 border rounded-lg" value={setId} onChange={(e) => setSetId(e.target.value)}>
|
|
<option value="">— Bitte wählen —</option>
|
|
{(config.sets || []).map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
|
</select>
|
|
</div>
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input type="checkbox" checked={shuffle} onChange={() => setShuffle(!shuffle)} className="w-5 h-5 accent-indigo-600" />
|
|
Fragen mischen
|
|
</label>
|
|
<div>
|
|
<label className="block text-sm font-medium mb-1">Punktevergabe</label>
|
|
<select className="w-full p-3 border rounded-lg" value={scoring} onChange={(e) => setScoring(e.target.value)}>
|
|
<option value="binary">Binär (1000 / 0)</option>
|
|
<option value="time">Zeitbasiert (schneller = mehr Punkte)</option>
|
|
</select>
|
|
</div>
|
|
{scoring === 'time' && (
|
|
<div>
|
|
<label className="block text-sm font-medium mb-1">Sekunden pro Frage</label>
|
|
<input type="number" className="w-full p-3 border rounded-lg" value={timeLimit} onChange={(e) => setTimeLimit(parseInt(e.target.value) || 30)} min={5} max={120} />
|
|
</div>
|
|
)}
|
|
{error && <p className="text-red-500 text-sm">{error}</p>}
|
|
<button onClick={create} disabled={loading} className="w-full py-3 bg-indigo-600 text-white rounded-lg font-bold hover:bg-indigo-700 disabled:opacity-50">
|
|
{loading ? 'Erstelle...' : 'Session erstellen'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ================================================================== */
|
|
/* 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 (
|
|
<div className="space-y-4 max-w-lg mx-auto">
|
|
<div className="bg-white rounded-xl shadow p-6 text-center">
|
|
<h2 className="text-xl font-bold mb-2">Session-Code</h2>
|
|
<div className="text-5xl font-mono font-extrabold tracking-widest text-indigo-600 mb-2">{code}</div>
|
|
<p className="text-gray-400 text-sm">{session?.setName} — {session?.questionCount || '?'} Fragen</p>
|
|
<a href={presentUrl} target="_blank" rel="noopener" className="inline-block mt-2 text-sm text-indigo-500 hover:underline">📺 Präsentations-Fenster öffnen</a>
|
|
</div>
|
|
|
|
<div className="bg-white rounded-xl shadow p-6">
|
|
<h3 className="font-bold mb-2">Teilnehmer ({session?.players?.length || 0})</h3>
|
|
<div className="flex flex-wrap gap-2">
|
|
{(session?.players || []).map((n) => <span key={n} className="px-3 py-1 bg-gray-100 rounded-full text-sm">{n}</span>)}
|
|
</div>
|
|
</div>
|
|
|
|
{phase === 'waiting' && (
|
|
<button onClick={start} disabled={!session?.players?.length} className="w-full py-4 bg-green-600 text-white rounded-xl text-xl font-bold hover:bg-green-700 disabled:opacity-50">
|
|
▶ Quiz starten
|
|
</button>
|
|
)}
|
|
|
|
{phase === 'question' && (
|
|
<div className="bg-white rounded-xl shadow p-6 text-center">
|
|
<p className="text-lg mb-2">Antworten: <strong>{answerCount}</strong> / {session?.players?.length || 0}</p>
|
|
<button onClick={next} className="w-full py-3 bg-indigo-600 text-white rounded-xl font-bold hover:bg-indigo-700 mt-2">Ergebnis zeigen ➜</button>
|
|
</div>
|
|
)}
|
|
|
|
{phase === 'result' && (
|
|
<div className="bg-white rounded-xl shadow p-6">
|
|
<h3 className="font-bold text-center mb-3">Ergebnis</h3>
|
|
{result?.stats && (
|
|
<div className="grid grid-cols-2 gap-2 mb-4">
|
|
{Object.entries(result.stats).map(([k, v]) => (
|
|
<div key={k} className={`p-2 rounded-lg text-white text-center ${k === 'A' ? 'bg-red-500' : k === 'B' ? 'bg-blue-500' : k === 'C' ? 'bg-amber-500' : 'bg-green-500'}`}>
|
|
{k}: <strong>{v}</strong>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
<p className="text-center text-sm text-gray-400 mb-2">Richtig: {result?.correctAnswer?.join(', ')}</p>
|
|
<button onClick={next} className="w-full py-3 bg-indigo-600 text-white rounded-xl font-bold hover:bg-indigo-700">Nächste Frage ➜</button>
|
|
</div>
|
|
)}
|
|
|
|
{phase === 'ended' && (
|
|
<div className="bg-white rounded-xl shadow p-6 text-center">
|
|
<h3 className="text-xl font-bold mb-4">🏁 Quiz beendet</h3>
|
|
</div>
|
|
)}
|
|
|
|
{leaderboardData.length > 0 && (
|
|
<div className="bg-white rounded-xl shadow p-6">
|
|
<h3 className="font-bold mb-2 text-center">🏆 Leaderboard</h3>
|
|
{leaderboardData.map((e) => (
|
|
<div key={e.name} className="flex justify-between py-2 px-3 bg-gray-50 rounded-lg mb-1">
|
|
<span>{e.rank}. {e.name}</span><span className="font-bold">{e.score}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{phase !== 'ended' && phase !== 'waiting' && (
|
|
<button onClick={end} className="w-full py-3 bg-red-500 text-white rounded-xl font-bold hover:bg-red-600">⏹ Session beenden</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ================================================================== */
|
|
/* 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 <LoginPage onLogin={setToken} />;
|
|
|
|
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 (
|
|
<div className="min-h-screen bg-gray-100">
|
|
<div className="bg-white shadow">
|
|
<div className="max-w-3xl mx-auto px-4 py-3 flex items-center justify-between">
|
|
<h1 className="text-xl font-bold">⚡ Quizalarm Admin</h1>
|
|
<a href="/" className="text-gray-400 hover:text-gray-600 text-sm">← Zur App</a>
|
|
</div>
|
|
<div className="max-w-3xl mx-auto px-4 flex gap-1 overflow-x-auto">
|
|
{tabs.map((t) => (
|
|
<button key={t.id} onClick={() => setTab(t.id)} className={`px-4 py-2 text-sm font-medium rounded-t-lg whitespace-nowrap ${tab === t.id ? 'bg-gray-100 text-indigo-600 border-b-2 border-indigo-600' : 'text-gray-500 hover:text-gray-700'}`}>{t.label}</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="max-w-3xl mx-auto p-4">
|
|
{tab === 'config' && <ConfigPage config={config} setConfig={setConfig} saveConfig={saveConfig} />}
|
|
{tab === 'live' && <LiveSetupPage config={config} token={token} onSessionCreated={handleSessionCreated} />}
|
|
{tab === 'live-control' && <LiveControlPage token={token} code={liveCode} ws={liveWs} />}
|
|
{tab === 'stats' && <StatsPage />}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
ReactDOM.createRoot(document.getElementById('root')).render(<AdminApp />); |