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
+3 -1
View File
@@ -5,13 +5,15 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Quizalarm — Admin</title>
<link rel="icon"
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🚨</text></svg>">
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-100 text-gray-900 min-h-screen">
<body class="bg-stone-100 text-gray-900 min-h-screen">
<div id="root"></div>
<script type="text/babel" src="/js/admin.js"></script>
</body>
+25 -13
View File
@@ -5,6 +5,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Quizalarm</title>
<link rel="icon"
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🚨</text></svg>">
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
@@ -12,38 +14,41 @@
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
background: #111111;
background-image: radial-gradient(ellipse at 20% 50%, rgba(220, 38, 38, 0.08) 0%, transparent 50%),
radial-gradient(ellipse at 80% 50%, rgba(245, 158, 11, 0.06) 0%, transparent 50%);
}
.answer-a {
background: #ef4444;
}
.answer-a:hover {
background: #dc2626;
}
.answer-b {
background: #3b82f6;
.answer-a:hover {
background: #b91c1c;
}
.answer-b:hover {
.answer-b {
background: #2563eb;
}
.answer-c {
background: #f59e0b;
.answer-b:hover {
background: #1d4ed8;
}
.answer-c:hover {
.answer-c {
background: #d97706;
}
.answer-c:hover {
background: #b45309;
}
.answer-d {
background: #22c55e;
background: #16a34a;
}
.answer-d:hover {
background: #16a34a;
background: #15803d;
}
.answer-a.selected,
@@ -61,10 +66,17 @@
opacity: 0.6;
cursor: not-allowed;
}
.fire-gradient {
background: linear-gradient(135deg, #dc2626, #f59e0b);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
</style>
</head>
<body class="bg-gray-900 text-white min-h-screen">
<body class="text-white min-h-screen">
<div id="root"></div>
<script type="text/babel" src="/js/app.js"></script>
</body>
+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>
+80 -60
View File
@@ -1,8 +1,5 @@
const { useState, useEffect, useRef, useCallback } = React;
/* ================================================================== */
/* API Helper */
/* ================================================================== */
const api = {
async get(url) {
const r = await fetch(url);
@@ -20,6 +17,15 @@ function wsUrl() {
return `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}`;
}
function shuffleArray(arr) {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
/* ================================================================== */
/* Shared Components */
/* ================================================================== */
@@ -27,8 +33,8 @@ function wsUrl() {
function ImageModal({ src, onClose }) {
if (!src) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-80 flex items-center justify-center z-50 p-4" onClick={onClose}>
<img src={src} className="max-w-full max-h-full object-contain rounded-lg" alt="Vergrößerung" />
<div className="fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-50 p-4" onClick={onClose}>
<img src={src} className="max-w-full max-h-full object-contain rounded-lg" alt="Vergroesserung" />
</div>
);
}
@@ -38,9 +44,11 @@ function Timer({ timeLeft, total }) {
const pct = Math.max(0, (timeLeft / total) * 100);
const color = pct > 50 ? 'bg-green-500' : pct > 25 ? 'bg-yellow-500' : 'bg-red-500';
return (
<div className="w-full bg-gray-700 rounded-full h-4 mb-4 overflow-hidden">
<div className="w-full mb-4">
<div className="w-full bg-gray-700 rounded-full h-4 overflow-hidden">
<div className={`${color} h-full rounded-full transition-all duration-200`} style={{ width: `${pct}%` }} />
<div className="text-center text-sm mt-1">{Math.ceil(timeLeft)}s</div>
</div>
<div className="text-center text-sm mt-1 text-gray-300">{Math.ceil(timeLeft)}s</div>
</div>
);
}
@@ -49,11 +57,11 @@ function Leaderboard({ entries, highlight, compact }) {
if (!entries || !entries.length) return null;
const show = compact ? entries.slice(0, 5) : entries;
return (
<div className="bg-gray-800 rounded-xl p-4 mt-4">
<div className="bg-gray-800 rounded-xl p-4 mt-4 w-full">
<h3 className="text-lg font-bold mb-2 text-center">🏆 Leaderboard</h3>
{show.map((e) => (
<div key={e.name} className={`flex justify-between py-2 px-3 rounded-lg mb-1 ${e.name === highlight ? 'bg-indigo-600' : 'bg-gray-700'}`}>
<span className="font-medium">{e.rank}. {e.name}</span>
{show.map((e, i) => (
<div key={e.name} className={`flex justify-between py-2 px-3 rounded-lg mb-1 ${e.name === highlight ? 'bg-red-700' : i === 0 ? 'bg-amber-700' : 'bg-gray-700'}`}>
<span className="font-medium">{i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${e.rank}.`} {e.name}</span>
<span className="font-bold">{e.score}</span>
</div>
))}
@@ -71,7 +79,7 @@ function AnswerGrid({ question, selected, onToggle, disabled }) {
return (
<input
type="text"
className="w-full p-4 rounded-xl bg-gray-700 text-white text-xl text-center border-2 border-gray-600 focus:border-indigo-500 outline-none"
className="w-full p-4 rounded-xl bg-gray-700 text-white text-xl text-center border-2 border-gray-600 focus:border-red-500 outline-none"
placeholder="Deine Antwort..."
value={selected[0] || ''}
onChange={(e) => onToggle([e.target.value])}
@@ -100,7 +108,7 @@ function AnswerGrid({ question, selected, onToggle, disabled }) {
}
}}
>
<span className="opacity-70 mr-2">{a.key}</span> {a.text}
{a.text}
</button>
);
})}
@@ -115,11 +123,12 @@ function AnswerGrid({ question, selected, onToggle, disabled }) {
function HomePage({ navigate }) {
return (
<div className="min-h-screen flex flex-col items-center justify-center p-6">
<h1 className="text-5xl font-extrabold mb-2 text-transparent bg-clip-text bg-gradient-to-r from-indigo-400 to-pink-500"> Quizalarm</h1>
<div className="text-6xl mb-2">🚨</div>
<h1 className="text-5xl font-extrabold mb-2 fire-gradient">Quizalarm</h1>
<p className="text-gray-400 mb-10 text-lg">Lernen. Quizzen. Wissen.</p>
<div className="w-full max-w-sm space-y-4">
<button onClick={() => navigate('join')} className="w-full py-4 bg-indigo-600 hover:bg-indigo-700 rounded-xl text-xl font-bold transition-all">🎮 Live-Quiz beitreten</button>
<button onClick={() => navigate('solo-select')} className="w-full py-4 bg-emerald-600 hover:bg-emerald-700 rounded-xl text-xl font-bold transition-all">📚 Solo-Lernen</button>
<button onClick={() => navigate('join')} className="w-full py-4 bg-red-600 hover:bg-red-700 rounded-xl text-xl font-bold transition-all shadow-lg shadow-red-900/30">🎮 Live-Quiz beitreten</button>
<button onClick={() => navigate('solo-select')} className="w-full py-4 bg-amber-600 hover:bg-amber-700 rounded-xl text-xl font-bold transition-all shadow-lg shadow-amber-900/30">📚 Solo-Lernen</button>
<button onClick={() => navigate('my-results')} className="w-full py-4 bg-gray-700 hover:bg-gray-600 rounded-xl text-xl font-bold transition-all">📊 Meine Ergebnisse</button>
</div>
<a href="/admin" className="mt-8 text-gray-500 hover:text-gray-300 text-sm">Admin-Bereich </a>
@@ -145,10 +154,10 @@ function JoinPage({ navigate }) {
<div className="min-h-screen flex flex-col items-center justify-center p-6">
<h2 className="text-3xl font-bold mb-6">🎮 Live-Quiz beitreten</h2>
<div className="w-full max-w-sm space-y-4">
<input className="w-full p-4 rounded-xl bg-gray-800 text-white text-center text-2xl tracking-widest border-2 border-gray-700 focus:border-indigo-500 outline-none uppercase" placeholder="CODE" value={code} onChange={(e) => setCode(e.target.value)} maxLength={6} />
<input className="w-full p-4 rounded-xl bg-gray-800 text-white text-center text-xl border-2 border-gray-700 focus:border-indigo-500 outline-none" placeholder="Dein Name" value={name} onChange={(e) => setName(e.target.value)} maxLength={20} onKeyDown={(e) => e.key === 'Enter' && handleJoin()} />
<input className="w-full p-4 rounded-xl bg-gray-800 text-white text-center text-2xl tracking-widest border-2 border-gray-700 focus:border-red-500 outline-none uppercase" placeholder="CODE" value={code} onChange={(e) => setCode(e.target.value)} maxLength={6} />
<input className="w-full p-4 rounded-xl bg-gray-800 text-white text-center text-xl border-2 border-gray-700 focus:border-red-500 outline-none" placeholder="Dein Name" value={name} onChange={(e) => setName(e.target.value)} maxLength={20} onKeyDown={(e) => e.key === 'Enter' && handleJoin()} />
{error && <p className="text-red-400 text-center">{error}</p>}
<button onClick={handleJoin} className="w-full py-4 bg-indigo-600 hover:bg-indigo-700 rounded-xl text-xl font-bold">Beitreten</button>
<button onClick={handleJoin} className="w-full py-4 bg-red-600 hover:bg-red-700 rounded-xl text-xl font-bold shadow-lg shadow-red-900/30">Beitreten</button>
<button onClick={() => navigate('home')} className="w-full py-3 text-gray-400 hover:text-white"> Zurück</button>
</div>
</div>
@@ -175,6 +184,8 @@ function LivePlayPage({ params, navigate }) {
const [enlargedImg, setEnlargedImg] = useState(null);
const wsRef = useRef(null);
const timerRef = useRef(null);
const phaseRef = useRef(phase);
phaseRef.current = phase;
useEffect(() => {
const ws = new WebSocket(wsUrl());
@@ -236,13 +247,12 @@ function LivePlayPage({ params, navigate }) {
};
ws.onclose = () => {
if (phase !== 'final' && phase !== 'error') setPhase('disconnected');
if (phaseRef.current !== 'final' && phaseRef.current !== 'error') setPhase('disconnected');
};
return () => { clearInterval(timerRef.current); ws.close(); };
}, []);
// Timer countdown
useEffect(() => {
clearInterval(timerRef.current);
if (phase === 'question' && timeLimit > 0 && !answered) {
@@ -276,7 +286,7 @@ function LivePlayPage({ params, navigate }) {
</div>
);
if (phase === 'connecting') return <div className="min-h-screen flex items-center justify-center"><p className="text-xl animate-pulse">Verbinde...</p></div>;
if (phase === 'connecting') return <div className="min-h-screen flex items-center justify-center"><p className="text-xl animate-pulse text-amber-400">Verbinde...</p></div>;
if (phase === 'waiting') return (
<div className="min-h-screen flex flex-col items-center justify-center p-6">
@@ -285,7 +295,7 @@ function LivePlayPage({ params, navigate }) {
<p className="text-lg mb-4">{session?.playerCount || 0} Teilnehmer</p>
<div className="flex flex-wrap gap-2 justify-center max-w-md">
{(session?.players || []).map((n) => (
<span key={n} className={`px-3 py-1 rounded-full text-sm ${n === params.name ? 'bg-indigo-600' : 'bg-gray-700'}`}>{n}</span>
<span key={n} className={`px-3 py-1 rounded-full text-sm ${n === params.name ? 'bg-red-600' : 'bg-gray-700'}`}>{n}</span>
))}
</div>
<p className="mt-6 text-gray-500 animate-pulse">Warte auf den Start...</p>
@@ -300,9 +310,9 @@ function LivePlayPage({ params, navigate }) {
{question?.bild && <img src={question.bild} className="max-h-48 mx-auto rounded-lg mb-4 cursor-pointer" alt="" onClick={() => setEnlargedImg(question.bild)} />}
<AnswerGrid question={question} selected={selected} onToggle={setSelected} disabled={answered} />
{!answered && (
<button onClick={submitAnswer} disabled={selected.length === 0} className="mt-4 py-3 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-40 rounded-xl text-lg font-bold w-full">Antwort senden</button>
<button onClick={submitAnswer} disabled={selected.length === 0} className="mt-4 py-3 bg-red-600 hover:bg-red-700 disabled:opacity-40 rounded-xl text-lg font-bold w-full shadow-lg shadow-red-900/30">Antwort senden</button>
)}
{answered && <p className="mt-4 text-center text-green-400 animate-pulse"> Antwort gesendet warte auf Ergebnis...</p>}
{answered && <p className="mt-4 text-center text-amber-400 animate-pulse"> Antwort gesendet warte auf Ergebnis...</p>}
<ImageModal src={enlargedImg} onClose={() => setEnlargedImg(null)} />
</div>
);
@@ -315,7 +325,7 @@ function LivePlayPage({ params, navigate }) {
<p className="text-2xl font-bold mb-1">+{result?.yourResult?.points || 0} Punkte</p>
<p className="text-gray-400 mb-4">Gesamt: {result?.yourResult?.totalScore || 0}</p>
{result?.correctAnswer && (
<p className="bg-gray-800 py-2 px-4 rounded-lg mb-4">Richtige Antwort: <strong>{result.correctAnswer.join(', ')}</strong></p>
<p className="bg-gray-800 py-2 px-4 rounded-lg mb-4">Richtige Antwort: <strong className="text-amber-400">{result.correctAnswer.join(', ')}</strong></p>
)}
<Leaderboard entries={leaderboardData} highlight={params.name} compact />
<p className="mt-4 text-gray-500 animate-pulse">Warte auf nächste Frage...</p>
@@ -340,7 +350,7 @@ function LivePlayPage({ params, navigate }) {
function SoloSelectPage({ navigate }) {
const [sets, setSets] = useState([]);
const [name, setName] = useState('');
const [shuffle, setShuffle] = useState(true);
const [doShuffle, setDoShuffle] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
@@ -349,23 +359,23 @@ function SoloSelectPage({ navigate }) {
const start = (setId, setName2) => {
if (!name.trim()) return setError('Bitte Name eingeben');
navigate('solo-quiz', { setId, setName: setName2, name: name.trim(), shuffle });
navigate('solo-quiz', { setId, setName: setName2, name: name.trim(), shuffle: doShuffle });
};
return (
<div className="min-h-screen flex flex-col items-center p-6 pt-12">
<h2 className="text-3xl font-bold mb-6">📚 Solo-Lernen</h2>
<div className="w-full max-w-md space-y-4">
<input className="w-full p-4 rounded-xl bg-gray-800 text-white text-center text-xl border-2 border-gray-700 focus:border-emerald-500 outline-none" placeholder="Dein Name" value={name} onChange={(e) => setName(e.target.value)} />
<input className="w-full p-4 rounded-xl bg-gray-800 text-white text-center text-xl border-2 border-gray-700 focus:border-amber-500 outline-none" placeholder="Dein Name" value={name} onChange={(e) => setName(e.target.value)} />
<label className="flex items-center gap-3 text-gray-300 cursor-pointer">
<input type="checkbox" checked={shuffle} onChange={() => setShuffle(!shuffle)} className="w-5 h-5 accent-emerald-500" />
<input type="checkbox" checked={doShuffle} onChange={() => setDoShuffle(!doShuffle)} className="w-5 h-5 accent-amber-500" />
Fragen mischen
</label>
{error && <p className="text-red-400">{error}</p>}
<h3 className="text-lg font-semibold mt-4">Fragenset wählen:</h3>
{sets.length === 0 && <p className="text-gray-500">Keine Fragensets konfiguriert.</p>}
{sets.length === 0 && <p className="text-gray-500">Keine Fragensets konfiguriert. Bitte im Admin-Bereich einrichten.</p>}
{sets.map((s) => (
<button key={s.id} onClick={() => start(s.id, s.name)} className="w-full py-4 bg-emerald-700 hover:bg-emerald-600 rounded-xl text-lg font-bold transition-all">{s.name}</button>
<button key={s.id} onClick={() => start(s.id, s.name)} className="w-full py-4 bg-amber-600 hover:bg-amber-700 rounded-xl text-lg font-bold transition-all shadow-lg shadow-amber-900/30">{s.name}</button>
))}
<button onClick={() => navigate('home')} className="w-full py-3 text-gray-400 hover:text-white"> Zurück</button>
</div>
@@ -387,6 +397,7 @@ function SoloQuizPage({ params, navigate }) {
const [results, setResults] = useState([]);
const [error, setError] = useState('');
const [enlargedImg, setEnlargedImg] = useState(null);
const [shuffledAnswers, setShuffledAnswers] = useState([]);
const sessionId = useRef('SOLO-' + Date.now().toString(36).toUpperCase());
useEffect(() => {
@@ -401,27 +412,37 @@ function SoloQuizPage({ params, navigate }) {
}).catch((e) => setError(e.message));
}, []);
if (error) return <div className="min-h-screen flex items-center justify-center text-red-400 text-xl">{error}</div>;
if (!questions.length) return <div className="min-h-screen flex items-center justify-center animate-pulse text-xl">Lade Fragen...</div>;
// Shuffle answers when question changes
useEffect(() => {
if (questions.length === 0 || index >= questions.length) return;
const q = questions[index];
const answers = [];
['A', 'B', 'C', 'D'].forEach((k) => {
if (q[`Antwort ${k}`]) answers.push({ key: k, text: q[`Antwort ${k}`] });
});
setShuffledAnswers(shuffleArray(answers));
}, [index, questions]);
if (error) return <div className="min-h-screen flex items-center justify-center text-red-400 text-xl p-6 text-center">{error}</div>;
if (!questions.length) return <div className="min-h-screen flex items-center justify-center animate-pulse text-xl text-amber-400">Lade Fragen...</div>;
const q = questions[index];
const typ = (q['Typ'] || 'MC').toLowerCase();
const sanitized = {
frage: q['Frage'],
typ: q['Typ'] || 'MC',
bild: q['Bild'] && q['Bild'].length ? q['Bild'][0].url : null,
antworten: [],
frage: q['Frage'] || q['frage'] || '(Kein Fragetext)',
typ: q['Typ'] || q['typ'] || 'MC',
bild: q['Bild'] && Array.isArray(q['Bild']) && q['Bild'].length ? q['Bild'][0].url : null,
antworten: shuffledAnswers,
};
['A', 'B', 'C', 'D'].forEach((k) => { if (q[`Antwort ${k}`]) sanitized.antworten.push({ key: k, text: q[`Antwort ${k}`] }); });
const normalizeText = (t) => (t || '').toLowerCase().replace(/[äÄ]/g, 'ae').replace(/[öÖ]/g, 'oe').replace(/[üÜ]/g, 'ue').replace(/ß/g, 'ss').replace(/[^a-z0-9]/g, '');
const checkAnswer = () => {
const rawCorrect = (q['Richtige Antwort'] || '').trim();
let correct, ok;
if (typ === 'freitext') {
correct = [rawCorrect];
// Normalize comparison
const normalize = (t) => (t || '').toLowerCase().replace(/[^a-zäöüß0-9]/g, '').replace(/[ä]/g, 'ae').replace(/[ö]/g, 'oe').replace(/[ü]/g, 'ue').replace(/ß/g, 'ss');
ok = normalize(selected[0]) === normalize(rawCorrect);
ok = normalizeText(selected[0]) === normalizeText(rawCorrect);
} else {
correct = rawCorrect.split(',').map((s) => s.trim().toUpperCase()).filter(Boolean);
const correctSet = new Set(correct);
@@ -433,7 +454,7 @@ function SoloQuizPage({ params, navigate }) {
setIsCorrect(ok);
setShowFeedback(true);
results.push({
const newResults = [...results, {
Nutzername: params.name,
Frage_ID: q.id,
Fragenset: params.setName,
@@ -443,13 +464,13 @@ function SoloQuizPage({ params, navigate }) {
Session_ID: sessionId.current,
Modus: 'Solo',
Zeitstempel: new Date().toISOString(),
});
}];
setResults(newResults);
};
const nextQuestion = () => {
if (index + 1 >= questions.length) {
// Save all results, then navigate
api.post('/api/results', { answers: results }).catch(console.error);
api.post('/api/results', { answers: results }).catch((e) => console.error('Save error:', e));
navigate('solo-result', { results, setName: params.setName, name: params.name, questions });
return;
}
@@ -462,21 +483,21 @@ function SoloQuizPage({ params, navigate }) {
<div className="min-h-screen flex flex-col p-4 max-w-2xl mx-auto">
<div className="text-center text-sm text-gray-400 mb-2">Frage {index + 1} / {questions.length}</div>
<div className="w-full bg-gray-700 rounded-full h-2 mb-4">
<div className="bg-emerald-500 h-full rounded-full transition-all" style={{ width: `${((index + 1) / questions.length) * 100}%` }} />
<div className="bg-amber-500 h-full rounded-full transition-all" style={{ width: `${((index + 1) / questions.length) * 100}%` }} />
</div>
<h2 className="text-xl font-bold mb-4 text-center">{sanitized.frage}</h2>
{sanitized.bild && <img src={sanitized.bild} className="max-h-48 mx-auto rounded-lg mb-4 cursor-pointer" alt="" onClick={() => setEnlargedImg(sanitized.bild)} />}
<AnswerGrid question={sanitized} selected={selected} onToggle={setSelected} disabled={showFeedback} />
{!showFeedback && (
<button onClick={checkAnswer} disabled={selected.length === 0} className="mt-4 py-3 bg-emerald-600 hover:bg-emerald-700 disabled:opacity-40 rounded-xl text-lg font-bold w-full">Prüfen</button>
<button onClick={checkAnswer} disabled={selected.length === 0} className="mt-4 py-3 bg-amber-600 hover:bg-amber-700 disabled:opacity-40 rounded-xl text-lg font-bold w-full shadow-lg shadow-amber-900/30">Prüfen</button>
)}
{showFeedback && (
<div className="mt-4 text-center space-y-3">
<div className={`text-2xl font-bold ${isCorrect ? 'text-green-400' : 'text-red-400'}`}>
{isCorrect ? '✅ Richtig!' : '❌ Falsch'}
</div>
{!isCorrect && <p className="bg-gray-800 py-2 px-4 rounded-lg inline-block">Richtig: <strong>{correctAnswer.join(', ')}</strong></p>}
<button onClick={nextQuestion} className="w-full py-3 bg-indigo-600 hover:bg-indigo-700 rounded-xl text-lg font-bold">
{!isCorrect && <p className="bg-gray-800 py-2 px-4 rounded-lg inline-block">Richtig: <strong className="text-amber-400">{correctAnswer.join(', ')}</strong></p>}
<button onClick={nextQuestion} className="w-full py-3 bg-red-600 hover:bg-red-700 rounded-xl text-lg font-bold shadow-lg shadow-red-900/30">
{index + 1 >= questions.length ? 'Ergebnis anzeigen' : 'Nächste Frage →'}
</button>
</div>
@@ -496,14 +517,14 @@ function SoloResultPage({ params, navigate }) {
const correct = results.filter((r) => r.Richtig).length;
const pct = total > 0 ? Math.round((correct / total) * 100) : 0;
// Group by category
const byCategory = {};
results.forEach((r, i) => {
const q = questions[i];
const cat = q?.['Kategorie'] || 'Ohne Kategorie';
if (!byCategory[cat]) byCategory[cat] = { total: 0, correct: 0 };
byCategory[cat].total++;
if (r.Richtig) byCategory[cat].correct++;
const cat = (q && (q['Kategorie'] || q['kategorie'])) || 'Ohne Kategorie';
const catName = typeof cat === 'object' && cat.value ? cat.value : cat;
if (!byCategory[catName]) byCategory[catName] = { total: 0, correct: 0 };
byCategory[catName].total++;
if (r.Richtig) byCategory[catName].correct++;
});
return (
@@ -524,7 +545,7 @@ function SoloResultPage({ params, navigate }) {
</div>
)}
<div className="w-full max-w-md space-y-3">
<button onClick={() => navigate('solo-select')} className="w-full py-3 bg-emerald-600 hover:bg-emerald-700 rounded-xl font-bold">Nochmal spielen</button>
<button onClick={() => navigate('solo-select')} className="w-full py-3 bg-amber-600 hover:bg-amber-700 rounded-xl font-bold shadow-lg shadow-amber-900/30">Nochmal spielen</button>
<button onClick={() => navigate('home')} className="w-full py-3 bg-gray-700 hover:bg-gray-600 rounded-xl font-bold">Zur Startseite</button>
</div>
</div>
@@ -548,7 +569,6 @@ function MyResultsPage({ navigate }) {
.catch((e) => setError(e.message));
};
// Group by session
const sessions = {};
if (results) {
results.forEach((r) => {
@@ -562,8 +582,8 @@ function MyResultsPage({ navigate }) {
<div className="min-h-screen flex flex-col items-center p-6 pt-12">
<h2 className="text-3xl font-bold mb-6">📊 Meine Ergebnisse</h2>
<div className="w-full max-w-md space-y-3">
<input className="w-full p-4 rounded-xl bg-gray-800 text-white text-center border-2 border-gray-700 focus:border-indigo-500 outline-none" placeholder="Dein Name" value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && search()} />
<button onClick={search} className="w-full py-3 bg-indigo-600 hover:bg-indigo-700 rounded-xl font-bold">Suchen</button>
<input className="w-full p-4 rounded-xl bg-gray-800 text-white text-center border-2 border-gray-700 focus:border-red-500 outline-none" placeholder="Dein Name" value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && search()} />
<button onClick={search} className="w-full py-3 bg-red-600 hover:bg-red-700 rounded-xl font-bold shadow-lg shadow-red-900/30">Suchen</button>
{error && <p className="text-red-400 text-center">{error}</p>}
</div>
{results && results.length === 0 && <p className="mt-4 text-gray-500">Keine Ergebnisse gefunden.</p>}
@@ -577,7 +597,7 @@ function MyResultsPage({ navigate }) {
<div key={sid} className="bg-gray-800 rounded-xl p-4">
<div className="flex justify-between items-start mb-2">
<div>
<span className={`text-xs px-2 py-0.5 rounded-full ${s.modus === 'Live' ? 'bg-indigo-600' : 'bg-emerald-600'}`}>{s.modus}</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>
<span className="ml-2 font-semibold">{s.fragenset}</span>
</div>
<span className="text-sm text-gray-400">{s.date ? new Date(s.date).toLocaleDateString('de') : ''}</span>
+29 -25
View File
@@ -17,12 +17,14 @@ function PresentApp() {
const [answerCount, setAnswerCount] = useState(0);
const [result, setResult] = useState(null);
const [leaderboard, setLeaderboard] = useState([]);
const [error, setError] = useState('');
const timerRef = useRef(null);
const wsRef = useRef(null);
useEffect(() => {
const code = location.hash.replace('#', '');
const token = prompt_alternative();
const params = new URLSearchParams(location.search);
const token = params.get('token');
if (!code || !token) {
setPhase('need-config');
@@ -79,15 +81,18 @@ function PresentApp() {
clearInterval(timerRef.current);
break;
case 'error':
setError(msg.message);
setPhase('error');
break;
}
};
ws.onerror = () => setPhase('error');
ws.onclose = () => { if (phase !== 'final') setPhase('error'); };
return () => ws.close();
}, []);
// Timer
useEffect(() => {
clearInterval(timerRef.current);
if (phase === 'question' && timeLimit > 0) {
@@ -98,32 +103,31 @@ function PresentApp() {
}
}, [phase, timeLimit]);
// Simple token retrieval (read from URL param or sessionStorage)
function prompt_alternative() {
const params = new URLSearchParams(location.search);
let t = params.get('token');
if (!t) {
try { t = sessionStorage.getItem('qa_token'); } catch (e) { }
}
return t;
}
if (phase === 'need-config') {
return (
<div className="min-h-screen flex flex-col items-center justify-center p-8">
<h1 className="text-3xl font-bold mb-4">📺 Präsentation</h1>
<p className="text-gray-400 mb-4">Öffne diesen Link aus dem Admin-Panel oder füge <code>?token=PASSWORT#CODE</code> zur URL hinzu.</p>
<div className="text-6xl mb-4">🚨</div>
<h1 className="text-3xl font-bold mb-4 fire-gradient">Quizalarm Präsentation</h1>
<p className="text-gray-400 mb-4 text-center">Öffne diesen Link aus dem Admin-Panel.<br />Die URL muss <code className="bg-gray-800 px-2 py-1 rounded">?token=PASSWORT#CODE</code> enthalten.</p>
</div>
);
}
if (phase === 'error') return <div className="min-h-screen flex items-center justify-center text-red-400 text-3xl">Verbindungsfehler</div>;
if (phase === 'error') return (
<div className="min-h-screen flex flex-col items-center justify-center text-red-400 text-3xl">
<p> Verbindungsfehler</p>
{error && <p className="text-lg mt-2">{error}</p>}
</div>
);
if (phase === 'connect') return <div className="min-h-screen flex items-center justify-center animate-pulse text-2xl text-amber-400">Verbinde...</div>;
if (phase === 'waiting') return (
<div className="min-h-screen flex flex-col items-center justify-center p-8">
<h1 className="text-4xl font-extrabold mb-4 text-transparent bg-clip-text bg-gradient-to-r from-indigo-400 to-pink-500"> Quizalarm</h1>
<div className="text-6xl mb-4">🚨</div>
<h1 className="text-4xl font-extrabold mb-4 fire-gradient">Quizalarm</h1>
<p className="text-gray-400 text-xl mb-6">{session?.setName}</p>
<div className="text-8xl font-mono font-extrabold tracking-widest text-indigo-400 mb-8">{session?.code}</div>
<div className="text-8xl font-mono font-extrabold tracking-widest text-red-500 mb-8">{session?.code}</div>
<p className="text-2xl mb-4">{session?.players?.length || 0} Teilnehmer</p>
<div className="flex flex-wrap gap-3 justify-center max-w-2xl">
{(session?.players || []).map((n) => (
@@ -141,7 +145,7 @@ function PresentApp() {
<div className="flex justify-between items-center mb-4">
<span className="text-gray-400 text-xl">Frage {qIndex + 1} / {qTotal}</span>
<span className="text-xl">{answerCount} / {session?.players?.length || 0} 💬</span>
{timeLimit > 0 && <span className="text-3xl font-bold">{Math.ceil(timeLeft)}s</span>}
{timeLimit > 0 && <span className="text-3xl font-bold text-amber-400">{Math.ceil(timeLeft)}s</span>}
</div>
{timeLimit > 0 && (
<div className="w-full bg-gray-700 rounded-full h-3 mb-6 overflow-hidden">
@@ -154,7 +158,7 @@ function PresentApp() {
<div className="grid grid-cols-2 gap-4 flex-1 max-h-96">
{(question?.antworten || []).map((a) => (
<div key={a.key} className={`${COLORS[a.key]} rounded-2xl flex items-center justify-center p-6 text-2xl font-bold`}>
<span className="opacity-60 mr-3 text-3xl">{a.key}</span> {a.text}
{a.text}
</div>
))}
</div>
@@ -171,7 +175,7 @@ function PresentApp() {
return (
<div className="min-h-screen flex flex-col p-8">
<h2 className="text-3xl font-bold text-center mb-2">Ergebnis</h2>
<p className="text-center text-xl text-green-400 mb-6">Richtig: {result?.correctAnswer?.join(', ')}</p>
<p className="text-center text-xl text-amber-400 mb-6">Richtig: {result?.correctAnswer?.join(', ')}</p>
<div className="flex gap-4 justify-center items-end mb-8 h-48">
{Object.entries(result?.stats || {}).map(([k, v]) => (
<div key={k} className="flex flex-col items-center flex-1 max-w-32">
@@ -185,8 +189,8 @@ function PresentApp() {
<h3 className="text-2xl font-bold text-center mb-4">🏆 Leaderboard</h3>
<div className="max-w-xl mx-auto">
{leaderboard.slice(0, 8).map((e, i) => (
<div key={e.name} className={`flex justify-between py-3 px-5 rounded-xl mb-2 text-xl ${i === 0 ? 'bg-yellow-600' : i === 1 ? 'bg-gray-500' : i === 2 ? 'bg-amber-700' : 'bg-gray-800'}`}>
<span className="font-bold">{e.rank}. {e.name}</span>
<div key={e.name} className={`flex justify-between py-3 px-5 rounded-xl mb-2 text-xl ${i === 0 ? 'bg-amber-600' : i === 1 ? 'bg-gray-500' : i === 2 ? 'bg-amber-800' : 'bg-gray-800'}`}>
<span className="font-bold">{i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${e.rank}.`} {e.name}</span>
<span className="font-extrabold">{e.score}</span>
</div>
))}
@@ -198,10 +202,10 @@ function PresentApp() {
if (phase === 'final') return (
<div className="min-h-screen flex flex-col items-center justify-center p-8">
<h2 className="text-5xl font-extrabold mb-8">🏁 Quiz beendet!</h2>
<h2 className="text-5xl font-extrabold mb-8 fire-gradient">Quiz beendet!</h2>
<div className="w-full max-w-xl">
{leaderboard.map((e, i) => (
<div key={e.name} className={`flex justify-between py-4 px-6 rounded-xl mb-2 text-2xl ${i === 0 ? 'bg-yellow-600' : i === 1 ? 'bg-gray-500' : i === 2 ? 'bg-amber-700' : 'bg-gray-800'}`}>
<div key={e.name} className={`flex justify-between py-4 px-6 rounded-xl mb-2 text-2xl ${i === 0 ? 'bg-amber-600' : i === 1 ? 'bg-gray-500' : i === 2 ? 'bg-amber-800' : 'bg-gray-800'}`}>
<span className="font-bold">{i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${e.rank}.`} {e.name}</span>
<span className="font-extrabold">{e.score}</span>
</div>
@@ -210,7 +214,7 @@ function PresentApp() {
</div>
);
return <div className="min-h-screen flex items-center justify-center animate-pulse text-2xl">Verbinde...</div>;
return <div className="min-h-screen flex items-center justify-center animate-pulse text-2xl text-amber-400">Verbinde...</div>;
}
ReactDOM.createRoot(document.getElementById('root')).render(<PresentApp />);
+17 -5
View File
@@ -5,6 +5,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Quizalarm — Präsentation</title>
<link rel="icon"
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🚨</text></svg>">
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
@@ -12,27 +14,37 @@
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
background: #111111;
background-image: radial-gradient(ellipse at 20% 50%, rgba(220, 38, 38, 0.08) 0%, transparent 50%),
radial-gradient(ellipse at 80% 50%, rgba(245, 158, 11, 0.06) 0%, transparent 50%);
}
.answer-a {
background: #ef4444;
background: #dc2626;
}
.answer-b {
background: #3b82f6;
background: #2563eb;
}
.answer-c {
background: #f59e0b;
background: #d97706;
}
.answer-d {
background: #22c55e;
background: #16a34a;
}
.fire-gradient {
background: linear-gradient(135deg, #dc2626, #f59e0b);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
</style>
</head>
<body class="bg-gray-900 text-white min-h-screen">
<body class="text-white min-h-screen">
<div id="root"></div>
<script type="text/babel" src="/js/present.js"></script>
</body>
+16 -8
View File
@@ -3,17 +3,24 @@ const { getEnvConfig } = require('./config');
async function baserowFetch(endpoint, options = {}) {
const { baserowUrl, baserowToken } = getEnvConfig();
const url = `${baserowUrl}/api${endpoint}`;
const res = await fetch(url, {
...options,
headers: {
console.log(`[baserow] ${options.method || 'GET'} ${url}`);
const headers = {
Authorization: `Token ${baserowToken}`,
'Content-Type': 'application/json',
...(options.headers || {}),
},
});
};
// Only set Content-Type for requests with body
if (options.body) {
headers['Content-Type'] = 'application/json';
}
const res = await fetch(url, { ...options, headers });
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`Baserow ${res.status}: ${body.substring(0, 200)}`);
const msg = `Baserow ${res.status} ${res.statusText}: ${body.substring(0, 300)}`;
console.error(`[baserow] ERROR: ${msg}`);
throw new Error(msg);
}
return res.json();
}
@@ -27,6 +34,7 @@ async function listRows(tableId, filters = {}) {
url += `&filter__${encodeURIComponent(field)}__equal=${encodeURIComponent(value)}`;
}
const data = await baserowFetch(url);
console.log(`[baserow] Table ${tableId} page ${page}: ${(data.results || []).length} rows`);
all = all.concat(data.results || []);
if (!data.next) break;
page++;
@@ -36,7 +44,7 @@ async function listRows(tableId, filters = {}) {
async function batchCreateRows(tableId, rows) {
if (!rows.length) return;
// Baserow limit: 200 per batch
console.log(`[baserow] Batch create ${rows.length} rows in table ${tableId}`);
for (let i = 0; i < rows.length; i += 200) {
const chunk = rows.slice(i, i + 200);
await baserowFetch(`/database/rows/table/${tableId}/batch/?user_field_names=true`, {
+42 -5
View File
@@ -4,6 +4,7 @@ const path = require('path');
const { getEnvConfig, readConfig, writeConfig } = require('./config');
const { listRows, batchCreateRows } = require('./baserow');
const { setupWebSocket, getSessions } = require('./live-session');
const { sanitizeQuestion, shuffleArray } = require('./quiz-logic');
const app = express();
const server = http.createServer(app);
@@ -11,7 +12,6 @@ const server = http.createServer(app);
app.use(express.json({ limit: '5mb' }));
app.use(express.static(path.join(__dirname, '..', 'public')));
/* ---- Auth middleware ---- */
function requireAdmin(req, res, next) {
if (req.headers['x-admin-token'] !== getEnvConfig().adminPassword) {
return res.status(401).json({ error: 'Nicht autorisiert' });
@@ -25,17 +25,24 @@ function requireAdmin(req, res, next) {
app.get('/api/sets', (req, res) => {
const config = readConfig();
res.json(config.sets.map((s) => ({ id: s.id, name: s.name })));
console.log(`[api] GET /api/sets — ${(config.sets || []).length} sets`);
res.json((config.sets || []).map((s) => ({ id: s.id, name: s.name })));
});
app.get('/api/sets/:id/questions', async (req, res) => {
try {
const config = readConfig();
const set = config.sets.find((s) => s.id === req.params.id);
if (!set) return res.status(404).json({ error: 'Nicht gefunden' });
if (!set) return res.status(404).json({ error: 'Fragenset nicht gefunden' });
console.log(`[api] Loading questions for set "${set.name}" (table ${set.tableId})`);
const rows = await listRows(set.tableId);
console.log(`[api] Loaded ${rows.length} questions`);
if (rows.length > 0) {
console.log(`[api] Fields: ${Object.keys(rows[0]).join(', ')}`);
}
res.json(rows);
} catch (e) {
console.error(`[api] Error loading questions:`, e.message);
res.status(500).json({ error: e.message });
}
});
@@ -44,9 +51,12 @@ app.post('/api/results', async (req, res) => {
try {
const config = readConfig();
if (!config.answersTableId) return res.status(400).json({ error: 'Antworten-Tabelle nicht konfiguriert' });
await batchCreateRows(config.answersTableId, req.body.answers);
const answers = req.body.answers || [];
console.log(`[api] Saving ${answers.length} results`);
await batchCreateRows(config.answersTableId, answers);
res.json({ success: true });
} catch (e) {
console.error(`[api] Error saving results:`, e.message);
res.status(500).json({ error: e.message });
}
});
@@ -58,6 +68,7 @@ app.get('/api/results/:name', async (req, res) => {
const rows = await listRows(config.answersTableId, { Nutzername: req.params.name });
res.json(rows);
} catch (e) {
console.error(`[api] Error loading results:`, e.message);
res.status(500).json({ error: e.message });
}
});
@@ -79,6 +90,7 @@ app.get('/api/admin/config', requireAdmin, (req, res) => res.json(readConfig()))
app.post('/api/admin/config', requireAdmin, (req, res) => {
try {
writeConfig(req.body);
console.log('[api] Config updated');
res.json({ success: true });
} catch (e) {
res.status(500).json({ error: e.message });
@@ -93,6 +105,27 @@ app.get('/api/admin/stats', requireAdmin, async (req, res) => {
if (!config.answersTableId) return res.json({ answers: [] });
const rows = await listRows(config.answersTableId);
res.json({ answers: rows });
} catch (e) {
console.error('[api] Stats error:', e.message);
res.status(500).json({ error: e.message });
}
});
// Debug endpoint to test Baserow connection
app.get('/api/admin/debug-baserow', requireAdmin, async (req, res) => {
try {
const { baserowUrl, baserowToken } = getEnvConfig();
const config = readConfig();
const result = { baserowUrl, tokenLength: baserowToken.length, sets: config.sets, tests: [] };
for (const set of config.sets) {
try {
const rows = await listRows(set.tableId);
result.tests.push({ set: set.name, tableId: set.tableId, rowCount: rows.length, sampleFields: rows.length > 0 ? Object.keys(rows[0]) : [] });
} catch (e) {
result.tests.push({ set: set.name, tableId: set.tableId, error: e.message });
}
}
res.json(result);
} catch (e) {
res.status(500).json({ error: e.message });
}
@@ -106,4 +139,8 @@ app.get('*', (req, res) => res.sendFile(path.join(__dirname, '..', 'public', 'in
/* ---- Start ---- */
setupWebSocket(server);
const { port } = getEnvConfig();
server.listen(port, '0.0.0.0', () => console.log(`[quizalarm] Laeuft auf Port ${port}`));
server.listen(port, '0.0.0.0', () => {
console.log(`[quizalarm] Server laeuft auf Port ${port}`);
console.log(`[quizalarm] Baserow URL: ${getEnvConfig().baserowUrl}`);
console.log(`[quizalarm] Config: ${JSON.stringify(readConfig())}`);
});
+34 -25
View File
@@ -5,10 +5,6 @@ const { calculateScore, sanitizeQuestion, parseCorrectAnswers } = require('./qui
const sessions = new Map();
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function generateCode() {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
let code = '';
@@ -87,13 +83,13 @@ function setupWebSocket(server) {
try {
handleMessage(ws, JSON.parse(raw));
} catch (e) {
console.error('[ws] Message error:', e);
send(ws, { type: 'error', message: 'Ungueltige Nachricht' });
}
});
ws.on('close', () => handleDisconnect(ws));
});
// Heartbeat every 30 s
setInterval(() => {
wss.clients.forEach((ws) => {
if (!ws.isAlive) return ws.terminate();
@@ -108,6 +104,7 @@ function setupWebSocket(server) {
/* ------------------------------------------------------------------ */
async function handleMessage(ws, msg) {
console.log(`[ws] Received: ${msg.type}`);
switch (msg.type) {
case 'admin-create':
return adminCreate(ws, msg);
@@ -115,6 +112,8 @@ async function handleMessage(ws, msg) {
return adminJoin(ws, msg);
case 'admin-start':
return adminStart(ws, msg);
case 'admin-show-result':
return adminShowResult(ws, msg);
case 'admin-next':
return adminNext(ws, msg);
case 'admin-end':
@@ -126,7 +125,7 @@ async function handleMessage(ws, msg) {
case 'answer':
return playerAnswer(ws, msg);
default:
send(ws, { type: 'error', message: 'Unbekannter Typ' });
send(ws, { type: 'error', message: 'Unbekannter Typ: ' + msg.type });
}
}
@@ -144,10 +143,15 @@ async function adminCreate(ws, msg) {
let questions;
try {
questions = await listRows(set.tableId);
} catch (e) {
return send(ws, { type: 'error', message: 'Fehler beim Laden: ' + e.message });
console.log(`[live] Loaded ${questions.length} questions for set "${set.name}"`);
if (questions.length > 0) {
console.log(`[live] Sample fields:`, Object.keys(questions[0]).join(', '));
}
if (!questions.length) return send(ws, { type: 'error', message: 'Keine Fragen gefunden' });
} catch (e) {
console.error('[live] Baserow error:', e.message);
return send(ws, { type: 'error', message: 'Fehler beim Laden der Fragen: ' + e.message });
}
if (!questions.length) return send(ws, { type: 'error', message: 'Keine Fragen in dieser Tabelle gefunden' });
const opts = {
shuffle: msg.options.shuffle !== false,
@@ -216,6 +220,14 @@ function adminStart(ws, msg) {
sendNextQuestion(s);
}
function adminShowResult(ws, msg) {
if (!isAdmin(msg.token)) return;
const s = sessions.get(msg.code);
if (!s || s.status !== 'active') return;
console.log(`[live] Admin forced show result for session ${s.code}`);
showResult(s);
}
function adminNext(ws, msg) {
if (!isAdmin(msg.token)) return;
const s = sessions.get(msg.code);
@@ -236,8 +248,8 @@ async function adminEnd(ws, msg) {
function playerJoin(ws, msg) {
const s = sessions.get(msg.code);
if (!s) return send(ws, { type: 'error', message: 'Session nicht gefunden' });
if (s.status !== 'waiting') return send(ws, { type: 'error', message: 'Session laeuft bereits' });
if (!s) return send(ws, { type: 'error', message: 'Session nicht gefunden. Pruefe den Code.' });
if (s.status !== 'waiting') return send(ws, { type: 'error', message: 'Session laeuft bereits oder ist beendet.' });
const name = (msg.name || '').trim();
if (!name) return send(ws, { type: 'error', message: 'Name darf nicht leer sein' });
@@ -247,7 +259,7 @@ function playerJoin(ws, msg) {
if (existing.ws && existing.ws.readyState === WebSocket.OPEN) {
return send(ws, { type: 'error', message: 'Name bereits online. Bitte waehle einen anderen.' });
}
existing.ws = ws; // Reconnect
existing.ws = ws;
} else {
s.players.set(name, { ws, score: 0, answers: [] });
}
@@ -263,13 +275,13 @@ function playerAnswer(ws, msg) {
if (!qa || qa.role !== 'player') return;
const s = sessions.get(qa.code);
if (!s || s.status !== 'active') return;
if (s.currentAnswers.has(qa.name)) return; // Already answered
if (s.currentAnswers.has(qa.name)) return;
const elapsed = (Date.now() - s.questionStartTime) / 1000;
const tl = s.options.scoring === 'time' ? s.options.timeLimit : 0;
const remaining = tl > 0 ? Math.max(0, tl - elapsed) : 0;
if (tl > 0 && remaining <= 0) return; // Time up
if (tl > 0 && remaining <= 0) return;
s.currentAnswers.set(qa.name, { answers: msg.answers || [], timeRemaining: remaining });
broadcastAdmins(s, { type: 'answer-count', count: s.currentAnswers.size, total: s.players.size });
@@ -292,12 +304,15 @@ function sendNextQuestion(s) {
const q = s.questions[s.currentQuestionIndex];
const tl = s.options.scoring === 'time' ? s.options.timeLimit : 0;
const sanitized = sanitizeQuestion(q);
console.log(`[live] Session ${s.code}: Sending question ${s.currentQuestionIndex + 1}/${s.questions.length}`);
broadcastAll(s, {
type: 'question',
index: s.currentQuestionIndex,
total: s.questions.length,
question: sanitizeQuestion(q),
question: sanitized,
timeLimit: tl,
});
@@ -317,11 +332,9 @@ function showResult(s) {
const correct = parseCorrectAnswers(q);
const tl = s.options.scoring === 'time' ? s.options.timeLimit : 0;
// Answer distribution
const stats = {};
['A', 'B', 'C', 'D'].forEach((k) => { if (q[`Antwort ${k}`]) stats[k] = 0; });
// Score for each player who answered
for (const [name, ans] of s.currentAnswers) {
const player = s.players.get(name);
const result = calculateScore(q, ans.answers, ans.timeRemaining, tl, s.options.scoring);
@@ -336,7 +349,6 @@ function showResult(s) {
});
}
// Players who didn't answer
for (const [name, player] of s.players) {
if (!s.currentAnswers.has(name)) {
player.answers.push({ qId: q.id, idx: s.currentQuestionIndex, answers: [], correct: false, points: 0 });
@@ -350,7 +362,6 @@ function showResult(s) {
const lb = leaderboard(s);
// Send personal results to players
for (const [name, player] of s.players) {
const last = player.answers[player.answers.length - 1];
send(player.ws, {
@@ -372,10 +383,12 @@ async function endSession(s) {
const config = readConfig();
if (config.answersTableId && s.results.length) {
await batchCreateRows(config.answersTableId, s.results);
console.log(`[quizalarm] Session ${s.code}: ${s.results.length} Ergebnisse gespeichert`);
console.log(`[live] Session ${s.code}: ${s.results.length} Ergebnisse in Baserow gespeichert`);
} else {
console.log(`[live] Session ${s.code}: Keine Antworten-Tabelle konfiguriert oder keine Ergebnisse`);
}
} catch (e) {
console.error('[quizalarm] Fehler beim Speichern:', e.message);
console.error('[live] Fehler beim Speichern:', e.message);
}
setTimeout(() => sessions.delete(s.code), 300000);
@@ -398,10 +411,6 @@ function handleDisconnect(ws) {
}
}
/* ------------------------------------------------------------------ */
/* Exports */
/* ------------------------------------------------------------------ */
function getSessions() {
return Array.from(sessions.values()).map(sessionInfo);
}
+14 -16
View File
@@ -1,6 +1,5 @@
/**
* Normalize text for freitext comparison.
* Removes special characters, lowercases, collapses whitespace.
* "Villingen-Schwenningen" == "villingen schwenningen" == "villingenschwenningen"
*/
function normalizeText(text) {
@@ -38,19 +37,21 @@ function countOptions(question) {
return n;
}
function shuffleArray(arr) {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
/**
* Calculate score for an answer.
* @param {object} question Baserow row
* @param {string[]} selected e.g. ['A','C'] or ['Berlin']
* @param {number} timeRemaining seconds left (0 if no timer)
* @param {number} totalTime total seconds (0 if binary)
* @param {string} scoringMode 'binary' | 'time'
* @returns {{ points: number, correct: boolean }}
*/
function calculateScore(question, selected, timeRemaining, totalTime, scoringMode) {
if (!selected || selected.length === 0) return { points: 0, correct: false };
// Base points
let basis;
if (scoringMode === 'time' && totalTime > 0) {
const ratio = Math.max(0, Math.min(1, timeRemaining / totalTime));
@@ -62,13 +63,11 @@ function calculateScore(question, selected, timeRemaining, totalTime, scoringMod
const typ = (question['Typ'] || 'MC').toLowerCase();
const correctAnswers = parseCorrectAnswers(question);
// Freitext
if (typ === 'freitext') {
const ok = checkFreitext(selected[0], correctAnswers[0]);
return { points: ok ? basis : 0, correct: ok };
}
// MC / Wahr-Falsch
const correctSet = new Set(correctAnswers);
const totalCorrect = correctSet.size;
const totalIncorrect = countOptions(question) - totalCorrect;
@@ -91,7 +90,7 @@ function calculateScore(question, selected, timeRemaining, totalTime, scoringMod
}
/**
* Strip the correct answer from a question before sending to participants.
* Strip the correct answer and shuffle options before sending to participants.
*/
function sanitizeQuestion(question) {
const typ = (question['Typ'] || 'MC').toLowerCase();
@@ -104,23 +103,22 @@ function sanitizeQuestion(question) {
kategorie: question['Kategorie'] || '',
};
// Bild (Baserow file field → array of objects)
const bild = question['Bild'];
if (bild && Array.isArray(bild) && bild.length > 0) {
sanitized.bild = bild[0].url;
}
if (typ === 'freitext') {
// no options
} else {
if (typ !== 'freitext') {
const keys = ['A', 'B', 'C', 'D'];
for (const k of keys) {
const val = question[`Antwort ${k}`];
if (val) sanitized.antworten.push({ key: k, text: val });
}
// Shuffle answer order
sanitized.antworten = shuffleArray(sanitized.antworten);
}
return sanitized;
}
module.exports = { calculateScore, sanitizeQuestion, parseCorrectAnswers, normalizeText };
module.exports = { calculateScore, sanitizeQuestion, parseCorrectAnswers, normalizeText, shuffleArray };