From 08b606d5b2a07ca08b0bbd1f5af08714fd0e98aa Mon Sep 17 00:00:00 2001 From: orfelorfel23 Date: Wed, 15 Apr 2026 21:55:03 +0200 Subject: [PATCH] Initial: Quizalarm Quiz-Plattform --- .dockerignore | 4 + BASEROW-SETUP.md | 149 ++++++++++ Dockerfile | 7 + package.json | 13 + public/admin.html | 19 ++ public/index.html | 72 +++++ public/js/admin.js | 477 +++++++++++++++++++++++++++++++ public/js/app.js | 622 +++++++++++++++++++++++++++++++++++++++++ public/js/present.js | 216 ++++++++++++++ public/present.html | 40 +++ server/baserow.js | 49 ++++ server/config.js | 33 +++ server/index.js | 109 ++++++++ server/live-session.js | 409 +++++++++++++++++++++++++++ server/quiz-logic.js | 126 +++++++++ 15 files changed, 2345 insertions(+) create mode 100644 .dockerignore create mode 100644 BASEROW-SETUP.md create mode 100644 Dockerfile create mode 100644 package.json create mode 100644 public/admin.html create mode 100644 public/index.html create mode 100644 public/js/admin.js create mode 100644 public/js/app.js create mode 100644 public/js/present.js create mode 100644 public/present.html create mode 100644 server/baserow.js create mode 100644 server/config.js create mode 100644 server/index.js create mode 100644 server/live-session.js create mode 100644 server/quiz-logic.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f68e13d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +node_modules +.git +.gitignore +README.md \ No newline at end of file diff --git a/BASEROW-SETUP.md b/BASEROW-SETUP.md new file mode 100644 index 0000000..839ef2c --- /dev/null +++ b/BASEROW-SETUP.md @@ -0,0 +1,149 @@ +# Quizalarm — Baserow Tabellenstruktur + +Diese Anleitung beschreibt, wie die benötigten Tabellen in Baserow angelegt werden. + +--- + +## Voraussetzungen + +- Baserow erreichbar unter `https://br.orfel.de` +- Ein API-Token erstellt unter: **Settings → API Tokens → Create Token** + - Rechte: mindestens `create`, `read`, `update` für alle relevanten Tabellen + +--- + +## 1. Datenbank anlegen + +1. In Baserow einloggen +2. Klicke auf **+ Create database** +3. Name: `Quizalarm` + +--- + +## 2. Antworten-Tabelle anlegen + +Diese Tabelle speichert ALLE Ergebnisse (Live + Solo). Es gibt nur eine einzige. + +1. In der Datenbank `Quizalarm` → **+ Add table** → Name: `Antworten` +2. Lösche die automatisch erstellten Beispiel-Spalten +3. Erstelle folgende Felder: + +| Nr. | Feldname | Feldtyp | Optionen / Hinweis | +|-----|----------------|---------------------|---------------------------------------------| +| 1 | `Nutzername` | Single line text | | +| 2 | `Frage_ID` | Number (Integer) | | +| 3 | `Fragenset` | Single line text | | +| 4 | `Antwort` | Single line text | Enthält z.B. `A,C` oder `Berlin` | +| 5 | `Richtig` | Boolean | | +| 6 | `Punkte` | Number (Integer) | | +| 7 | `Session_ID` | Single line text | z.B. `LIVE-M3K7X` oder `SOLO-K8P2Q` | +| 8 | `Modus` | Single select | Optionen anlegen: `Live`, `Solo` | +| 9 | `Zeitstempel` | Date | ✅ "Include time" aktivieren | + +1. **Table-ID notieren**: Klicke auf die Tabelle → die URL enthält `/database/TABLE_ID/...` + +--- + +## 3. Fragenset-Tabellen anlegen + +Für jedes Fragenset wird eine eigene Tabelle erstellt. Der Aufbau ist immer identisch. + +### Schritt-für-Schritt + +1. In der Datenbank `Quizalarm` → **+ Add table** +2. Name = Name des Fragensets (z.B. `IT-Grundlagen`, `Erste Hilfe`, `Brandschutz`) +3. Lösche die automatisch erstellten Beispiel-Spalten +4. Erstelle folgende Felder: + +| Nr. | Feldname | Feldtyp | Optionen / Hinweis | +|-----|-------------------|---------------------|------------------------------------------------------------| +| 1 | `Frage` | Long text | Der vollständige Fragetext | +| 2 | `Typ` | Single select | Optionen anlegen: `MC`, `Wahr/Falsch`, `Freitext` | +| 3 | `Bild` | File | Optional — Bild zur Frage hochladen | +| 4 | `Antwort A` | Single line text | Bei Wahr/Falsch: `Wahr` | +| 5 | `Antwort B` | Single line text | Bei Wahr/Falsch: `Falsch` | +| 6 | `Antwort C` | Single line text | Leer lassen bei Wahr/Falsch oder 2 Antworten | +| 7 | `Antwort D` | Single line text | Leer lassen bei weniger als 4 Antworten | +| 8 | `Richtige Antwort`| Single line text | Siehe Ausfüllregeln unten | +| 9 | `Kategorie` | Single select | Optional — z.B. `Geografie`, `Technik`, `Recht` | + +1. **Table-ID notieren**: URL enthält `/database/TABLE_ID/...` + +--- + +## 4. Ausfüllregeln für "Richtige Antwort" + +| Fragetyp | Beispiel-Wert | Erklärung | +|------------------|----------------------|---------------------------------------------------| +| MC (1 richtig) | `A` | Nur der Buchstabe der richtigen Antwort | +| MC (mehrere) | `A,C` | Kommagetrennt, ohne Leerzeichen | +| Wahr/Falsch | `A` | `A` = Wahr, `B` = Falsch | +| Freitext | `Berlin` | Das erwartete Wort (Groß/Kleinschreibung egal) | + +### Freitext-Toleranz + +Die App normalisiert Eingaben automatisch: + +- Groß/Kleinschreibung wird ignoriert +- Sonderzeichen (Bindestriche, Leerzeichen etc.) werden entfernt +- Umlaute werden umgewandelt (ä→ae, ö→oe, ü→ue, ß→ss) + +**Beispiel:** Richtige Antwort = `Villingen-Schwenningen` + +- ✅ `villingen schwenningen` +- ✅ `villingenschwenningen` +- ✅ `Villingen Schwenningen` +- ❌ `Villingen` (unvollständig) +- ❌ `Stuttgart` (falsch) + +--- + +## 5. Beispielfragen + +### MC mit 4 Antworten + +| Frage | Typ | Antwort A | Antwort B | Antwort C | Antwort D | Richtige Antwort | Kategorie | +|-------|-----|-----------|-----------|-----------|-----------|-----------------|-----------| +| Wie heißt die Hauptstadt von Baden-Württemberg? | MC | Stuttgart | Karlsruhe | Freiburg | Mannheim | A | Geografie | + +### MC mit mehreren richtigen Antworten + +| Frage | Typ | Antwort A | Antwort B | Antwort C | Antwort D | Richtige Antwort | Kategorie | +|-------|-----|-----------|-----------|-----------|-----------|-----------------|-----------| +| Welche sind Programmiersprachen? | MC | Python | HTML | Java | Photoshop | A,C | IT | + +### MC mit 3 Antworten + +| Frage | Typ | Antwort A | Antwort B | Antwort C | Antwort D | Richtige Antwort | Kategorie | +|-------|-----|-----------|-----------|-----------|-----------|-----------------|-----------| +| Welche Farbe hat ein Stoppschild? | MC | Rot | Blau | Grün | *(leer)* | A | Verkehr | + +### Wahr/Falsch + +| Frage | Typ | Antwort A | Antwort B | Antwort C | Antwort D | Richtige Antwort | Kategorie | +|-------|-----|-----------|-----------|-----------|-----------|-----------------|-----------| +| Die Erde ist eine Scheibe. | Wahr/Falsch | Wahr | Falsch | *(leer)* | *(leer)* | B | Allgemein | + +### Freitext + +| Frage | Typ | Antwort A | Antwort B | Antwort C | Antwort D | Richtige Antwort | Kategorie | +|-------|-----|-----------|-----------|-----------|-----------|-----------------|-----------| +| Wie heißt die Hauptstadt von Frankreich? | Freitext | *(leer)* | *(leer)* | *(leer)* | *(leer)* | Paris | Geografie | + +### Mit Bild + +| Frage | Typ | Bild | Antwort A | Antwort B | Antwort C | Antwort D | Richtige Antwort | Kategorie | +|-------|-----|------|-----------|-----------|-----------|-----------|-----------------|-----------| +| Was zeigt dieses Verkehrszeichen? | MC | *(Bild hochladen)* | Vorfahrt | Stopp | Einbahnstraße | Parkverbot | B | Verkehr | + +--- + +## 6. Checkliste + +- [ ] Datenbank `Quizalarm` angelegt +- [ ] Tabelle `Antworten` mit allen 9 Feldern erstellt +- [ ] Mindestens 1 Fragenset-Tabelle mit allen 9 Feldern erstellt +- [ ] Testfragen eingetragen +- [ ] API-Token erstellt (Settings → API Tokens) +- [ ] Alle Table-IDs notiert +- [ ] Table-IDs im Admin-Panel unter `https://quizalarm.orfel.de/admin` hinterlegt diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7f76b49 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm ci --production +COPY . . +EXPOSE 7849 +CMD ["node", "server/index.js"] \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..9cf22d4 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "quizalarm", + "version": "1.0.0", + "description": "Hybrid-Quiz-Plattform mit Baserow-Backend", + "main": "server/index.js", + "scripts": { + "start": "node server/index.js" + }, + "dependencies": { + "express": "^4.21.0", + "ws": "^8.18.0" + } +} \ No newline at end of file diff --git a/public/admin.html b/public/admin.html new file mode 100644 index 0000000..8305ebf --- /dev/null +++ b/public/admin.html @@ -0,0 +1,19 @@ + + + + + + + Quizalarm — Admin + + + + + + + +
+ + + + \ No newline at end of file diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..fd4a094 --- /dev/null +++ b/public/index.html @@ -0,0 +1,72 @@ + + + + + + + Quizalarm + + + + + + + + +
+ + + + \ No newline at end of file diff --git a/public/js/admin.js b/public/js/admin.js new file mode 100644 index 0000000..b4cb3c3 --- /dev/null +++ b/public/js/admin.js @@ -0,0 +1,477 @@ +const { useState, useEffect, useRef, useCallback } = React; + +const api = { + token: null, + async get(url) { + const r = await fetch(url, { headers: { 'X-Admin-Token': this.token } }); + if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || r.statusText); + return r.json(); + }, + async post(url, data) { + const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Admin-Token': this.token }, body: JSON.stringify(data) }); + if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || r.statusText); + return r.json(); + }, +}; + +function wsUrl() { + return `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}`; +} + +/* ================================================================== */ +/* Login */ +/* ================================================================== */ + +function LoginPage({ onLogin }) { + const [pw, setPw] = useState(''); + const [err, setErr] = useState(''); + const submit = async () => { + try { + const res = await api.post('/api/admin/login', { password: pw }); + api.token = res.token; + onLogin(res.token); + } catch (e) { setErr(e.message); } + }; + return ( +
+
+

🔐 Admin-Login

+ setPw(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && submit()} autoFocus /> + {err &&

{err}

} + +
+
+ ); +} + +/* ================================================================== */ +/* Config Page */ +/* ================================================================== */ + +function ConfigPage({ config, setConfig, saveConfig }) { + const [newSetName, setNewSetName] = useState(''); + const [newSetTableId, setNewSetTableId] = useState(''); + const [msg, setMsg] = useState(''); + + const addSet = () => { + if (!newSetName || !newSetTableId) return; + const updated = { + ...config, + sets: [...(config.sets || []), { id: Date.now().toString(36), name: newSetName, tableId: parseInt(newSetTableId) }], + }; + setConfig(updated); + saveConfig(updated); + setNewSetName(''); + setNewSetTableId(''); + setMsg('Fragenset hinzugefügt'); + setTimeout(() => setMsg(''), 2000); + }; + + const removeSet = (id) => { + const updated = { ...config, sets: config.sets.filter((s) => s.id !== id) }; + setConfig(updated); + saveConfig(updated); + }; + + const updateAnswersTable = (val) => { + const updated = { ...config, answersTableId: val ? parseInt(val) : null }; + setConfig(updated); + saveConfig(updated); + }; + + return ( +
+
+

📋 Fragensets

+ {(config.sets || []).map((s) => ( +
+
{s.name} (Table ID: {s.tableId})
+ +
+ ))} +
+ setNewSetName(e.target.value)} /> + setNewSetTableId(e.target.value)} /> + +
+ {msg &&

{msg}

} +
+
+

