From 4b6e43ba65dfffe12eb59d32d32bc7349ba34b9f Mon Sep 17 00:00:00 2001 From: orfelorfel23 Date: Thu, 16 Apr 2026 17:53:12 +0200 Subject: [PATCH] Massive feature update: Solo session recovery, immediate result saving, image proxy, player reconnection (Live), and UI/UX refinements --- BASEROW-SETUP.md | 92 ++++----- public/js/admin.js | 71 ++++--- public/js/app.js | 417 ++++++++++++++++++++++++----------------- public/js/present.js | 72 ++++--- server/baserow.js | 11 +- server/index.js | 182 +++++++++++++++--- server/live-session.js | 197 ++++++++----------- server/quiz-logic.js | 19 +- 8 files changed, 625 insertions(+), 436 deletions(-) diff --git a/BASEROW-SETUP.md b/BASEROW-SETUP.md index 839ef2c..9850d9f 100644 --- a/BASEROW-SETUP.md +++ b/BASEROW-SETUP.md @@ -28,17 +28,17 @@ Diese Tabelle speichert ALLE Ergebnisse (Live + Solo). Es gibt nur eine einzige. 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 | +| 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/...` @@ -55,17 +55,17 @@ Für jedes Fragenset wird eine eigene Tabelle erstellt. Der Aufbau ist immer ide 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` | +| 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/...` @@ -73,12 +73,12 @@ Für jedes Fragenset wird eine eigene Tabelle erstellt. Der Aufbau ist immer ide ## 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) | +| 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 @@ -102,39 +102,39 @@ Die App normalisiert Eingaben automatisch: ### 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 | +| 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 | +| 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 | +| 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 | +| 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 | +| 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 | +| 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 | --- diff --git a/public/js/admin.js b/public/js/admin.js index 4b9205c..3eaf7db 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -250,6 +250,8 @@ function LiveSetupPage({ config, token, onSessionCreated }) { ws.onmessage = (e) => { const msg = JSON.parse(e.data); if (msg.type === 'session-created') { + // IMPORTANT: Release WS control before handing off + ws.onmessage = null; onSessionCreated(msg.code, ws, { code: msg.code, setName: msg.setName, @@ -305,7 +307,7 @@ function LiveSetupPage({ config, token, onSessionCreated }) { } /* ================================================================== */ -/* Live Control Page (FIX: sends admin-join on mount) */ +/* Live Control Page */ /* ================================================================== */ function LiveControlPage({ token, code, ws, initialSession }) { @@ -314,19 +316,31 @@ function LiveControlPage({ token, code, ws, initialSession }) { const [answerCount, setAnswerCount] = useState(0); const [result, setResult] = useState(null); const [leaderboardData, setLeaderboardData] = useState([]); + const refreshRef = useRef(null); useEffect(() => { if (!ws) return; - const handler = (e) => { + // Take over the WS completely with onmessage (no addEventListener confusion) + ws.onmessage = (e) => { const msg = JSON.parse(e.data); - console.log('[admin-control] Received:', msg.type); + console.log('[admin-control] Received:', msg.type, msg); + switch (msg.type) { case 'session-created': - setSession({ code: msg.code, setName: msg.setName, questionCount: msg.questionCount, options: msg.options, players: [], playerCount: 0 }); + setSession({ + code: msg.code, + setName: msg.setName, + questionCount: msg.questionCount, + options: msg.options, + players: [], + playerCount: 0, + }); setPhase('waiting'); break; + case 'admin-joined': + console.log('[admin-control] Session synced:', msg.session.players, 'count:', msg.session.playerCount); setSession(msg.session); setLeaderboardData(msg.session.leaderboard || []); if (msg.session.status === 'waiting') setPhase('waiting'); @@ -334,69 +348,82 @@ function LiveControlPage({ token, code, ws, initialSession }) { else if (msg.session.status === 'showing-result') setPhase('result'); else setPhase('question'); break; + case 'player-joined': case 'player-left': + console.log('[admin-control] Player update:', msg.players, 'count:', msg.playerCount); setSession((s) => { - if (!s) return { code, players: msg.players, playerCount: msg.playerCount }; - return { ...s, players: msg.players, playerCount: msg.playerCount }; + const base = s || { code }; + return { ...base, players: msg.players, playerCount: msg.playerCount }; }); 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; + case 'error': - console.error('Admin error:', msg.message); + console.error('[admin-control] Error:', msg.message); break; } }; - ws.addEventListener('message', handler); + // Sync current session state from server + console.log('[admin-control] Sending admin-join for code:', code); + ws.send(JSON.stringify({ type: 'admin-join', 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 = () => { + // Safety net: periodically re-sync session state every 5 seconds while waiting + refreshRef.current = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'admin-join', token, code })); - ws.removeEventListener('open', onOpen); - }; - ws.addEventListener('open', onOpen); - } + } + }, 5000); - return () => ws.removeEventListener('message', handler); + return () => { + clearInterval(refreshRef.current); + ws.onmessage = null; + }; }, [ws, code, token]); + // Stop polling once game starts + useEffect(() => { + if (phase !== 'waiting') { + clearInterval(refreshRef.current); + } + }, [phase]); + const start = () => { - console.log('[admin-control] Sending admin-start'); + console.log('[admin-control] Starting game'); 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 })); }; diff --git a/public/js/app.js b/public/js/app.js index cf5763e..a84f577 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -13,23 +13,14 @@ const api = { }, }; -function wsUrl() { - return `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}`; -} +function wsUrl() { return `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}`; } function shuffleArray(arr) { const a = [...arr]; - for (let i = a.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [a[i], a[j]] = [a[j], a[i]]; - } + 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; } -/** - * 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; @@ -51,7 +42,7 @@ function ImageModal({ src, onClose }) { if (!src) return null; return (
- Vergroesserung +
); } @@ -88,44 +79,53 @@ function Leaderboard({ entries, highlight, compact }) { const ANSWER_COLORS = { A: 'answer-a', B: 'answer-b', C: 'answer-c', D: 'answer-d' }; -function AnswerGrid({ question, selected, onToggle, disabled }) { +function AnswerGrid({ question, selected, onToggle, disabled, feedback }) { const typ = (question.typ || 'MC').toLowerCase(); const isWF = typ === 'wahr/falsch'; if (typ === 'freitext') { + if (feedback) { + return ( +
+
+ {feedback.isCorrect ? '✓ ' : '✗ '}{selected[0] || '(keine Antwort)'} +
+ {!feedback.isCorrect && feedback.correctText && ( +
✓ {feedback.correctText}
+ )} +
+ ); + } return ( - onToggle([e.target.value])} - disabled={disabled} - autoFocus - /> + 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`; + const isSelected = selected.includes(a.key); + let cls, icon = ''; + + if (feedback) { + const isCorrectAnswer = feedback.correctKeys.includes(a.key); + if (isCorrectAnswer) { cls = 'bg-green-600 text-white ring-4 ring-green-400'; icon = '✓ '; } + else if (isSelected) { cls = 'bg-red-700 text-white ring-4 ring-red-400'; icon = '✗ '; } + else { cls = 'bg-gray-700 text-gray-500 opacity-40'; } + cls += ' font-bold py-4 px-6 rounded-xl text-lg text-center cursor-default'; + } else { + cls = `${ANSWER_COLORS[a.key]} ${isSelected ? 'selected' : ''} ${disabled ? 'disabled' : ''} text-white font-bold py-4 px-6 rounded-xl text-lg cursor-pointer transition-all text-center`; + } + return ( - ); })} @@ -133,6 +133,14 @@ function AnswerGrid({ question, selected, onToggle, disabled }) { ); } +function BackButton({ onClick, label }) { + return ( + + ); +} + /* ================================================================== */ /* Home Page */ /* ================================================================== */ @@ -161,12 +169,10 @@ 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

