UI overhaul: fire-gradient theme and visual polish; logic: added answer shuffling and improved Baserow error handling

This commit is contained in:
2026-04-16 06:54:54 +02:00
parent 12d2d51dc3
commit e993eef125
10 changed files with 342 additions and 216 deletions
+80 -56
View File
@@ -33,12 +33,13 @@ function LoginPage({ onLogin }) {
} 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 />
<div className="min-h-screen flex items-center justify-center bg-stone-100">
<div className="bg-white rounded-2xl shadow-lg p-8 w-full max-w-sm border-t-4 border-red-600">
<div className="text-center text-4xl mb-2">🚨</div>
<h1 className="text-2xl font-bold mb-6 text-center">Quizalarm Admin</h1>
<input type="password" className="w-full p-3 border rounded-lg mb-3 text-center focus:border-red-500 outline-none" 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>
<button onClick={submit} className="w-full py-3 bg-red-600 text-white rounded-lg font-bold hover:bg-red-700">Anmelden</button>
</div>
</div>
);
@@ -52,6 +53,7 @@ function ConfigPage({ config, setConfig, saveConfig }) {
const [newSetName, setNewSetName] = useState('');
const [newSetTableId, setNewSetTableId] = useState('');
const [msg, setMsg] = useState('');
const [debugResult, setDebugResult] = useState(null);
const addSet = () => {
if (!newSetName || !newSetTableId) return;
@@ -63,8 +65,8 @@ function ConfigPage({ config, setConfig, saveConfig }) {
saveConfig(updated);
setNewSetName('');
setNewSetTableId('');
setMsg('Fragenset hinzugefügt');
setTimeout(() => setMsg(''), 2000);
setMsg('Fragenset hinzugefügt!');
setTimeout(() => setMsg(''), 3000);
};
const removeSet = (id) => {
@@ -79,10 +81,21 @@ function ConfigPage({ config, setConfig, saveConfig }) {
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 (
<div className="space-y-6">
<div className="bg-white rounded-xl shadow p-6">
<div className="bg-white rounded-xl shadow p-6 border-l-4 border-red-600">
<h2 className="text-xl font-bold mb-4">📋 Fragensets</h2>
{(config.sets || []).length === 0 && <p className="text-gray-500 mb-3">Noch keine Fragensets konfiguriert.</p>}
{(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>
@@ -90,19 +103,28 @@ function ConfigPage({ config, setConfig, saveConfig }) {
</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>
<input className="flex-1 min-w-0 p-2 border rounded-lg focus:border-red-500 outline-none" placeholder="Name" value={newSetName} onChange={(e) => setNewSetName(e.target.value)} />
<input className="w-32 p-2 border rounded-lg focus:border-red-500 outline-none" placeholder="Table ID" type="number" value={newSetTableId} onChange={(e) => setNewSetTableId(e.target.value)} />
<button onClick={addSet} className="px-4 py-2 bg-red-600 text-white rounded-lg font-bold hover:bg-red-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">
<div className="bg-white rounded-xl shadow p-6 border-l-4 border-amber-500">
<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" />
<input className="w-32 p-2 border rounded-lg focus:border-amber-500 outline-none" type="number" value={config.answersTableId || ''} onChange={(e) => updateAnswersTable(e.target.value)} placeholder="Table ID" />
</div>
</div>
<div className="bg-white rounded-xl shadow p-6 border-l-4 border-gray-400">
<h2 className="text-xl font-bold mb-4">🔧 Verbindungstest</h2>
<button onClick={testConnection} className="px-4 py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-800">Baserow-Verbindung testen</button>
{debugResult && (
<pre className="mt-3 bg-gray-100 p-3 rounded-lg text-sm overflow-x-auto max-h-64 whitespace-pre-wrap">{debugResult}</pre>
)}
</div>
</div>
);
}
@@ -114,15 +136,16 @@ function ConfigPage({ config, setConfig, saveConfig }) {
function StatsPage() {
const [data, setData] = useState(null);
const [filter, setFilter] = useState('');
const [err, setErr] = useState('');
useEffect(() => {
api.get('/api/admin/stats').then(setData).catch(console.error);
api.get('/api/admin/stats').then(setData).catch((e) => setErr(e.message));
}, []);
if (err) return <div className="bg-white rounded-xl shadow p-6"><p className="text-red-500">{err}</p></div>;
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';
@@ -130,7 +153,6 @@ function StatsPage() {
sessions[sid].answers.push(a);
});
// Group by user
const users = {};
data.answers.forEach((a) => {
const u = a['Nutzername'] || 'Unbekannt';
@@ -145,16 +167,16 @@ function StatsPage() {
<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="bg-red-50 rounded-lg p-4">
<div className="text-2xl font-bold text-red-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="bg-amber-50 rounded-lg p-4">
<div className="text-2xl font-bold text-amber-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="bg-orange-50 rounded-lg p-4">
<div className="text-2xl font-bold text-orange-600">{data.answers.length}</div>
<div className="text-sm text-gray-500">Antworten</div>
</div>
</div>
@@ -162,7 +184,7 @@ function StatsPage() {
<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)} />
<input className="w-full p-2 border rounded-lg mb-3 focus:border-red-500 outline-none" 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>
@@ -193,7 +215,7 @@ function StatsPage() {
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><span className={`text-xs px-2 py-0.5 rounded-full text-white ${s.modus === 'Live' ? 'bg-red-600' : 'bg-amber-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>
@@ -235,27 +257,27 @@ function LiveSetupPage({ config, token, onSessionCreated }) {
ws.close();
}
};
ws.onerror = () => { setError('WebSocket Fehler'); setLoading(false); };
ws.onerror = () => { setError('WebSocket Verbindungsfehler'); setLoading(false); };
};
return (
<div className="bg-white rounded-xl shadow p-6 max-w-md mx-auto">
<div className="bg-white rounded-xl shadow p-6 max-w-md mx-auto border-t-4 border-red-600">
<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)}>
<select className="w-full p-3 border rounded-lg focus:border-red-500 outline-none" 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" />
<input type="checkbox" checked={shuffle} onChange={() => setShuffle(!shuffle)} className="w-5 h-5 accent-red-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)}>
<select className="w-full p-3 border rounded-lg focus:border-red-500 outline-none" 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>
@@ -263,11 +285,11 @@ function LiveSetupPage({ config, token, onSessionCreated }) {
{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} />
<input type="number" className="w-full p-3 border rounded-lg focus:border-red-500 outline-none" 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">
{error && <p className="text-red-500 text-sm bg-red-50 p-2 rounded">{error}</p>}
<button onClick={create} disabled={loading} className="w-full py-3 bg-red-600 text-white rounded-lg font-bold hover:bg-red-700 disabled:opacity-50">
{loading ? 'Erstelle...' : 'Session erstellen'}
</button>
</div>
@@ -293,7 +315,7 @@ function LiveControlPage({ token, code, ws }) {
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 });
setSession({ code: msg.code, setName: msg.setName, questionCount: msg.questionCount, options: msg.options, players: [] });
break;
case 'admin-joined':
setSession(msg.session);
@@ -326,6 +348,9 @@ function LiveControlPage({ token, code, ws }) {
setLeaderboardData(msg.leaderboard);
setPhase('ended');
break;
case 'error':
console.error('Admin error:', msg.message);
break;
}
};
@@ -334,30 +359,27 @@ function LiveControlPage({ token, code, ws }) {
}, [ws]);
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 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}`;
const presentUrl = `${location.origin}/present?token=${encodeURIComponent(token)}#${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>
<div className="bg-white rounded-xl shadow p-6 text-center border-t-4 border-red-600">
<h2 className="text-lg font-bold mb-2">Session-Code</h2>
<div className="text-5xl font-mono font-extrabold tracking-widest text-red-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>
<a href={presentUrl} target="_blank" rel="noopener" className="inline-block mt-2 text-sm text-red-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>)}
{(session?.players || []).map((n) => <span key={n} className="px-3 py-1 bg-red-50 text-red-700 rounded-full text-sm font-medium">{n}</span>)}
</div>
{(!session?.players || session.players.length === 0) && <p className="text-gray-400 text-sm">Noch keine Teilnehmer...</p>}
</div>
{phase === 'waiting' && (
@@ -368,8 +390,8 @@ function LiveControlPage({ token, code, ws }) {
{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>
<p className="text-lg mb-3">Antworten: <strong className="text-red-600">{answerCount}</strong> / {session?.players?.length || 0}</p>
<button onClick={showResultAction} className="w-full py-3 bg-amber-500 text-white rounded-xl font-bold hover:bg-amber-600">Ergebnis zeigen </button>
</div>
)}
@@ -385,30 +407,32 @@ function LiveControlPage({ token, code, ws }) {
))}
</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>
<p className="text-center text-sm text-gray-400 mb-3">Richtig: <strong className="text-green-600">{result?.correctAnswer?.join(', ')}</strong></p>
<button onClick={next} className="w-full py-3 bg-red-600 text-white rounded-xl font-bold hover:bg-red-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>
<p className="text-gray-500">Ergebnisse wurden gespeichert.</p>
</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>
{leaderboardData.map((e, i) => (
<div key={e.name} className={`flex justify-between py-2 px-3 rounded-lg mb-1 ${i === 0 ? 'bg-amber-100 text-amber-800' : 'bg-gray-50'}`}>
<span>{i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${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>
<button onClick={end} className="w-full py-3 bg-gray-800 text-white rounded-xl font-bold hover:bg-gray-900"> Session beenden</button>
)}
</div>
);
@@ -452,15 +476,15 @@ function AdminApp() {
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="min-h-screen bg-stone-100">
<div className="bg-white shadow border-b-2 border-red-600">
<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>
<h1 className="text-xl font-bold flex items-center gap-2"><span>🚨</span> 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>
<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-stone-100 text-red-600 border-b-2 border-red-600' : 'text-gray-500 hover:text-gray-700'}`}>{t.label}</button>
))}
</div>
</div>