💾 Antworten-Tabelle

+
+ Table ID: + updateAnswersTable(e.target.value)} placeholder="Table ID" /> +
+
+
+ ); +} + +/* ================================================================== */ +/* Stats Page */ +/* ================================================================== */ + +function StatsPage() { + const [data, setData] = useState(null); + const [filter, setFilter] = useState(''); + + useEffect(() => { + api.get('/api/admin/stats').then(setData).catch(console.error); + }, []); + + if (!data) return

Lade Statistiken...

; + if (!data.answers.length) return

Noch keine Ergebnisse vorhanden.

; + + // Group by session + const sessions = {}; + data.answers.forEach((a) => { + const sid = a['Session_ID'] || 'Unbekannt'; + if (!sessions[sid]) sessions[sid] = { modus: a['Modus'], fragenset: a['Fragenset'], date: a['Zeitstempel'], answers: [] }; + sessions[sid].answers.push(a); + }); + + // Group by user + const users = {}; + data.answers.forEach((a) => { + const u = a['Nutzername'] || 'Unbekannt'; + if (!users[u]) users[u] = { total: 0, correct: 0, points: 0 }; + users[u].total++; + if (a['Richtig']) users[u].correct++; + users[u].points += a['Punkte'] || 0; + }); + + return ( +
+
+

📊 Übersicht

+
+
+
{Object.keys(sessions).length}
+
Sessions
+
+
+
{Object.keys(users).length}
+
Teilnehmer
+
+
+
{data.answers.length}
+
Antworten
+
+
+
+ +
+

👤 Pro Teilnehmer

+ setFilter(e.target.value)} /> +
+ + + + {Object.entries(users) + .filter(([u]) => !filter || u.toLowerCase().includes(filter.toLowerCase())) + .sort(([, a], [, b]) => b.points - a.points) + .map(([u, v]) => ( + + + + + + + + ))} + +
NameRichtigGesamtQuotePunkte
{u}{v.correct}{v.total}{Math.round((v.correct / v.total) * 100)}%{v.points}
+
+
+ +
+