@@ -207,66 +213,28 @@ function LivePlayPage({ params, navigate }) { useEffect(() => { const ws = new WebSocket(wsUrl()); wsRef.current = ws; - - ws.onopen = () => { - ws.send(JSON.stringify({ type: 'join', code: params.code, name: params.name })); - }; - + 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 '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; + 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; + 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; + setLeaderboardData(msg.leaderboard); setPhase('final'); clearInterval(timerRef.current); break; + case 'error': setError(msg.message); setPhase('error'); break; } }; - - ws.onclose = () => { - if (phaseRef.current !== 'final' && phaseRef.current !== 'error') setPhase('disconnected'); - }; - + ws.onclose = () => { if (phaseRef.current !== 'final' && phaseRef.current !== 'error') setPhase('disconnected'); }; return () => { clearInterval(timerRef.current); ws.close(); }; }, []); @@ -275,8 +243,7 @@ function LivePlayPage({ params, navigate }) { 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); + const left = Math.max(0, timeLimit - (Date.now() - start) / 1000); setTimeLeft(left); if (left <= 0) clearInterval(timerRef.current); }, 100); @@ -289,6 +256,8 @@ function LivePlayPage({ params, navigate }) { setAnswered(true); }; + const leave = () => { if (wsRef.current) wsRef.current.close(); navigate('home'); }; + if (phase === 'error') return (

❌ {error}

@@ -298,8 +267,9 @@ function LivePlayPage({ params, navigate }) { if (phase === 'disconnected') return (
-

Verbindung verloren...

- +

Verbindung verloren

+

Du kannst mit demselben Namen wieder beitreten.

+
); @@ -307,6 +277,7 @@ function LivePlayPage({ params, navigate }) { if (phase === 'waiting') return (
+

⏳ Warteraum

{session?.setName}

{session?.playerCount || 0} Teilnehmer

@@ -321,33 +292,42 @@ function LivePlayPage({ params, navigate }) { if (phase === 'question') return (
-
Frage {qIndex + 1} / {qTotal}
+
+ + Frage {qIndex + 1} / {qTotal} +
{timeLimit > 0 && }

{question?.frage}

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

✓ Antwort gesendet — warte auf Ergebnis...

} setEnlargedImg(null)} />
); - if (phase === 'result') return ( -
-
- {result?.yourResult?.correct ? '✅ Richtig!' : '❌ Falsch'} + if (phase === 'result') { + const liveFeedback = result ? { + correctKeys: result.correctAnswer || [], + correctText: question?.typ?.toLowerCase() === 'freitext' ? (result.correctAnswer || [])[0] : null, + isCorrect: result.yourResult?.correct, + } : null; + return ( +
+
Frage {qIndex + 1} / {qTotal}
+

{question?.frage}

+ {question?.bild && } + {question && } +
+ {result?.yourResult?.correct ? '✅ Richtig!' : '❌ Falsch'} +
+

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

+

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

+ +

Warte auf nächste Frage...

-

+{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 (
@@ -361,7 +341,7 @@ function LivePlayPage({ params, navigate }) { } /* ================================================================== */ -/* Solo Select Page */ +/* Solo Select Page — with progress check */ /* ================================================================== */ function SoloSelectPage({ navigate }) { @@ -369,19 +349,51 @@ function SoloSelectPage({ navigate }) { const [name, setName] = useState(''); const [doShuffle, setDoShuffle] = useState(true); const [error, setError] = useState(''); + const [checking, setChecking] = useState(false); + const [progressDialog, setProgressDialog] = useState(null); - useEffect(() => { - api.get('/api/sets').then(setSets).catch((e) => setError(e.message)); - }, []); + useEffect(() => { api.get('/api/sets').then(setSets).catch((e) => setError(e.message)); }, []); - const start = (setId, setName2) => { + const startSet = async (setId, setName2) => { if (!name.trim()) return setError('Bitte Name eingeben'); - navigate('solo-quiz', { setId, setName: setName2, name: name.trim(), shuffle: doShuffle }); + setChecking(true); + setError(''); + try { + const progress = await api.get(`/api/progress/${encodeURIComponent(name.trim())}/${setId}`); + if (progress.hasProgress) { + setProgressDialog({ setId, setName: setName2, ...progress }); + } else { + navigate('solo-quiz', { setId, setName: setName2, name: name.trim(), shuffle: doShuffle }); + } + } catch (e) { + // If progress check fails, just start fresh + navigate('solo-quiz', { setId, setName: setName2, name: name.trim(), shuffle: doShuffle }); + } + setChecking(false); + }; + + const continueSession = () => { + navigate('solo-quiz', { + setId: progressDialog.setId, setName: progressDialog.setName, + name: name.trim(), shuffle: doShuffle, + continueSessionId: progressDialog.sessionId, + priorAnswers: progressDialog.answers, + }); + setProgressDialog(null); + }; + + const startFresh = () => { + navigate('solo-quiz', { + setId: progressDialog.setId, setName: progressDialog.setName, + name: name.trim(), shuffle: doShuffle, + }); + setProgressDialog(null); }; return (
-

📚 Solo-Lernen

+
navigate('home')} label="Zurück" />
+

📚 Solo-Lernen

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

{error}

} -

Fragenset wählen:

- {sets.length === 0 &&

Keine Fragensets konfiguriert. Bitte im Admin-Bereich einrichten.

} - {sets.map((s) => ( - - ))} - + + {/* Progress dialog */} + {progressDialog && ( +
+

📌 Gespeicherter Fortschritt

+

{progressDialog.setName}: {progressDialog.totalAnswered} / {progressDialog.totalQuestions} Fragen beantwortet

+

{progressDialog.totalCorrect} davon richtig

+
+ + +
+
+ )} + + {!progressDialog && ( + <> +

Fragenset wählen:

+ {sets.length === 0 &&

Keine Fragensets konfiguriert.

} + {sets.map((s) => ( + + ))} + + )}
); } /* ================================================================== */ -/* Solo Quiz Page */ +/* Solo Quiz Page — immediate save per answer + continue support */ /* ================================================================== */ function SoloQuizPage({ params, navigate }) { + const [allQuestions, setAllQuestions] = useState([]); 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 [correctKeys, setCorrectKeys] = useState([]); + const [correctText, setCorrectText] = useState(''); const [results, setResults] = useState([]); + const [priorResults] = useState(params.priorAnswers || []); const [error, setError] = useState(''); const [enlargedImg, setEnlargedImg] = useState(null); const [shuffledAnswers, setShuffledAnswers] = useState([]); - const sessionId = useRef('SOLO-' + Date.now().toString(36).toUpperCase()); + const [saving, setSaving] = useState(false); + const sessionId = useRef(params.continueSessionId || 'SOLO-' + Date.now().toString(36).toUpperCase()); + const priorIds = useRef(new Set((params.priorAnswers || []).map((a) => a.Frage_ID))); 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)); - [qs[i], qs[j]] = [qs[j], qs[i]]; - } + setAllQuestions(qs); + // Filter out already answered questions + let remaining = qs.filter((q) => !priorIds.current.has(q.id)); + if (params.shuffle && remaining.length > 0) { + remaining = shuffleArray(remaining); } - setQuestions(qs); + setQuestions(remaining); }).catch((e) => setError(e.message)); }, []); @@ -434,13 +470,27 @@ function SoloQuizPage({ params, navigate }) { if (questions.length === 0 || index >= questions.length) return; const q = questions[index]; const answers = []; - ['A', 'B', 'C', 'D'].forEach((k) => { - if (q[`Antwort ${k}`]) answers.push({ key: k, text: q[`Antwort ${k}`] }); - }); + ['A', 'B', 'C', 'D'].forEach((k) => { if (q[`Antwort ${k}`]) answers.push({ key: k, text: q[`Antwort ${k}`] }); }); setShuffledAnswers(shuffleArray(answers)); }, [index, questions]); if (error) return
{error}
; + if (!questions.length && allQuestions.length > 0) { + // All questions already answered → go straight to results + const allResults = priorResults.map((a) => ({ Frage_ID: a.Frage_ID, Richtig: a.Richtig, Punkte: a.Punkte })); + return ( +
+

✅ Alle Fragen bereits beantwortet!

+
+ + + +
+
+ ); + } if (!questions.length) return
Lade Fragen...
; const q = questions[index]; @@ -452,11 +502,18 @@ function SoloQuizPage({ params, navigate }) { antworten: shuffledAnswers, }; + const feedback = showFeedback ? { + correctKeys, correctText: typ === 'freitext' ? correctText : null, isCorrect, + } : null; + + const totalProgress = priorIds.current.size + index + 1; + const totalAll = allQuestions.length; + const checkAnswer = () => { const rawCorrect = (q['Richtige Antwort'] || '').trim(); let correct, ok; if (typ === 'freitext') { - correct = [rawCorrect]; + correct = []; setCorrectText(rawCorrect); ok = normalizeText(selected[0]) === normalizeText(rawCorrect); } else { correct = rawCorrect.split(',').map((s) => s.trim().toUpperCase()).filter(Boolean); @@ -465,44 +522,58 @@ function SoloQuizPage({ params, navigate }) { ok = correct.length === selectedSet.size && correct.every((c) => selectedSet.has(c)); } - setCorrectAnswer(correct); + setCorrectKeys(correct); setIsCorrect(ok); setShowFeedback(true); - const newResults = [...results, { - 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(), - }]; - setResults(newResults); + const answerText = selected.map((key) => { + const ans = shuffledAnswers.find((a) => a.key === key); + return ans ? ans.text : key; + }).join(', '); + + const answerData = { + Nutzername: params.name, Frage_ID: q.id, Fragenset: params.setName, + Antwort: answerText, Richtig: ok, Punkte: ok ? 1000 : 0, + Session_ID: sessionId.current, Modus: 'Solo', Zeitstempel: new Date().toISOString(), + }; + + // Save immediately to Baserow + setSaving(true); + api.post('/api/results/single', answerData) + .catch((e) => console.error('Save error:', e)) + .finally(() => setSaving(false)); + + setResults((prev) => [...prev, { Frage_ID: q.id, Richtig: ok, Punkte: ok ? 1000 : 0 }]); }; const nextQuestion = () => { if (index + 1 >= questions.length) { - api.post('/api/results', { answers: results }).catch((e) => console.error('Save error:', e)); - navigate('solo-result', { results, setName: params.setName, name: params.name, questions }); + const allResults = [ + ...priorResults.map((a) => ({ Frage_ID: a.Frage_ID, Richtig: a.Richtig, Punkte: a.Punkte })), + ...results, + ]; + navigate('solo-result', { results: allResults, setName: params.setName, name: params.name, questions: allQuestions }); return; } setIndex(index + 1); setSelected([]); setShowFeedback(false); + setCorrectKeys([]); + setCorrectText(''); }; return (
-
Frage {index + 1} / {questions.length}
+
+ navigate('home')} /> + Frage {totalProgress} / {totalAll} +
-
+

{sanitized.frage}

{sanitized.bild && setEnlargedImg(sanitized.bild)} />} - + {!showFeedback && ( )} @@ -511,8 +582,8 @@ function SoloQuizPage({ params, navigate }) {
{isCorrect ? '✅ Richtig!' : '❌ Falsch'}
- {!isCorrect &&

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

} -
@@ -523,7 +594,7 @@ function SoloQuizPage({ params, navigate }) { } /* ================================================================== */ -/* Solo Result Page */ +/* Solo Result Page — matches by Frage_ID */ /* ================================================================== */ function SoloResultPage({ params, navigate }) { @@ -532,9 +603,13 @@ function SoloResultPage({ params, navigate }) { const correct = results.filter((r) => r.Richtig).length; const pct = total > 0 ? Math.round((correct / total) * 100) : 0; + // Map questions by ID for category lookup + const qMap = {}; + (questions || []).forEach((q) => { qMap[q.id] = q; }); + const byCategory = {}; - results.forEach((r, i) => { - const q = questions[i]; + results.forEach((r) => { + const q = qMap[r.Frage_ID]; const cat = fieldVal(q?.['Kategorie'], 'Ohne Kategorie') || 'Ohne Kategorie'; if (!byCategory[cat]) byCategory[cat] = { total: 0, correct: 0 }; byCategory[cat].total++; @@ -579,8 +654,7 @@ function MyResultsPage({ navigate }) { if (!name.trim()) return; setError(''); api.get(`/api/results/${encodeURIComponent(name.trim())}`) - .then(setResults) - .catch((e) => setError(e.message)); + .then(setResults).catch((e) => setError(e.message)); }; const sessions = {}; @@ -594,7 +668,8 @@ function MyResultsPage({ navigate }) { return (
-

📊 Meine Ergebnisse

+
navigate('home')} label="Zurück" />
+

📊 Meine Ergebnisse

setName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && search()} /> @@ -605,7 +680,7 @@ function MyResultsPage({ navigate }) {
{Object.entries(sessions).reverse().map(([sid, s]) => { const total = s.answers.length; - const correct = s.answers.filter((a) => a['Richtig']).length; + const cor = s.answers.filter((a) => a['Richtig']).length; const points = s.answers.reduce((sum, a) => sum + (a['Punkte'] || 0), 0); return (
@@ -616,13 +691,12 @@ function MyResultsPage({ navigate }) {
{s.date ? new Date(s.date).toLocaleDateString('de') : ''}
-

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

+

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

); })}
)} -
); } @@ -634,12 +708,7 @@ function MyResultsPage({ navigate }) { function App() { const [page, setPage] = useState('home'); const [params, setParams] = useState({}); - - const navigate = useCallback((p, par = {}) => { - setPage(p); - setParams(par); - window.scrollTo(0, 0); - }, []); + const navigate = useCallback((p, par = {}) => { setPage(p); setParams(par); window.scrollTo(0, 0); }, []); switch (page) { case 'home': return ; diff --git a/public/js/present.js b/public/js/present.js index ecd2a45..85f04f5 100644 --- a/public/js/present.js +++ b/public/js/present.js @@ -4,8 +4,6 @@ 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); @@ -19,7 +17,6 @@ function PresentApp() { const [leaderboard, setLeaderboard] = useState([]); const [error, setError] = useState(''); const timerRef = useRef(null); - const wsRef = useRef(null); useEffect(() => { const code = location.hash.replace('#', ''); @@ -32,7 +29,6 @@ function PresentApp() { } const ws = new WebSocket(wsUrl()); - wsRef.current = ws; ws.onopen = () => { ws.send(JSON.stringify({ type: 'present-join', token, code })); @@ -88,7 +84,6 @@ function PresentApp() { }; ws.onerror = () => setPhase('error'); - ws.onclose = () => { if (phase !== 'final') setPhase('error'); }; return () => ws.close(); }, []); @@ -106,9 +101,9 @@ function PresentApp() { if (phase === 'need-config') { return (
- Quizalarm +
🚨

Quizalarm — Präsentation

-

Öffne diesen Link aus dem Admin-Panel.
Die URL muss ?token=PASSWORT#CODE enthalten.

+

Öffne diesen Link aus dem Admin-Panel.

); } @@ -124,7 +119,7 @@ function PresentApp() { if (phase === 'waiting') return (
- Quizalarm +
🚨

Quizalarm

{session?.setName}

{session?.code}
@@ -152,15 +147,18 @@ function PresentApp() {
)} -

{question?.frage}

+

{question?.frage}

{question?.bild && } {question?.typ?.toLowerCase() !== 'freitext' && (
- {(question?.antworten || []).map((a) => ( -
- {a.text} -
- ))} + {(question?.antworten || []).map((a) => { + const colors = { A: 'bg-red-600', B: 'bg-blue-600', C: 'bg-amber-600', D: 'bg-green-600' }; + return ( +
+ {a.text} +
+ ); + })}
)} {question?.typ?.toLowerCase() === 'freitext' && ( @@ -171,20 +169,44 @@ function PresentApp() { } if (phase === 'result') { - const maxStat = Math.max(1, ...Object.values(result?.stats || {})); + const correctKeys = new Set(result?.correctAnswer || []); return (
-

Ergebnis

-

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

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

Frage {qIndex + 1} / {qTotal}

+

{question?.frage}

+ {question?.bild && } + + {/* Answer options with feedback colors + vote counts */} + {question?.typ?.toLowerCase() !== 'freitext' && ( +
+ {(question?.antworten || []).map((a) => { + const isCorrect = correctKeys.has(a.key); + const count = result?.stats?.[a.key] || 0; + let cls; + if (isCorrect) { + cls = 'bg-green-600 ring-4 ring-green-400'; + } else { + cls = 'bg-gray-700 opacity-50'; + } + return ( +
+ {isCorrect ? '✓ ' : ''}{a.text} + {count} +
+ ); + })} +
+ )} + + {question?.typ?.toLowerCase() === 'freitext' && ( +
+
+ ✓ {result?.correctAnswer?.[0]}
- ))} -
+
+ )} + + {/* Leaderboard */}

🏆 Leaderboard

diff --git a/server/baserow.js b/server/baserow.js index 7192e80..683bdb1 100644 --- a/server/baserow.js +++ b/server/baserow.js @@ -9,7 +9,6 @@ async function baserowFetch(endpoint, options = {}) { Authorization: `Token ${baserowToken}`, ...(options.headers || {}), }; - // Only set Content-Type for requests with body if (options.body) { headers['Content-Type'] = 'application/json'; } @@ -42,6 +41,14 @@ async function listRows(tableId, filters = {}) { return all; } +async function createRow(tableId, row) { + console.log(`[baserow] Create row in table ${tableId}`); + return baserowFetch(`/database/rows/table/${tableId}/?user_field_names=true`, { + method: 'POST', + body: JSON.stringify(row), + }); +} + async function batchCreateRows(tableId, rows) { if (!rows.length) return; console.log(`[baserow] Batch create ${rows.length} rows in table ${tableId}`); @@ -54,4 +61,4 @@ async function batchCreateRows(tableId, rows) { } } -module.exports = { listRows, batchCreateRows }; \ No newline at end of file +module.exports = { listRows, createRow, batchCreateRows }; \ No newline at end of file diff --git a/server/index.js b/server/index.js index 65c0abf..acb10a6 100644 --- a/server/index.js +++ b/server/index.js @@ -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())}`); }); \ No newline at end of file diff --git a/server/live-session.js b/server/live-session.js index 40e3beb..46876c1 100644 --- a/server/live-session.js +++ b/server/live-session.js @@ -27,10 +27,6 @@ function broadcastAdmins(session, 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()); } @@ -44,15 +40,9 @@ function leaderboard(session) { 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), + 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), }; } @@ -69,27 +59,17 @@ 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) { - console.error('[ws] Message error:', e); - send(ws, { type: 'error', message: 'Ungueltige Nachricht' }); - } + try { handleMessage(ws, JSON.parse(raw)); } + catch (e) { console.error('[ws] Error:', e); send(ws, { type: 'error', message: 'Ungueltige Nachricht' }); } }); ws.on('close', () => handleDisconnect(ws)); }); - setInterval(() => { wss.clients.forEach((ws) => { if (!ws.isAlive) return ws.terminate(); @@ -99,98 +79,54 @@ function setupWebSocket(server) { }, 30000); } -/* ------------------------------------------------------------------ */ -/* Message router */ -/* ------------------------------------------------------------------ */ - async function handleMessage(ws, msg) { - console.log(`[ws] Received: ${msg.type}`); 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-show-result': - return adminShowResult(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: ' + 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-show-result': return adminShowResult(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 */ -/* ------------------------------------------------------------------ */ +/* ---- Admin ---- */ 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); - 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) { - console.error('[live] Baserow error:', e.message); - return send(ws, { type: 'error', message: 'Fehler beim Laden der Fragen: ' + e.message }); - } - if (!questions.length) return send(ws, { type: 'error', message: 'Keine Fragen in dieser Tabelle gefunden' }); + try { questions = await listRows(set.tableId); } + catch (e) { return send(ws, { type: 'error', message: 'Fehler: ' + 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: [], + 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, + currentSanitized: 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, - }); + send(ws, { type: 'session-created', code, setName: set.name, questionCount: questions.length, options: opts }); } function adminJoin(ws, msg) { @@ -224,7 +160,6 @@ 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); } @@ -242,31 +177,65 @@ async function adminEnd(ws, msg) { await endSession(s); } -/* ------------------------------------------------------------------ */ -/* Player handlers */ -/* ------------------------------------------------------------------ */ +/* ---- Player ---- */ function playerJoin(ws, msg) { const s = sessions.get(msg.code); - 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.' }); + if (!s) return send(ws, { type: 'error', message: 'Session nicht gefunden.' }); const name = (msg.name || '').trim(); if (!name) return send(ws, { type: 'error', message: 'Name darf nicht leer sein' }); + // --- Reconnect existing player --- 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.' }); } + + // Reconnect existing.ws = ws; - } 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) }, + }); + + // Catch up to current game state + if (s.status === 'active' && s.currentSanitized) { + const tl = s.options.scoring === 'time' ? s.options.timeLimit : 0; + send(ws, { type: 'game-start', totalQuestions: s.questions.length }); + send(ws, { + type: 'question', index: s.currentQuestionIndex, total: s.questions.length, + question: s.currentSanitized, timeLimit: tl, + }); + if (s.currentAnswers.has(name)) { + send(ws, { type: 'answer-received' }); + } + } else if (s.status === 'showing-result') { + send(ws, { type: 'game-start', totalQuestions: s.questions.length }); + // They'll see the next question when master clicks next + } else if (s.status === 'ended') { + send(ws, { type: 'game-end', leaderboard: leaderboard(s), sessionId: s.sessionId }); + } + + broadcastAll(s, { type: 'player-joined', name, playerCount: s.players.size, players: playerList(s) }, ws); + return; } + // --- New player: only allowed in waiting room --- + if (s.status !== 'waiting') { + return send(ws, { type: 'error', message: 'Session laeuft bereits. Nur bestehende Teilnehmer koennen wieder beitreten.' }); + } + + 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) } }); + 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); } @@ -280,7 +249,6 @@ function playerAnswer(ws, msg) { 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; s.currentAnswers.set(qa.name, { answers: msg.answers || [], timeRemaining: remaining }); @@ -290,9 +258,7 @@ function playerAnswer(ws, msg) { if (s.currentAnswers.size >= s.players.size) showResult(s); } -/* ------------------------------------------------------------------ */ -/* Game flow */ -/* ------------------------------------------------------------------ */ +/* ---- Game flow ---- */ function sendNextQuestion(s) { s.currentQuestionIndex++; @@ -305,15 +271,11 @@ 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}`); + s.currentSanitized = sanitized; // Store for reconnecting players broadcastAll(s, { - type: 'question', - index: s.currentQuestionIndex, - total: s.questions.length, - question: sanitized, - timeLimit: tl, + type: 'question', index: s.currentQuestionIndex, total: s.questions.length, + question: sanitized, timeLimit: tl, }); clearTimeout(s.timer); @@ -383,32 +345,23 @@ async function endSession(s) { const config = readConfig(); if (config.answersTableId && s.results.length) { await batchCreateRows(config.answersTableId, s.results); - 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`); + console.log(`[live] Session ${s.code}: ${s.results.length} Ergebnisse gespeichert`); } } catch (e) { - console.error('[live] Fehler beim Speichern:', e.message); + console.error('[live] Speicherfehler:', 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) }); - } + // Players stay in the session (they can reconnect) } function getSessions() { diff --git a/server/quiz-logic.js b/server/quiz-logic.js index bc0b0f4..c5e3925 100644 --- a/server/quiz-logic.js +++ b/server/quiz-logic.js @@ -1,7 +1,3 @@ -/** - * Normalize text for freitext comparison. - * "Villingen-Schwenningen" == "villingen schwenningen" == "villingenschwenningen" - */ function normalizeText(text) { if (!text) return ''; return text @@ -14,10 +10,6 @@ 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; @@ -61,9 +53,6 @@ function shuffleArray(arr) { return a; } -/** - * Calculate score for an answer. - */ function calculateScore(question, selected, timeRemaining, totalTime, scoringMode) { if (!selected || selected.length === 0) return { points: 0, correct: false }; @@ -104,9 +93,6 @@ function calculateScore(question, selected, timeRemaining, totalTime, scoringMod return { points, correct: fullyCorrect }; } -/** - * Strip the correct answer and shuffle options before sending to participants. - */ function sanitizeQuestion(question) { const typ = getTyp(question); const sanitized = { @@ -118,9 +104,10 @@ function sanitizeQuestion(question) { kategorie: extractFieldValue(question['Kategorie'], ''), }; + // Rewrite image URL to use proxy const bild = question['Bild']; - if (bild && Array.isArray(bild) && bild.length > 0) { - sanitized.bild = bild[0].url; + if (bild && Array.isArray(bild) && bild.length > 0 && bild[0].url) { + sanitized.bild = `/api/img?url=${encodeURIComponent(bild[0].url)}`; } if (typ !== 'freitext') {