Admin panel sync improvements, Baserow field handling (Single Select support), and UI emoji swap

This commit is contained in:
2026-04-16 17:01:12 +02:00
parent ec7b4d1f71
commit 247cca0803
4 changed files with 111 additions and 43 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 152 KiB

+63 -23
View File
@@ -35,7 +35,7 @@ function LoginPage({ onLogin }) {
return (
<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">
<img src="/img/logo.png" alt="Quizalarm" className="w-24 h-24 mb-2" />
<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>}
@@ -250,7 +250,14 @@ function LiveSetupPage({ config, token, onSessionCreated }) {
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'session-created') {
onSessionCreated(msg.code, ws);
onSessionCreated(msg.code, ws, {
code: msg.code,
setName: msg.setName,
questionCount: msg.questionCount,
options: msg.options,
players: [],
playerCount: 0,
});
} else if (msg.type === 'error') {
setError(msg.message);
setLoading(false);
@@ -298,11 +305,11 @@ function LiveSetupPage({ config, token, onSessionCreated }) {
}
/* ================================================================== */
/* Live Control Page */
/* Live Control Page (FIX: sends admin-join on mount) */
/* ================================================================== */
function LiveControlPage({ token, code, ws }) {
const [session, setSession] = useState(null);
function LiveControlPage({ token, code, ws, initialSession }) {
const [session, setSession] = useState(initialSession || null);
const [phase, setPhase] = useState('waiting');
const [answerCount, setAnswerCount] = useState(0);
const [result, setResult] = useState(null);
@@ -313,20 +320,26 @@ function LiveControlPage({ token, code, ws }) {
const handler = (e) => {
const msg = JSON.parse(e.data);
console.log('[admin-control] Received:', msg.type);
switch (msg.type) {
case 'session-created':
setSession({ code: msg.code, setName: msg.setName, questionCount: msg.questionCount, options: msg.options, players: [] });
setSession({ code: msg.code, setName: msg.setName, questionCount: msg.questionCount, options: msg.options, players: [], playerCount: 0 });
setPhase('waiting');
break;
case 'admin-joined':
setSession(msg.session);
setLeaderboardData(msg.session.leaderboard || []);
if (msg.session.status === 'waiting') setPhase('waiting');
else if (msg.session.status === 'ended') setPhase('ended');
else setPhase('active');
setLeaderboardData(msg.session.leaderboard);
else if (msg.session.status === 'showing-result') setPhase('result');
else setPhase('question');
break;
case 'player-joined':
case 'player-left':
setSession((s) => s ? { ...s, players: msg.players, playerCount: msg.playerCount } : s);
setSession((s) => {
if (!s) return { code, players: msg.players, playerCount: msg.playerCount };
return { ...s, players: msg.players, playerCount: msg.playerCount };
});
break;
case 'game-start':
setPhase('active');
@@ -355,42 +368,67 @@ function LiveControlPage({ token, code, ws }) {
};
ws.addEventListener('message', handler);
return () => ws.removeEventListener('message', handler);
}, [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 }));
// Request current session state from server to sync up
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'admin-join', token, code }));
} else {
const onOpen = () => {
ws.send(JSON.stringify({ type: 'admin-join', token, code }));
ws.removeEventListener('open', onOpen);
};
ws.addEventListener('open', onOpen);
}
return () => ws.removeEventListener('message', handler);
}, [ws, code, token]);
const start = () => {
console.log('[admin-control] Sending admin-start');
ws.send(JSON.stringify({ type: 'admin-start', token, code }));
};
const showResultAction = () => {
console.log('[admin-control] Sending admin-show-result');
ws.send(JSON.stringify({ type: 'admin-show-result', token, code }));
};
const next = () => {
console.log('[admin-control] Sending admin-next');
ws.send(JSON.stringify({ type: 'admin-next', token, code }));
};
const end = () => {
console.log('[admin-control] Sending admin-end');
ws.send(JSON.stringify({ type: 'admin-end', token, code }));
};
const presentUrl = `${location.origin}/present?token=${encodeURIComponent(token)}#${code}`;
const playerCount = session?.players?.length || session?.playerCount || 0;
return (
<div className="space-y-4 max-w-lg mx-auto">
<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>
<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-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>
<h3 className="font-bold mb-2">Teilnehmer ({playerCount})</h3>
<div className="flex flex-wrap gap-2">
{(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>}
{playerCount === 0 && <p className="text-gray-400 text-sm">Noch keine Teilnehmer...</p>}
</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 onClick={start} disabled={playerCount === 0} 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 ({playerCount} Teilnehmer)
</button>
)}
{phase === 'question' && (
<div className="bg-white rounded-xl shadow p-6 text-center">
<p className="text-lg mb-3">Antworten: <strong className="text-red-600">{answerCount}</strong> / {session?.players?.length || 0}</p>
<p className="text-lg mb-3">Antworten: <strong className="text-red-600">{answerCount}</strong> / {playerCount}</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>
)}
@@ -448,6 +486,7 @@ function AdminApp() {
const [config, setConfig] = useState({ sets: [], answersTableId: null });
const [liveCode, setLiveCode] = useState(null);
const [liveWs, setLiveWs] = useState(null);
const [liveInitialSession, setLiveInitialSession] = useState(null);
useEffect(() => {
if (token) {
@@ -460,9 +499,10 @@ function AdminApp() {
try { await api.post('/api/admin/config', c); } catch (e) { console.error(e); }
};
const handleSessionCreated = (code, ws) => {
const handleSessionCreated = (code, ws, sessionData) => {
setLiveCode(code);
setLiveWs(ws);
setLiveInitialSession(sessionData);
setTab('live-control');
};
@@ -491,7 +531,7 @@ function AdminApp() {
<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 === 'live-control' && <LiveControlPage token={token} code={liveCode} ws={liveWs} initialSession={liveInitialSession} />}
{tab === 'stats' && <StatsPage />}
</div>
</div>
+27 -13
View File
@@ -26,6 +26,23 @@ function shuffleArray(arr) {
return a;
}
/**
* Extract value from Baserow Single Select field.
* Can be { id: 6, value: "MC", color: "..." } or plain string or null.
*/
function fieldVal(field, fallback) {
if (!field) return fallback || '';
if (typeof field === 'string') return field;
if (typeof field === 'object' && field.value !== undefined) return field.value;
return String(field);
}
function normalizeText(t) {
return (t || '').toLowerCase()
.replace(/[äÄ]/g, 'ae').replace(/[öÖ]/g, 'oe').replace(/[üÜ]/g, 'ue').replace(/ß/g, 'ss')
.replace(/[^a-z0-9]/g, '');
}
/* ================================================================== */
/* Shared Components */
/* ================================================================== */
@@ -123,7 +140,7 @@ function AnswerGrid({ question, selected, onToggle, disabled }) {
function HomePage({ navigate }) {
return (
<div className="min-h-screen flex flex-col items-center justify-center p-6">
<img src="/img/logo.png" alt="Quizalarm" className="w-24 h-24 mb-2" />
<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">
@@ -402,6 +419,7 @@ function SoloQuizPage({ params, navigate }) {
useEffect(() => {
api.get(`/api/sets/${params.setId}/questions`).then((qs) => {
console.log(`Loaded ${qs.length} questions, sample Typ:`, qs[0]?.['Typ']);
if (params.shuffle) {
for (let i = qs.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
@@ -412,7 +430,6 @@ function SoloQuizPage({ params, navigate }) {
}).catch((e) => setError(e.message));
}, []);
// Shuffle answers when question changes
useEffect(() => {
if (questions.length === 0 || index >= questions.length) return;
const q = questions[index];
@@ -427,16 +444,14 @@ function SoloQuizPage({ params, navigate }) {
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 typ = fieldVal(q['Typ'], 'MC').toLowerCase();
const sanitized = {
frage: q['Frage'] || q['frage'] || '(Kein Fragetext)',
typ: q['Typ'] || q['typ'] || 'MC',
frage: q['Frage'] || '(Kein Fragetext)',
typ: fieldVal(q['Typ'], 'MC'),
bild: q['Bild'] && Array.isArray(q['Bild']) && q['Bild'].length ? q['Bild'][0].url : null,
antworten: shuffledAnswers,
};
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;
@@ -520,11 +535,10 @@ function SoloResultPage({ params, navigate }) {
const byCategory = {};
results.forEach((r, i) => {
const q = questions[i];
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++;
const cat = fieldVal(q?.['Kategorie'], 'Ohne Kategorie') || 'Ohne Kategorie';
if (!byCategory[cat]) byCategory[cat] = { total: 0, correct: 0 };
byCategory[cat].total++;
if (r.Richtig) byCategory[cat].correct++;
});
return (
@@ -573,7 +587,7 @@ function MyResultsPage({ navigate }) {
if (results) {
results.forEach((r) => {
const sid = r['Session_ID'] || 'Unbekannt';
if (!sessions[sid]) sessions[sid] = { modus: r['Modus'], fragenset: r['Fragenset'], date: r['Zeitstempel'], answers: [] };
if (!sessions[sid]) sessions[sid] = { modus: fieldVal(r['Modus'], ''), fragenset: r['Fragenset'], date: r['Zeitstempel'], answers: [] };
sessions[sid].answers.push(r);
});
}
+21 -7
View File
@@ -14,13 +14,28 @@ function normalizeText(text) {
.trim();
}
/**
* Extract the string value from a Baserow Single Select field.
* Baserow returns { id: 6, value: "MC", color: "..." } for Single Select.
*/
function extractFieldValue(field, fallback) {
if (!field) return fallback || '';
if (typeof field === 'string') return field;
if (typeof field === 'object' && field.value !== undefined) return field.value;
return String(field);
}
function getTyp(question) {
return extractFieldValue(question['Typ'], 'MC').toLowerCase();
}
function checkFreitext(userAnswer, correctAnswer) {
return normalizeText(userAnswer) === normalizeText(correctAnswer);
}
function parseCorrectAnswers(question) {
const raw = (question['Richtige Antwort'] || '').trim();
const typ = (question['Typ'] || 'MC').toLowerCase();
const typ = getTyp(question);
if (typ === 'freitext') return [raw];
return raw
.split(',')
@@ -60,7 +75,7 @@ function calculateScore(question, selected, timeRemaining, totalTime, scoringMod
basis = 1000;
}
const typ = (question['Typ'] || 'MC').toLowerCase();
const typ = getTyp(question);
const correctAnswers = parseCorrectAnswers(question);
if (typ === 'freitext') {
@@ -93,14 +108,14 @@ function calculateScore(question, selected, timeRemaining, totalTime, scoringMod
* Strip the correct answer and shuffle options before sending to participants.
*/
function sanitizeQuestion(question) {
const typ = (question['Typ'] || 'MC').toLowerCase();
const typ = getTyp(question);
const sanitized = {
id: question.id,
frage: question['Frage'] || '',
typ: question['Typ'] || 'MC',
typ: extractFieldValue(question['Typ'], 'MC'),
bild: null,
antworten: [],
kategorie: question['Kategorie'] || '',
kategorie: extractFieldValue(question['Kategorie'], ''),
};
const bild = question['Bild'];
@@ -114,11 +129,10 @@ function sanitizeQuestion(question) {
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, shuffleArray };
module.exports = { calculateScore, sanitizeQuestion, parseCorrectAnswers, normalizeText, shuffleArray, extractFieldValue };