📁 Sessions

+ {Object.entries(sessions).reverse().map(([sid, s]) => { + const total = s.answers.length; + const correct = s.answers.filter((a) => a['Richtig']).length; + const uniqueUsers = new Set(s.answers.map((a) => a['Nutzername'])).size; + return ( +
+
+ {s.modus} {s.fragenset} + {s.date ? new Date(s.date).toLocaleString('de') : ''} +
+
{uniqueUsers} Teilnehmer — {correct}/{total} richtig gesamt
+
+ ); + })} +
+
+ ); +} + +/* ================================================================== */ +/* Live Setup Page */ +/* ================================================================== */ + +function LiveSetupPage({ config, token, onSessionCreated }) { + const [setId, setSetId] = useState(''); + const [shuffle, setShuffle] = useState(true); + const [scoring, setScoring] = useState('binary'); + const [timeLimit, setTimeLimit] = useState(30); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const create = () => { + if (!setId) return setError('Bitte ein Fragenset waehlen'); + setLoading(true); + setError(''); + const ws = new WebSocket(wsUrl()); + ws.onopen = () => { + ws.send(JSON.stringify({ type: 'admin-create', token, setId, options: { shuffle, scoring, timeLimit } })); + }; + ws.onmessage = (e) => { + const msg = JSON.parse(e.data); + if (msg.type === 'session-created') { + onSessionCreated(msg.code, ws); + } else if (msg.type === 'error') { + setError(msg.message); + setLoading(false); + ws.close(); + } + }; + ws.onerror = () => { setError('WebSocket Fehler'); setLoading(false); }; + }; + + return ( +
+

🎮 Live-Session erstellen

+
+
+ + +
+ +
+ + +
+ {scoring === 'time' && ( +
+ + setTimeLimit(parseInt(e.target.value) || 30)} min={5} max={120} /> +
+ )} + {error &&

{error}

} + +
+
+ ); +} + +/* ================================================================== */ +/* Live Control Page */ +/* ================================================================== */ + +function LiveControlPage({ token, code, ws }) { + const [session, setSession] = useState(null); + const [phase, setPhase] = useState('waiting'); + const [answerCount, setAnswerCount] = useState(0); + const [result, setResult] = useState(null); + const [leaderboardData, setLeaderboardData] = useState([]); + + useEffect(() => { + if (!ws) return; + + const handler = (e) => { + 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 }); + break; + case 'admin-joined': + setSession(msg.session); + if (msg.session.status === 'waiting') setPhase('waiting'); + else if (msg.session.status === 'ended') setPhase('ended'); + else setPhase('active'); + setLeaderboardData(msg.session.leaderboard); + break; + case 'player-joined': + case 'player-left': + setSession((s) => s ? { ...s, players: msg.players, playerCount: msg.playerCount } : s); + break; + case 'game-start': + setPhase('active'); + break; + case 'question': + setPhase('question'); + setAnswerCount(0); + setResult(null); + break; + case 'answer-count': + setAnswerCount(msg.count); + break; + case 'question-result': + setResult(msg); + setLeaderboardData(msg.leaderboard); + setPhase('result'); + break; + case 'game-end': + setLeaderboardData(msg.leaderboard); + setPhase('ended'); + break; + } + }; + + ws.addEventListener('message', handler); + return () => ws.removeEventListener('message', handler); + }, [ws]); + + const start = () => ws.send(JSON.stringify({ type: 'admin-start', 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}`; + + return ( +
+
+

Session-Code

+
{code}
+

{session?.setName} — {session?.questionCount || '?'} Fragen

+ 📺 Präsentations-Fenster öffnen +
+ +
+

Teilnehmer ({session?.players?.length || 0})

+
+ {(session?.players || []).map((n) => {n})} +
+
+ + {phase === 'waiting' && ( + + )} + + {phase === 'question' && ( +
+

Antworten: {answerCount} / {session?.players?.length || 0}

+ +
+ )} + + {phase === 'result' && ( +
+

Ergebnis

+ {result?.stats && ( +
+ {Object.entries(result.stats).map(([k, v]) => ( +
+ {k}: {v} +
+ ))} +
+ )} +

Richtig: {result?.correctAnswer?.join(', ')}

+ +
+ )} + + {phase === 'ended' && ( +
+

🏁 Quiz beendet

+
+ )} + + {leaderboardData.length > 0 && ( +
+

🏆 Leaderboard

+ {leaderboardData.map((e) => ( +
+ {e.rank}. {e.name}{e.score} +
+ ))} +
+ )} + + {phase !== 'ended' && phase !== 'waiting' && ( + + )} +
+ ); +} + +/* ================================================================== */ +/* Admin App */ +/* ================================================================== */ + +function AdminApp() { + const [token, setToken] = useState(null); + const [tab, setTab] = useState('config'); + const [config, setConfig] = useState({ sets: [], answersTableId: null }); + const [liveCode, setLiveCode] = useState(null); + const [liveWs, setLiveWs] = useState(null); + + useEffect(() => { + if (token) { + api.token = token; + api.get('/api/admin/config').then(setConfig).catch(console.error); + } + }, [token]); + + const saveConfig = async (c) => { + try { await api.post('/api/admin/config', c); } catch (e) { console.error(e); } + }; + + const handleSessionCreated = (code, ws) => { + setLiveCode(code); + setLiveWs(ws); + setTab('live-control'); + }; + + if (!token) return ; + + const tabs = [ + { id: 'config', label: '⚙️ Konfiguration' }, + { id: 'live', label: '🎮 Live-Session' }, + { id: 'stats', label: '📊 Statistiken' }, + ]; + if (liveCode) tabs.splice(2, 0, { id: 'live-control', label: '🎛️ Steuerung' }); + + return ( +
+
+
+

⚡ Quizalarm Admin

+ ← Zur App +
+
+ {tabs.map((t) => ( + + ))} +
+
+
+ {tab === 'config' && } + {tab === 'live' && } + {tab === 'live-control' && } + {tab === 'stats' && } +
+
+ ); +} + +ReactDOM.createRoot(document.getElementById('root')).render(); \ No newline at end of file diff --git a/public/js/app.js b/public/js/app.js new file mode 100644 index 0000000..cd3edff --- /dev/null +++ b/public/js/app.js @@ -0,0 +1,622 @@ +const { useState, useEffect, useRef, useCallback } = React; + +/* ================================================================== */ +/* API Helper */ +/* ================================================================== */ +const api = { + async get(url) { + const r = await fetch(url); + if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || r.statusText); + return r.json(); + }, + async post(url, data) { + const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); + if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || r.statusText); + return r.json(); + }, +}; + +function wsUrl() { + return `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}`; +} + +/* ================================================================== */ +/* Shared Components */ +/* ================================================================== */ + +function ImageModal({ src, onClose }) { + if (!src) return null; + return ( +
+ Vergrößerung +
+ ); +} + +function Timer({ timeLeft, total }) { + if (!total) return null; + const pct = Math.max(0, (timeLeft / total) * 100); + const color = pct > 50 ? 'bg-green-500' : pct > 25 ? 'bg-yellow-500' : 'bg-red-500'; + return ( +
+
+
{Math.ceil(timeLeft)}s
+
+ ); +} + +function Leaderboard({ entries, highlight, compact }) { + if (!entries || !entries.length) return null; + const show = compact ? entries.slice(0, 5) : entries; + return ( +
+

🏆 Leaderboard

+ {show.map((e) => ( +
+ {e.rank}. {e.name} + {e.score} +
+ ))} +
+ ); +} + +const ANSWER_COLORS = { A: 'answer-a', B: 'answer-b', C: 'answer-c', D: 'answer-d' }; + +function AnswerGrid({ question, selected, onToggle, disabled }) { + const typ = (question.typ || 'MC').toLowerCase(); + const isWF = typ === 'wahr/falsch'; + + if (typ === 'freitext') { + return ( + onToggle([e.target.value])} + disabled={disabled} + autoFocus + /> + ); + } + + return ( +
+ {question.antworten.map((a) => { + const active = selected.includes(a.key); + const cls = `${ANSWER_COLORS[a.key]} ${active ? 'selected' : ''} ${disabled ? 'disabled' : ''} text-white font-bold py-4 px-6 rounded-xl text-lg cursor-pointer transition-all text-center`; + return ( + + ); + })} +
+ ); +} + +/* ================================================================== */ +/* Home Page */ +/* ================================================================== */ + +function HomePage({ navigate }) { + return ( +
+

⚡ Quizalarm

+

Lernen. Quizzen. Wissen.

+
+ + + +
+ Admin-Bereich → +
+ ); +} + +/* ================================================================== */ +/* Join Page */ +/* ================================================================== */ + +function JoinPage({ navigate }) { + const [code, setCode] = useState(''); + const [name, setName] = useState(''); + const [error, setError] = useState(''); + + const handleJoin = () => { + if (!code.trim() || !name.trim()) return setError('Code und Name erforderlich'); + navigate('live-play', { code: code.trim().toUpperCase(), name: name.trim() }); + }; + + return ( +
+

🎮 Live-Quiz beitreten

+
+ setCode(e.target.value)} maxLength={6} /> + setName(e.target.value)} maxLength={20} onKeyDown={(e) => e.key === 'Enter' && handleJoin()} /> + {error &&

{error}

} + + +
+
+ ); +} + +/* ================================================================== */ +/* Live Play Page */ +/* ================================================================== */ + +function LivePlayPage({ params, navigate }) { + const [phase, setPhase] = useState('connecting'); + const [session, setSession] = useState(null); + const [question, setQuestion] = useState(null); + const [qIndex, setQIndex] = useState(0); + const [qTotal, setQTotal] = useState(0); + const [timeLimit, setTimeLimit] = useState(0); + const [timeLeft, setTimeLeft] = useState(0); + const [selected, setSelected] = useState([]); + const [answered, setAnswered] = useState(false); + const [result, setResult] = useState(null); + const [leaderboardData, setLeaderboardData] = useState([]); + const [error, setError] = useState(''); + const [enlargedImg, setEnlargedImg] = useState(null); + const wsRef = useRef(null); + const timerRef = useRef(null); + + useEffect(() => { + const ws = new WebSocket(wsUrl()); + wsRef.current = ws; + + ws.onopen = () => { + ws.send(JSON.stringify({ type: 'join', code: params.code, name: params.name })); + }; + + ws.onmessage = (e) => { + const msg = JSON.parse(e.data); + switch (msg.type) { + case 'joined': + setSession(msg.session); + setPhase('waiting'); + break; + case 'player-joined': + case 'player-left': + setSession((s) => s ? { ...s, players: msg.players, playerCount: msg.playerCount } : s); + break; + case 'game-start': + setQTotal(msg.totalQuestions); + setPhase('playing'); + break; + case 'question': + setQuestion(msg.question); + setQIndex(msg.index); + setQTotal(msg.total); + setTimeLimit(msg.timeLimit); + setTimeLeft(msg.timeLimit); + setSelected([]); + setAnswered(false); + setResult(null); + setPhase('question'); + break; + case 'answer-received': + setAnswered(true); + break; + case 'time-up': + setAnswered(true); + clearInterval(timerRef.current); + break; + case 'question-result': + setResult(msg); + setLeaderboardData(msg.leaderboard); + setPhase('result'); + clearInterval(timerRef.current); + break; + case 'game-end': + setLeaderboardData(msg.leaderboard); + setPhase('final'); + clearInterval(timerRef.current); + break; + case 'error': + setError(msg.message); + setPhase('error'); + break; + } + }; + + ws.onclose = () => { + if (phase !== 'final' && phase !== 'error') setPhase('disconnected'); + }; + + return () => { clearInterval(timerRef.current); ws.close(); }; + }, []); + + // Timer countdown + useEffect(() => { + clearInterval(timerRef.current); + if (phase === 'question' && timeLimit > 0 && !answered) { + const start = Date.now(); + timerRef.current = setInterval(() => { + const elapsed = (Date.now() - start) / 1000; + const left = Math.max(0, timeLimit - elapsed); + setTimeLeft(left); + if (left <= 0) clearInterval(timerRef.current); + }, 100); + } + }, [phase, timeLimit, answered]); + + const submitAnswer = () => { + if (answered || !wsRef.current) return; + wsRef.current.send(JSON.stringify({ type: 'answer', answers: selected })); + setAnswered(true); + }; + + if (phase === 'error') return ( +
+

❌ {error}

+ +
+ ); + + if (phase === 'disconnected') return ( +
+

Verbindung verloren...

+ +
+ ); + + if (phase === 'connecting') return

Verbinde...

; + + if (phase === 'waiting') return ( +
+

⏳ Warteraum

+

{session?.setName}

+

{session?.playerCount || 0} Teilnehmer

+
+ {(session?.players || []).map((n) => ( + {n} + ))} +
+

Warte auf den Start...

+
+ ); + + if (phase === 'question') return ( +
+
Frage {qIndex + 1} / {qTotal}
+ {timeLimit > 0 && } +

{question?.frage}

+ {question?.bild && setEnlargedImg(question.bild)} />} + + {!answered && ( + + )} + {answered &&

✓ Antwort gesendet — warte auf Ergebnis...

} + setEnlargedImg(null)} /> +
+ ); + + if (phase === 'result') return ( +
+
+ {result?.yourResult?.correct ? '✅ Richtig!' : '❌ Falsch'} +
+

+{result?.yourResult?.points || 0} Punkte

+

Gesamt: {result?.yourResult?.totalScore || 0}

+ {result?.correctAnswer && ( +

Richtige Antwort: {result.correctAnswer.join(', ')}

+ )} + +

Warte auf nächste Frage...

+
+ ); + + if (phase === 'final') return ( +
+

🏁 Quiz beendet!

+ + +
+ ); + + return null; +} + +/* ================================================================== */ +/* Solo Select Page */ +/* ================================================================== */ + +function SoloSelectPage({ navigate }) { + const [sets, setSets] = useState([]); + const [name, setName] = useState(''); + const [shuffle, setShuffle] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + api.get('/api/sets').then(setSets).catch((e) => setError(e.message)); + }, []); + + const start = (setId, setName2) => { + if (!name.trim()) return setError('Bitte Name eingeben'); + navigate('solo-quiz', { setId, setName: setName2, name: name.trim(), shuffle }); + }; + + return ( +
+

📚 Solo-Lernen

+
+ setName(e.target.value)} /> + + {error &&

{error}

} +

Fragenset wählen:

+ {sets.length === 0 &&

Keine Fragensets konfiguriert.

} + {sets.map((s) => ( + + ))} + +
+
+ ); +} + +/* ================================================================== */ +/* Solo Quiz Page */ +/* ================================================================== */ + +function SoloQuizPage({ params, navigate }) { + const [questions, setQuestions] = useState([]); + const [index, setIndex] = useState(0); + const [selected, setSelected] = useState([]); + const [showFeedback, setShowFeedback] = useState(false); + const [isCorrect, setIsCorrect] = useState(false); + const [correctAnswer, setCorrectAnswer] = useState([]); + const [results, setResults] = useState([]); + const [error, setError] = useState(''); + const [enlargedImg, setEnlargedImg] = useState(null); + const sessionId = useRef('SOLO-' + Date.now().toString(36).toUpperCase()); + + useEffect(() => { + api.get(`/api/sets/${params.setId}/questions`).then((qs) => { + if (params.shuffle) { + for (let i = qs.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [qs[i], qs[j]] = [qs[j], qs[i]]; + } + } + setQuestions(qs); + }).catch((e) => setError(e.message)); + }, []); + + if (error) return
{error}
; + if (!questions.length) return
Lade Fragen...
; + + 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: [], + }; + ['A', 'B', 'C', 'D'].forEach((k) => { if (q[`Antwort ${k}`]) sanitized.antworten.push({ key: k, text: q[`Antwort ${k}`] }); }); + + 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); + } else { + correct = rawCorrect.split(',').map((s) => s.trim().toUpperCase()).filter(Boolean); + const correctSet = new Set(correct); + const selectedSet = new Set(selected.map((s) => s.toUpperCase())); + ok = correct.length === selectedSet.size && correct.every((c) => selectedSet.has(c)); + } + + setCorrectAnswer(correct); + setIsCorrect(ok); + setShowFeedback(true); + + results.push({ + Nutzername: params.name, + Frage_ID: q.id, + Fragenset: params.setName, + Antwort: selected.join(','), + Richtig: ok, + Punkte: ok ? 1000 : 0, + Session_ID: sessionId.current, + Modus: 'Solo', + Zeitstempel: new Date().toISOString(), + }); + }; + + const nextQuestion = () => { + if (index + 1 >= questions.length) { + // Save all results, then navigate + api.post('/api/results', { answers: results }).catch(console.error); + navigate('solo-result', { results, setName: params.setName, name: params.name, questions }); + return; + } + setIndex(index + 1); + setSelected([]); + setShowFeedback(false); + }; + + return ( +
+
Frage {index + 1} / {questions.length}
+
+
+
+

{sanitized.frage}

+ {sanitized.bild && setEnlargedImg(sanitized.bild)} />} + + {!showFeedback && ( + + )} + {showFeedback && ( +
+
+ {isCorrect ? '✅ Richtig!' : '❌ Falsch'} +
+ {!isCorrect &&

Richtig: {correctAnswer.join(', ')}

} + +
+ )} + setEnlargedImg(null)} /> +
+ ); +} + +/* ================================================================== */ +/* Solo Result Page */ +/* ================================================================== */ + +function SoloResultPage({ params, navigate }) { + const { results, setName, name, questions } = params; + const total = results.length; + 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++; + }); + + return ( +
+

📊 Ergebnis

+

{setName} — {name}

+
= 70 ? 'text-green-400' : pct >= 40 ? 'text-yellow-400' : 'text-red-400'}`}>{pct}%
+

