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
+18 -10
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: {
Authorization: `Token ${baserowToken}`,
'Content-Type': 'application/json',
...(options.headers || {}),
},
});
console.log(`[baserow] ${options.method || 'GET'} ${url}`);
const headers = {
Authorization: `Token ${baserowToken}`,
...(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())}`);
});
+33 -24
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);
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(', '));
}
} catch (e) {
return send(ws, { type: 'error', message: 'Fehler beim Laden: ' + e.message });
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 gefunden' });
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 };