Massive feature update: Solo session recovery, immediate result saving, image proxy, player reconnection (Live), and UI/UX refinements

This commit is contained in:
2026-04-16 17:53:12 +02:00
parent 247cca0803
commit 4b6e43ba65
8 changed files with 625 additions and 436 deletions
+153 -29
View File
@@ -2,9 +2,9 @@ const express = require('express');
const http = require('http');
const path = require('path');
const { getEnvConfig, readConfig, writeConfig } = require('./config');
const { listRows, batchCreateRows } = require('./baserow');
const { listRows, createRow, batchCreateRows } = require('./baserow');
const { setupWebSocket, getSessions } = require('./live-session');
const { sanitizeQuestion, shuffleArray } = require('./quiz-logic');
const { extractFieldValue } = require('./quiz-logic');
const app = express();
const server = http.createServer(app);
@@ -19,13 +19,77 @@ function requireAdmin(req, res, next) {
next();
}
/* ============================================================ */
/* Image Proxy */
/* ============================================================ */
const imageCache = new Map();
const MAX_CACHE = 50;
app.get('/api/img', async (req, res) => {
const url = req.query.url;
if (!url) return res.status(400).send('Missing url parameter');
let parsed;
try { parsed = new URL(url); } catch { return res.status(400).send('Invalid URL'); }
const { baserowUrl } = getEnvConfig();
let allowedHost = '';
try { allowedHost = new URL(baserowUrl).host; } catch { }
if (parsed.host !== allowedHost) return res.status(403).send('Forbidden');
if (imageCache.has(url)) {
const cached = imageCache.get(url);
res.set('Content-Type', cached.contentType);
res.set('Cache-Control', 'public, max-age=86400');
return res.send(cached.buffer);
}
try {
const response = await fetch(url);
if (!response.ok) return res.status(response.status).send('Image fetch failed');
const contentType = response.headers.get('content-type') || 'image/png';
const buffer = Buffer.from(await response.arrayBuffer());
if (imageCache.size >= MAX_CACHE) {
const oldest = imageCache.keys().next().value;
imageCache.delete(oldest);
}
imageCache.set(url, { buffer, contentType });
res.set('Content-Type', contentType);
res.set('Cache-Control', 'public, max-age=86400');
res.send(buffer);
} catch (e) {
console.error(`[img-proxy] Error:`, e.message);
res.status(500).send('Proxy error');
}
});
/* ============================================================ */
/* Helper: rewrite Baserow image URLs to use our proxy */
/* ============================================================ */
function rewriteImageUrls(rows) {
return rows.map((row) => {
const bild = row['Bild'];
if (bild && Array.isArray(bild) && bild.length > 0) {
return {
...row,
Bild: bild.map((img) => ({
...img,
url: img.url ? `/api/img?url=${encodeURIComponent(img.url)}` : img.url,
})),
};
}
return row;
});
}
/* ============================================================ */
/* Public API */
/* ============================================================ */
app.get('/api/sets', (req, res) => {
const config = readConfig();
console.log(`[api] GET /api/sets — ${(config.sets || []).length} sets`);
res.json((config.sets || []).map((s) => ({ id: s.id, name: s.name })));
});
@@ -34,29 +98,103 @@ app.get('/api/sets/:id/questions', async (req, res) => {
const config = readConfig();
const set = config.sets.find((s) => s.id === req.params.id);
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(', ')}`);
}
let rows = await listRows(set.tableId);
rows = rewriteImageUrls(rows);
res.json(rows);
} catch (e) {
console.error(`[api] Error loading questions:`, e.message);
res.status(500).json({ error: e.message });
}
});
/* ---- Solo: Save single answer immediately ---- */
app.post('/api/results/single', async (req, res) => {
try {
const config = readConfig();
if (!config.answersTableId) return res.status(400).json({ error: 'Antworten-Tabelle nicht konfiguriert' });
await createRow(config.answersTableId, req.body);
res.json({ success: true });
} catch (e) {
console.error('[api] Single save error:', e.message);
res.status(500).json({ error: e.message });
}
});
/* ---- Solo: Check for incomplete session ---- */
app.get('/api/progress/:name/:setId', async (req, res) => {
try {
const config = readConfig();
const set = config.sets.find((s) => s.id === req.params.setId);
if (!set) return res.status(404).json({ error: 'Set nicht gefunden' });
if (!config.answersTableId) return res.json({ hasProgress: false });
// Count total questions
const questions = await listRows(set.tableId);
const totalQuestions = questions.length;
// Get answers for this user + set
const allAnswers = await listRows(config.answersTableId, {
Nutzername: req.params.name,
Fragenset: set.name,
});
// Filter to Solo only (Modus may be Single Select object)
const soloAnswers = allAnswers.filter((a) => {
return extractFieldValue(a['Modus'], '') === 'Solo';
});
// Group by Session_ID
const bySession = {};
soloAnswers.forEach((a) => {
const sid = a['Session_ID'] || '';
if (!bySession[sid]) bySession[sid] = [];
bySession[sid].push(a);
});
// Find the most recent incomplete session
let best = null;
for (const [sid, answers] of Object.entries(bySession)) {
if (answers.length > 0 && answers.length < totalQuestions) {
const latest = answers.reduce((max, a) => {
const ts = a['Zeitstempel'] || '';
return ts > max ? ts : max;
}, '');
if (!best || latest > best.latest) {
best = { sid, answers, latest };
}
}
}
if (best) {
res.json({
hasProgress: true,
sessionId: best.sid,
answers: best.answers.map((a) => ({
Frage_ID: a['Frage_ID'],
Antwort: a['Antwort'] || '',
Richtig: !!a['Richtig'],
Punkte: a['Punkte'] || 0,
})),
totalAnswered: best.answers.length,
totalCorrect: best.answers.filter((a) => a['Richtig']).length,
totalQuestions,
});
} else {
res.json({ hasProgress: false, totalQuestions });
}
} catch (e) {
console.error('[api] Progress error:', e.message);
res.status(500).json({ error: e.message });
}
});
/* ---- Batch save (still used by live sessions) ---- */
app.post('/api/results', async (req, res) => {
try {
const config = readConfig();
if (!config.answersTableId) return res.status(400).json({ error: 'Antworten-Tabelle nicht konfiguriert' });
const answers = req.body.answers || [];
console.log(`[api] Saving ${answers.length} results`);
await batchCreateRows(config.answersTableId, answers);
await batchCreateRows(config.answersTableId, req.body.answers || []);
res.json({ success: true });
} catch (e) {
console.error(`[api] Error saving results:`, e.message);
res.status(500).json({ error: e.message });
}
});
@@ -68,7 +206,6 @@ 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 });
}
});
@@ -86,17 +223,9 @@ app.post('/api/admin/login', (req, res) => {
});
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 });
}
try { writeConfig(req.body); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); }
});
app.get('/api/admin/sessions', requireAdmin, (req, res) => res.json(getSessions()));
app.get('/api/admin/stats', requireAdmin, async (req, res) => {
@@ -106,12 +235,10 @@ app.get('/api/admin/stats', requireAdmin, async (req, res) => {
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();
@@ -131,16 +258,13 @@ app.get('/api/admin/debug-baserow', requireAdmin, async (req, res) => {
}
});
/* ---- SPA routes ---- */
app.get('/admin', (req, res) => res.sendFile(path.join(__dirname, '..', 'public', 'admin.html')));
app.get('/present', (req, res) => res.sendFile(path.join(__dirname, '..', 'public', 'present.html')));
app.get('*', (req, res) => res.sendFile(path.join(__dirname, '..', 'public', 'index.html')));
/* ---- Start ---- */
setupWebSocket(server);
const { port } = getEnvConfig();
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())}`);
});