{correct} / {total} richtig

+ {Object.keys(byCategory).length > 1 && ( +
+

Nach Kategorie:

+ {Object.entries(byCategory).map(([cat, v]) => ( +
+ {cat} + = 0.7 ? 'text-green-400' : 'text-red-400'}>{v.correct}/{v.total} +
+ ))} +
+ )} +
+ + +
+
+ ); +} + +/* ================================================================== */ +/* My Results Page */ +/* ================================================================== */ + +function MyResultsPage({ navigate }) { + const [name, setName] = useState(''); + const [results, setResults] = useState(null); + const [error, setError] = useState(''); + + const search = () => { + if (!name.trim()) return; + setError(''); + api.get(`/api/results/${encodeURIComponent(name.trim())}`) + .then(setResults) + .catch((e) => setError(e.message)); + }; + + // Group by session + const sessions = {}; + 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: [] }; + sessions[sid].answers.push(r); + }); + } + + return ( +
+

📊 Meine Ergebnisse

+
+ setName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && search()} /> + + {error &&

{error}

} +
+ {results && results.length === 0 &&

Keine Ergebnisse gefunden.

} + {results && results.length > 0 && ( +
+ {Object.entries(sessions).reverse().map(([sid, s]) => { + const total = s.answers.length; + const correct = s.answers.filter((a) => a['Richtig']).length; + const points = s.answers.reduce((sum, a) => sum + (a['Punkte'] || 0), 0); + return ( +
+
+
+ {s.modus} + {s.fragenset} +
+ {s.date ? new Date(s.date).toLocaleDateString('de') : ''} +
+

{correct}/{total} richtig — {points} Punkte

+
+ ); + })} +
+ )} + +
+ ); +} + +/* ================================================================== */ +/* App Router */ +/* ================================================================== */ + +function App() { + const [page, setPage] = useState('home'); + const [params, setParams] = useState({}); + + const navigate = useCallback((p, par = {}) => { + setPage(p); + setParams(par); + window.scrollTo(0, 0); + }, []); + + switch (page) { + case 'home': return ; + case 'join': return ; + case 'live-play': return ; + case 'solo-select': return ; + case 'solo-quiz': return ; + case 'solo-result': return ; + case 'my-results': return ; + default: return ; + } +} + +ReactDOM.createRoot(document.getElementById('root')).render(); \ No newline at end of file diff --git a/public/js/present.js b/public/js/present.js new file mode 100644 index 0000000..d47df19 --- /dev/null +++ b/public/js/present.js @@ -0,0 +1,216 @@ +const { useState, useEffect, useRef } = React; + +function wsUrl() { + return `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}`; +} + +const COLORS = { A: 'answer-a', B: 'answer-b', C: 'answer-c', D: 'answer-d' }; + +function PresentApp() { + const [phase, setPhase] = useState('connect'); + const [session, setSession] = useState(null); + const [question, setQuestion] = useState(null); + const [qIndex, setQIndex] = useState(0); + const [qTotal, setQTotal] = useState(0); + const [timeLimit, setTimeLimit] = useState(0); + const [timeLeft, setTimeLeft] = useState(0); + const [answerCount, setAnswerCount] = useState(0); + const [result, setResult] = useState(null); + const [leaderboard, setLeaderboard] = useState([]); + const timerRef = useRef(null); + const wsRef = useRef(null); + + useEffect(() => { + const code = location.hash.replace('#', ''); + const token = prompt_alternative(); + + if (!code || !token) { + setPhase('need-config'); + return; + } + + const ws = new WebSocket(wsUrl()); + wsRef.current = ws; + + ws.onopen = () => { + ws.send(JSON.stringify({ type: 'present-join', token, code })); + }; + + ws.onmessage = (e) => { + const msg = JSON.parse(e.data); + switch (msg.type) { + case 'present-joined': + setSession(msg.session); + setLeaderboard(msg.session.leaderboard || []); + setPhase('waiting'); + break; + case 'player-joined': + case 'player-left': + setSession((s) => s ? { ...s, players: msg.players, playerCount: msg.playerCount } : s); + break; + case 'game-start': + setQTotal(msg.totalQuestions); + break; + case 'question': + setQuestion(msg.question); + setQIndex(msg.index); + setQTotal(msg.total); + setTimeLimit(msg.timeLimit); + setTimeLeft(msg.timeLimit); + setAnswerCount(0); + setResult(null); + setPhase('question'); + break; + case 'answer-count': + setAnswerCount(msg.count); + break; + case 'time-up': + clearInterval(timerRef.current); + break; + case 'question-result': + setResult(msg); + setLeaderboard(msg.leaderboard); + setPhase('result'); + clearInterval(timerRef.current); + break; + case 'game-end': + setLeaderboard(msg.leaderboard); + setPhase('final'); + clearInterval(timerRef.current); + break; + case 'error': + setPhase('error'); + break; + } + }; + + return () => ws.close(); + }, []); + + // Timer + useEffect(() => { + clearInterval(timerRef.current); + if (phase === 'question' && timeLimit > 0) { + const start = Date.now(); + timerRef.current = setInterval(() => { + setTimeLeft(Math.max(0, timeLimit - (Date.now() - start) / 1000)); + }, 100); + } + }, [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 ( +
+

📺 Präsentation

+

Öffne diesen Link aus dem Admin-Panel oder füge ?token=PASSWORT#CODE zur URL hinzu.

+
+ ); + } + + if (phase === 'error') return
Verbindungsfehler
; + + if (phase === 'waiting') return ( +
+

⚡ Quizalarm

+

{session?.setName}

+
{session?.code}
+

{session?.players?.length || 0} Teilnehmer

+
+ {(session?.players || []).map((n) => ( + {n} + ))} +
+
+ ); + + if (phase === 'question') { + const pct = timeLimit > 0 ? (timeLeft / timeLimit) * 100 : 100; + const barColor = pct > 50 ? 'bg-green-500' : pct > 25 ? 'bg-yellow-500' : 'bg-red-500'; + return ( +
+
+ Frage {qIndex + 1} / {qTotal} + {answerCount} / {session?.players?.length || 0} 💬 + {timeLimit > 0 && {Math.ceil(timeLeft)}s} +
+ {timeLimit > 0 && ( +
+
+
+ )} +

{question?.frage}

+ {question?.bild && } + {question?.typ?.toLowerCase() !== 'freitext' && ( +
+ {(question?.antworten || []).map((a) => ( +
+ {a.key} {a.text} +
+ ))} +
+ )} + {question?.typ?.toLowerCase() === 'freitext' && ( +
✏️ Freitext-Antwort
+ )} +
+ ); + } + + if (phase === 'result') { + const maxStat = Math.max(1, ...Object.values(result?.stats || {})); + return ( +
+

Ergebnis

+

Richtig: {result?.correctAnswer?.join(', ')}

+
+ {Object.entries(result?.stats || {}).map(([k, v]) => ( +
+ {v} +
+ {k} +
+ ))} +
+
+

🏆 Leaderboard

+
+ {leaderboard.slice(0, 8).map((e, i) => ( +
+ {e.rank}. {e.name} + {e.score} +
+ ))} +
+
+
+ ); + } + + if (phase === 'final') return ( +
+

🏁 Quiz beendet!

+
+ {leaderboard.map((e, i) => ( +
+ {i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${e.rank}.`} {e.name} + {e.score} +
+ ))} +
+
+ ); + + return
Verbinde...
; +} + +ReactDOM.createRoot(document.getElementById('root')).render(); \ No newline at end of file diff --git a/public/present.html b/public/present.html new file mode 100644 index 0000000..0c7f1d4 --- /dev/null +++ b/public/present.html @@ -0,0 +1,40 @@ + + + + + + + Quizalarm — Präsentation + + + + + + + + +
+ + + + \ No newline at end of file diff --git a/server/baserow.js b/server/baserow.js new file mode 100644 index 0000000..4837286 --- /dev/null +++ b/server/baserow.js @@ -0,0 +1,49 @@ +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 || {}), + }, + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`Baserow ${res.status}: ${body.substring(0, 200)}`); + } + return res.json(); +} + +async function listRows(tableId, filters = {}) { + let all = []; + let page = 1; + while (true) { + let url = `/database/rows/table/${tableId}/?user_field_names=true&size=200&page=${page}`; + for (const [field, value] of Object.entries(filters)) { + url += `&filter__${encodeURIComponent(field)}__equal=${encodeURIComponent(value)}`; + } + const data = await baserowFetch(url); + all = all.concat(data.results || []); + if (!data.next) break; + page++; + } + return all; +} + +async function batchCreateRows(tableId, rows) { + if (!rows.length) return; + // Baserow limit: 200 per batch + 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`, { + method: 'POST', + body: JSON.stringify({ items: chunk }), + }); + } +} + +module.exports = { listRows, batchCreateRows }; \ No newline at end of file diff --git a/server/config.js b/server/config.js new file mode 100644 index 0000000..ab6b4e9 --- /dev/null +++ b/server/config.js @@ -0,0 +1,33 @@ +const fs = require('fs'); +const path = require('path'); + +const CONFIG_PATH = process.env.CONFIG_PATH || '/data/config.json'; + +function getEnvConfig() { + return { + adminPassword: process.env.ADMIN_PASSWORD || 'admin', + baserowUrl: (process.env.BASEROW_URL || 'http://baserow:80').replace(/\/+$/, ''), + baserowToken: process.env.BASEROW_TOKEN || '', + port: parseInt(process.env.PORT || '7849', 10), + }; +} + +function readConfig() { + const defaults = { sets: [], answersTableId: null }; + try { + if (fs.existsSync(CONFIG_PATH)) { + return { ...defaults, ...JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) }; + } + } catch (e) { + console.error('[config] Fehler beim Lesen:', e.message); + } + return defaults; +} + +function writeConfig(data) { + const dir = path.dirname(CONFIG_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(CONFIG_PATH, JSON.stringify(data, null, 2), 'utf8'); +} + +module.exports = { getEnvConfig, readConfig, writeConfig }; \ No newline at end of file diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..b5b1f48 --- /dev/null +++ b/server/index.js @@ -0,0 +1,109 @@ +const express = require('express'); +const http = require('http'); +const path = require('path'); +const { getEnvConfig, readConfig, writeConfig } = require('./config'); +const { listRows, batchCreateRows } = require('./baserow'); +const { setupWebSocket, getSessions } = require('./live-session'); + +const app = express(); +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' }); + } + next(); +} + +/* ============================================================ */ +/* Public API */ +/* ============================================================ */ + +app.get('/api/sets', (req, res) => { + const config = readConfig(); + 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' }); + const rows = await listRows(set.tableId); + res.json(rows); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +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); + res.json({ success: true }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +app.get('/api/results/:name', async (req, res) => { + try { + const config = readConfig(); + if (!config.answersTableId) return res.json([]); + const rows = await listRows(config.answersTableId, { Nutzername: req.params.name }); + res.json(rows); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +/* ============================================================ */ +/* Admin API */ +/* ============================================================ */ + +app.post('/api/admin/login', (req, res) => { + if (req.body.password === getEnvConfig().adminPassword) { + res.json({ success: true, token: getEnvConfig().adminPassword }); + } else { + res.status(401).json({ error: 'Falsches Passwort' }); + } +}); + +app.get('/api/admin/config', requireAdmin, (req, res) => res.json(readConfig())); + +app.post('/api/admin/config', requireAdmin, (req, res) => { + 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) => { + try { + const config = readConfig(); + if (!config.answersTableId) return res.json({ answers: [] }); + const rows = await listRows(config.answersTableId); + res.json({ answers: rows }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +/* ---- 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] Laeuft auf Port ${port}`)); \ No newline at end of file diff --git a/server/live-session.js b/server/live-session.js new file mode 100644 index 0000000..838d55d --- /dev/null +++ b/server/live-session.js @@ -0,0 +1,409 @@ +const { WebSocket, WebSocketServer } = require('ws'); +const { getEnvConfig, readConfig } = require('./config'); +const { listRows, batchCreateRows } = require('./baserow'); +const { calculateScore, sanitizeQuestion, parseCorrectAnswers } = require('./quiz-logic'); + +const sessions = new Map(); + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +function generateCode() { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + let code = ''; + for (let i = 0; i < 6; i++) code += chars[Math.floor(Math.random() * chars.length)]; + return code; +} + +function send(ws, data) { + if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(data)); +} + +function broadcastAll(session, data, exclude) { + for (const [, p] of session.players) if (p.ws !== exclude) send(p.ws, data); + for (const ws of session.admins) if (ws !== exclude) send(ws, data); + for (const ws of session.presents) if (ws !== exclude) send(ws, data); +} + +function broadcastAdmins(session, data) { + for (const ws of session.admins) send(ws, data); + for (const ws of session.presents) send(ws, data); +} + +function broadcastPlayers(session, data) { + for (const [, p] of session.players) send(p.ws, data); +} + +function playerList(session) { + return Array.from(session.players.keys()); +} + +function leaderboard(session) { + return Array.from(session.players.entries()) + .map(([name, p]) => ({ name, score: p.score })) + .sort((a, b) => b.score - a.score) + .map((e, i) => ({ ...e, rank: i + 1 })); +} + +function sessionInfo(s) { + return { + code: s.code, + setName: s.setName, + status: s.status, + options: s.options, + questionCount: s.questions.length, + currentQuestionIndex: s.currentQuestionIndex, + playerCount: s.players.size, + players: playerList(s), + leaderboard: leaderboard(s), + }; +} + +function shuffle(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; +} + +function isAdmin(token) { + return token === getEnvConfig().adminPassword; +} + +/* ------------------------------------------------------------------ */ +/* WebSocket setup */ +/* ------------------------------------------------------------------ */ + +function setupWebSocket(server) { + const wss = new WebSocketServer({ server }); + + wss.on('connection', (ws) => { + ws.isAlive = true; + ws.on('pong', () => (ws.isAlive = true)); + ws.on('message', (raw) => { + try { + handleMessage(ws, JSON.parse(raw)); + } catch (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(); + ws.isAlive = false; + ws.ping(); + }); + }, 30000); +} + +/* ------------------------------------------------------------------ */ +/* Message router */ +/* ------------------------------------------------------------------ */ + +async function handleMessage(ws, msg) { + switch (msg.type) { + case 'admin-create': + return adminCreate(ws, msg); + case 'admin-join': + return adminJoin(ws, msg); + case 'admin-start': + return adminStart(ws, msg); + case 'admin-next': + return adminNext(ws, msg); + case 'admin-end': + return adminEnd(ws, msg); + case 'present-join': + return presentJoin(ws, msg); + case 'join': + return playerJoin(ws, msg); + case 'answer': + return playerAnswer(ws, msg); + default: + send(ws, { type: 'error', message: 'Unbekannter Typ' }); + } +} + +/* ------------------------------------------------------------------ */ +/* Admin handlers */ +/* ------------------------------------------------------------------ */ + +async function adminCreate(ws, msg) { + if (!isAdmin(msg.token)) return send(ws, { type: 'error', message: 'Nicht autorisiert' }); + + const config = readConfig(); + const set = config.sets.find((s) => s.id === msg.setId); + if (!set) return send(ws, { type: 'error', message: 'Fragenset nicht gefunden' }); + + let questions; + try { + questions = await listRows(set.tableId); + } catch (e) { + return send(ws, { type: 'error', message: 'Fehler beim Laden: ' + e.message }); + } + if (!questions.length) return send(ws, { type: 'error', message: 'Keine Fragen gefunden' }); + + const opts = { + shuffle: msg.options.shuffle !== false, + scoring: msg.options.scoring || 'binary', + timeLimit: parseInt(msg.options.timeLimit) || 30, + }; + + if (opts.shuffle) questions = shuffle(questions); + + const code = generateCode(); + const session = { + code, + sessionId: 'LIVE-' + Date.now().toString(36).toUpperCase(), + setId: set.id, + setName: set.name, + tableId: set.tableId, + options: opts, + questions, + currentQuestionIndex: -1, + status: 'waiting', + players: new Map(), + admins: new Set([ws]), + presents: new Set(), + currentAnswers: new Map(), + questionStartTime: null, + timer: null, + results: [], + }; + + sessions.set(code, session); + ws._qa = { code, role: 'admin' }; + + send(ws, { + type: 'session-created', + code, + setName: set.name, + questionCount: questions.length, + options: opts, + }); +} + +function adminJoin(ws, msg) { + if (!isAdmin(msg.token)) return send(ws, { type: 'error', message: 'Nicht autorisiert' }); + const s = sessions.get(msg.code); + if (!s) return send(ws, { type: 'error', message: 'Session nicht gefunden' }); + s.admins.add(ws); + ws._qa = { code: msg.code, role: 'admin' }; + send(ws, { type: 'admin-joined', session: sessionInfo(s) }); +} + +function presentJoin(ws, msg) { + if (!isAdmin(msg.token)) return send(ws, { type: 'error', message: 'Nicht autorisiert' }); + const s = sessions.get(msg.code); + if (!s) return send(ws, { type: 'error', message: 'Session nicht gefunden' }); + s.presents.add(ws); + ws._qa = { code: msg.code, role: 'present' }; + send(ws, { type: 'present-joined', session: sessionInfo(s) }); +} + +function adminStart(ws, msg) { + if (!isAdmin(msg.token)) return; + const s = sessions.get(msg.code); + if (!s || s.status !== 'waiting') return; + s.status = 'active'; + broadcastAll(s, { type: 'game-start', totalQuestions: s.questions.length }); + sendNextQuestion(s); +} + +function adminNext(ws, msg) { + if (!isAdmin(msg.token)) return; + const s = sessions.get(msg.code); + if (!s || s.status !== 'showing-result') return; + sendNextQuestion(s); +} + +async function adminEnd(ws, msg) { + if (!isAdmin(msg.token)) return; + const s = sessions.get(msg.code); + if (!s) return; + await endSession(s); +} + +/* ------------------------------------------------------------------ */ +/* Player handlers */ +/* ------------------------------------------------------------------ */ + +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' }); + + const name = (msg.name || '').trim(); + if (!name) return send(ws, { type: 'error', message: 'Name darf nicht leer sein' }); + + if (s.players.has(name)) { + const existing = s.players.get(name); + 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 + } else { + s.players.set(name, { ws, score: 0, answers: [] }); + } + + ws._qa = { code: msg.code, role: 'player', name }; + + send(ws, { type: 'joined', session: { code: s.code, setName: s.setName, playerCount: s.players.size, players: playerList(s) } }); + broadcastAll(s, { type: 'player-joined', name, playerCount: s.players.size, players: playerList(s) }, ws); +} + +function playerAnswer(ws, msg) { + const qa = ws._qa; + 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 + + 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 + + s.currentAnswers.set(qa.name, { answers: msg.answers || [], timeRemaining: remaining }); + broadcastAdmins(s, { type: 'answer-count', count: s.currentAnswers.size, total: s.players.size }); + send(ws, { type: 'answer-received' }); + + if (s.currentAnswers.size >= s.players.size) showResult(s); +} + +/* ------------------------------------------------------------------ */ +/* Game flow */ +/* ------------------------------------------------------------------ */ + +function sendNextQuestion(s) { + s.currentQuestionIndex++; + if (s.currentQuestionIndex >= s.questions.length) return endSession(s); + + s.status = 'active'; + s.currentAnswers.clear(); + s.questionStartTime = Date.now(); + + const q = s.questions[s.currentQuestionIndex]; + const tl = s.options.scoring === 'time' ? s.options.timeLimit : 0; + + broadcastAll(s, { + type: 'question', + index: s.currentQuestionIndex, + total: s.questions.length, + question: sanitizeQuestion(q), + timeLimit: tl, + }); + + clearTimeout(s.timer); + if (tl > 0) { + s.timer = setTimeout(() => { + if (s.status === 'active') broadcastAll(s, { type: 'time-up' }); + }, tl * 1000); + } +} + +function showResult(s) { + clearTimeout(s.timer); + s.status = 'showing-result'; + + const q = s.questions[s.currentQuestionIndex]; + 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); + player.score += result.points; + player.answers.push({ qId: q.id, idx: s.currentQuestionIndex, answers: ans.answers, correct: result.correct, points: result.points }); + ans.answers.forEach((a) => { if (stats[a] !== undefined) stats[a]++; }); + s.results.push({ + Nutzername: name, Frage_ID: q.id, Fragenset: s.setName, + Antwort: ans.answers.join(','), Richtig: result.correct, + Punkte: result.points, Session_ID: s.sessionId, + Modus: 'Live', Zeitstempel: new Date().toISOString(), + }); + } + + // 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 }); + s.results.push({ + Nutzername: name, Frage_ID: q.id, Fragenset: s.setName, + Antwort: '', Richtig: false, Punkte: 0, Session_ID: s.sessionId, + Modus: 'Live', Zeitstempel: new Date().toISOString(), + }); + } + } + + 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, { + type: 'question-result', correctAnswer: correct, stats, leaderboard: lb, + yourResult: { correct: last.correct, points: last.points, totalScore: player.score }, + }); + } + + broadcastAdmins(s, { type: 'question-result', correctAnswer: correct, stats, leaderboard: lb }); +} + +async function endSession(s) { + clearTimeout(s.timer); + s.status = 'ended'; + const lb = leaderboard(s); + broadcastAll(s, { type: 'game-end', leaderboard: lb, sessionId: s.sessionId }); + + try { + 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`); + } + } catch (e) { + console.error('[quizalarm] Fehler beim Speichern:', e.message); + } + + setTimeout(() => sessions.delete(s.code), 300000); +} + +/* ------------------------------------------------------------------ */ +/* Disconnect */ +/* ------------------------------------------------------------------ */ + +function handleDisconnect(ws) { + const qa = ws._qa; + if (!qa) return; + const s = sessions.get(qa.code); + if (!s) return; + + if (qa.role === 'admin') s.admins.delete(ws); + else if (qa.role === 'present') s.presents.delete(ws); + else if (qa.role === 'player') { + broadcastAll(s, { type: 'player-left', name: qa.name, playerCount: s.players.size, players: playerList(s) }); + } +} + +/* ------------------------------------------------------------------ */ +/* Exports */ +/* ------------------------------------------------------------------ */ + +function getSessions() { + return Array.from(sessions.values()).map(sessionInfo); +} + +module.exports = { setupWebSocket, getSessions }; \ No newline at end of file diff --git a/server/quiz-logic.js b/server/quiz-logic.js new file mode 100644 index 0000000..cc23e04 --- /dev/null +++ b/server/quiz-logic.js @@ -0,0 +1,126 @@ +/** + * Normalize text for freitext comparison. + * Removes special characters, lowercases, collapses whitespace. + * "Villingen-Schwenningen" == "villingen schwenningen" == "villingenschwenningen" + */ +function normalizeText(text) { + if (!text) return ''; + return text + .toLowerCase() + .replace(/[äÄ]/g, 'ae') + .replace(/[öÖ]/g, 'oe') + .replace(/[üÜ]/g, 'ue') + .replace(/ß/g, 'ss') + .replace(/[^a-z0-9]/g, '') + .trim(); +} + +function checkFreitext(userAnswer, correctAnswer) { + return normalizeText(userAnswer) === normalizeText(correctAnswer); +} + +function parseCorrectAnswers(question) { + const raw = (question['Richtige Antwort'] || '').trim(); + const typ = (question['Typ'] || 'MC').toLowerCase(); + if (typ === 'freitext') return [raw]; + return raw + .split(',') + .map((s) => s.trim().toUpperCase()) + .filter(Boolean); +} + +function countOptions(question) { + let n = 0; + if (question['Antwort A']) n++; + if (question['Antwort B']) n++; + if (question['Antwort C']) n++; + if (question['Antwort D']) n++; + return n; +} + +/** + * 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)); + basis = 500 + Math.round(ratio * 500); + } else { + basis = 1000; + } + + 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; + + let rightPicked = 0; + let wrongPicked = 0; + for (const s of selected) { + if (correctSet.has(s.toUpperCase())) rightPicked++; + else wrongPicked++; + } + + let points = (rightPicked / Math.max(totalCorrect, 1)) * basis; + if (totalIncorrect > 0) { + points -= (wrongPicked / totalIncorrect) * (basis / 2); + } + points = Math.max(0, Math.round(points)); + + const fullyCorrect = rightPicked === totalCorrect && wrongPicked === 0; + return { points, correct: fullyCorrect }; +} + +/** + * Strip the correct answer from a question before sending to participants. + */ +function sanitizeQuestion(question) { + const typ = (question['Typ'] || 'MC').toLowerCase(); + const sanitized = { + id: question.id, + frage: question['Frage'] || '', + typ: question['Typ'] || 'MC', + bild: null, + antworten: [], + 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 { + 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 }); + } + } + + return sanitized; +} + +module.exports = { calculateScore, sanitizeQuestion, parseCorrectAnswers, normalizeText }; \ No newline at end of file