From e993eef125229c01efa9cb7e39ddf734af8c031a Mon Sep 17 00:00:00 2001 From: orfelorfel23 Date: Thu, 16 Apr 2026 06:54:54 +0200 Subject: [PATCH] UI overhaul: fire-gradient theme and visual polish; logic: added answer shuffling and improved Baserow error handling --- public/admin.html | 4 +- public/index.html | 38 +++++++---- public/js/admin.js | 136 +++++++++++++++++++++++---------------- public/js/app.js | 142 +++++++++++++++++++++++------------------ public/js/present.js | 54 ++++++++-------- public/present.html | 22 +++++-- server/baserow.js | 28 +++++--- server/index.js | 47 ++++++++++++-- server/live-session.js | 57 ++++++++++------- server/quiz-logic.js | 30 ++++----- 10 files changed, 342 insertions(+), 216 deletions(-) diff --git a/public/admin.html b/public/admin.html index 8305ebf..3bc4fd5 100644 --- a/public/admin.html +++ b/public/admin.html @@ -5,13 +5,15 @@ Quizalarm — Admin + - +
diff --git a/public/index.html b/public/index.html index fd4a094..87ef1f9 100644 --- a/public/index.html +++ b/public/index.html @@ -5,6 +5,8 @@ Quizalarm + @@ -12,38 +14,41 @@ - +
diff --git a/public/js/admin.js b/public/js/admin.js index b4cb3c3..af3336b 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -33,12 +33,13 @@ function LoginPage({ onLogin }) { } catch (e) { setErr(e.message); } }; return ( -
-
-

🔐 Admin-Login

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

Quizalarm Admin

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

{err}

} - +
); @@ -52,6 +53,7 @@ function ConfigPage({ config, setConfig, saveConfig }) { const [newSetName, setNewSetName] = useState(''); const [newSetTableId, setNewSetTableId] = useState(''); const [msg, setMsg] = useState(''); + const [debugResult, setDebugResult] = useState(null); const addSet = () => { if (!newSetName || !newSetTableId) return; @@ -63,8 +65,8 @@ function ConfigPage({ config, setConfig, saveConfig }) { saveConfig(updated); setNewSetName(''); setNewSetTableId(''); - setMsg('Fragenset hinzugefügt'); - setTimeout(() => setMsg(''), 2000); + setMsg('Fragenset hinzugefügt!'); + setTimeout(() => setMsg(''), 3000); }; const removeSet = (id) => { @@ -79,10 +81,21 @@ function ConfigPage({ config, setConfig, saveConfig }) { saveConfig(updated); }; + const testConnection = async () => { + try { + setDebugResult('Teste...'); + const result = await api.get('/api/admin/debug-baserow'); + setDebugResult(JSON.stringify(result, null, 2)); + } catch (e) { + setDebugResult('Fehler: ' + e.message); + } + }; + return (
-
+

📋 Fragensets

+ {(config.sets || []).length === 0 &&

Noch keine Fragensets konfiguriert.

} {(config.sets || []).map((s) => (
{s.name} (Table ID: {s.tableId})
@@ -90,19 +103,28 @@ function ConfigPage({ config, setConfig, saveConfig }) {
))}
- setNewSetName(e.target.value)} /> - setNewSetTableId(e.target.value)} /> - + setNewSetName(e.target.value)} /> + setNewSetTableId(e.target.value)} /> +
{msg &&

{msg}

}
-
+ +

💾 Antworten-Tabelle

Table ID: - updateAnswersTable(e.target.value)} placeholder="Table ID" /> + updateAnswersTable(e.target.value)} placeholder="Table ID" />
+ +
+

🔧 Verbindungstest

+ + {debugResult && ( +
{debugResult}
+ )} +
); } @@ -114,15 +136,16 @@ function ConfigPage({ config, setConfig, saveConfig }) { function StatsPage() { const [data, setData] = useState(null); const [filter, setFilter] = useState(''); + const [err, setErr] = useState(''); useEffect(() => { - api.get('/api/admin/stats').then(setData).catch(console.error); + api.get('/api/admin/stats').then(setData).catch((e) => setErr(e.message)); }, []); + if (err) return

{err}

; 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'; @@ -130,7 +153,6 @@ function StatsPage() { sessions[sid].answers.push(a); }); - // Group by user const users = {}; data.answers.forEach((a) => { const u = a['Nutzername'] || 'Unbekannt'; @@ -145,16 +167,16 @@ function StatsPage() {

📊 Übersicht

-
-
{Object.keys(sessions).length}
+
+
{Object.keys(sessions).length}
Sessions
-
-
{Object.keys(users).length}
+
+
{Object.keys(users).length}
Teilnehmer
-
-
{data.answers.length}
+
+
{data.answers.length}
Antworten
@@ -162,7 +184,7 @@ function StatsPage() {

👤 Pro Teilnehmer

- setFilter(e.target.value)} /> + setFilter(e.target.value)} />
@@ -193,7 +215,7 @@ function StatsPage() { return (
- {s.modus} {s.fragenset} + {s.modus} {s.fragenset} {s.date ? new Date(s.date).toLocaleString('de') : ''}
{uniqueUsers} Teilnehmer — {correct}/{total} richtig gesamt
@@ -235,27 +257,27 @@ function LiveSetupPage({ config, token, onSessionCreated }) { ws.close(); } }; - ws.onerror = () => { setError('WebSocket Fehler'); setLoading(false); }; + ws.onerror = () => { setError('WebSocket Verbindungsfehler'); setLoading(false); }; }; return ( -
+

🎮 Live-Session erstellen

- setSetId(e.target.value)}> {(config.sets || []).map((s) => )}
- setScoring(e.target.value)}> @@ -263,11 +285,11 @@ function LiveSetupPage({ config, token, onSessionCreated }) { {scoring === 'time' && (
- setTimeLimit(parseInt(e.target.value) || 30)} min={5} max={120} /> + setTimeLimit(parseInt(e.target.value) || 30)} min={5} max={120} />
)} - {error &&

{error}

} -
@@ -293,7 +315,7 @@ function LiveControlPage({ token, code, ws }) { 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 }); + setSession({ code: msg.code, setName: msg.setName, questionCount: msg.questionCount, options: msg.options, players: [] }); break; case 'admin-joined': setSession(msg.session); @@ -326,6 +348,9 @@ function LiveControlPage({ token, code, ws }) { setLeaderboardData(msg.leaderboard); setPhase('ended'); break; + case 'error': + console.error('Admin error:', msg.message); + break; } }; @@ -334,30 +359,27 @@ function LiveControlPage({ token, code, ws }) { }, [ws]); const start = () => ws.send(JSON.stringify({ type: 'admin-start', token, code })); + const showResultAction = () => ws.send(JSON.stringify({ type: 'admin-show-result', token, code })); const next = () => ws.send(JSON.stringify({ type: 'admin-next', token, code })); const end = () => ws.send(JSON.stringify({ type: 'admin-end', token, code })); - 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}`; + const presentUrl = `${location.origin}/present?token=${encodeURIComponent(token)}#${code}`; return (
-
-

Session-Code

-
{code}
+
+

Session-Code

+
{code}

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

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

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

- {(session?.players || []).map((n) => {n})} + {(session?.players || []).map((n) => {n})}
+ {(!session?.players || session.players.length === 0) &&

Noch keine Teilnehmer...

}
{phase === 'waiting' && ( @@ -368,8 +390,8 @@ function LiveControlPage({ token, code, ws }) { {phase === 'question' && (
-

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

- +

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

+
)} @@ -385,30 +407,32 @@ function LiveControlPage({ token, code, ws }) { ))}
)} -

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

- +

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

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

🏁 Quiz beendet

+

Ergebnisse wurden gespeichert.

)} {leaderboardData.length > 0 && (

🏆 Leaderboard

- {leaderboardData.map((e) => ( -
- {e.rank}. {e.name}{e.score} + {leaderboardData.map((e, i) => ( +
+ {i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${e.rank}.`} {e.name} + {e.score}
))}
)} {phase !== 'ended' && phase !== 'waiting' && ( - + )}
); @@ -452,15 +476,15 @@ function AdminApp() { if (liveCode) tabs.splice(2, 0, { id: 'live-control', label: '🎛️ Steuerung' }); return ( -
-
+
+
-

⚡ Quizalarm Admin

+

🚨 Quizalarm Admin

← Zur App
{tabs.map((t) => ( - + ))}
diff --git a/public/js/app.js b/public/js/app.js index cd3edff..0227b90 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1,8 +1,5 @@ const { useState, useEffect, useRef, useCallback } = React; -/* ================================================================== */ -/* API Helper */ -/* ================================================================== */ const api = { async get(url) { const r = await fetch(url); @@ -20,6 +17,15 @@ 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]]; + } + return a; +} + /* ================================================================== */ /* Shared Components */ /* ================================================================== */ @@ -27,8 +33,8 @@ function wsUrl() { function ImageModal({ src, onClose }) { if (!src) return null; return ( -
- Vergrößerung +
+ Vergroesserung
); } @@ -38,9 +44,11 @@ function Timer({ timeLeft, total }) { 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
+
+
+
+
+
{Math.ceil(timeLeft)}s
); } @@ -49,11 +57,11 @@ 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} + {show.map((e, i) => ( +
+ {i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${e.rank}.`} {e.name} {e.score}
))} @@ -71,7 +79,7 @@ function AnswerGrid({ question, selected, onToggle, disabled }) { return ( onToggle([e.target.value])} @@ -100,7 +108,7 @@ function AnswerGrid({ question, selected, onToggle, disabled }) { } }} > - {a.key} {a.text} + {a.text} ); })} @@ -115,11 +123,12 @@ function AnswerGrid({ question, selected, onToggle, disabled }) { function HomePage({ navigate }) { return (
-

⚡ Quizalarm

+
🚨
+

Quizalarm

Lernen. Quizzen. Wissen.

- - + +
Admin-Bereich → @@ -145,10 +154,10 @@ function JoinPage({ navigate }) {

🎮 Live-Quiz beitreten

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

{error}

} - +
@@ -175,6 +184,8 @@ function LivePlayPage({ params, navigate }) { const [enlargedImg, setEnlargedImg] = useState(null); const wsRef = useRef(null); const timerRef = useRef(null); + const phaseRef = useRef(phase); + phaseRef.current = phase; useEffect(() => { const ws = new WebSocket(wsUrl()); @@ -236,13 +247,12 @@ function LivePlayPage({ params, navigate }) { }; ws.onclose = () => { - if (phase !== 'final' && phase !== 'error') setPhase('disconnected'); + if (phaseRef.current !== 'final' && phaseRef.current !== 'error') setPhase('disconnected'); }; return () => { clearInterval(timerRef.current); ws.close(); }; }, []); - // Timer countdown useEffect(() => { clearInterval(timerRef.current); if (phase === 'question' && timeLimit > 0 && !answered) { @@ -276,7 +286,7 @@ function LivePlayPage({ params, navigate }) {
); - if (phase === 'connecting') return

Verbinde...

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

Verbinde...

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

{session?.playerCount || 0} Teilnehmer

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

Warte auf den Start...

@@ -300,9 +310,9 @@ function LivePlayPage({ params, navigate }) { {question?.bild && setEnlargedImg(question.bild)} />} {!answered && ( - + )} - {answered &&

✓ Antwort gesendet — warte auf Ergebnis...

} + {answered &&

✓ Antwort gesendet — warte auf Ergebnis...

} setEnlargedImg(null)} />
); @@ -315,7 +325,7 @@ function LivePlayPage({ params, navigate }) {

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

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

{result?.correctAnswer && ( -

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

+

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

)}

Warte auf nächste Frage...

@@ -340,7 +350,7 @@ function LivePlayPage({ params, navigate }) { function SoloSelectPage({ navigate }) { const [sets, setSets] = useState([]); const [name, setName] = useState(''); - const [shuffle, setShuffle] = useState(true); + const [doShuffle, setDoShuffle] = useState(true); const [error, setError] = useState(''); useEffect(() => { @@ -349,23 +359,23 @@ function SoloSelectPage({ navigate }) { const start = (setId, setName2) => { if (!name.trim()) return setError('Bitte Name eingeben'); - navigate('solo-quiz', { setId, setName: setName2, name: name.trim(), shuffle }); + navigate('solo-quiz', { setId, setName: setName2, name: name.trim(), shuffle: doShuffle }); }; return (

📚 Solo-Lernen

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

{error}

}

Fragenset wählen:

- {sets.length === 0 &&

Keine Fragensets konfiguriert.

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

Keine Fragensets konfiguriert. Bitte im Admin-Bereich einrichten.

} {sets.map((s) => ( - + ))}
@@ -387,6 +397,7 @@ function SoloQuizPage({ params, navigate }) { const [results, setResults] = useState([]); const [error, setError] = useState(''); const [enlargedImg, setEnlargedImg] = useState(null); + const [shuffledAnswers, setShuffledAnswers] = useState([]); const sessionId = useRef('SOLO-' + Date.now().toString(36).toUpperCase()); useEffect(() => { @@ -401,27 +412,37 @@ function SoloQuizPage({ params, navigate }) { }).catch((e) => setError(e.message)); }, []); - if (error) return
{error}
; - if (!questions.length) return
Lade Fragen...
; + // Shuffle answers when question changes + useEffect(() => { + 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}`] }); + }); + setShuffledAnswers(shuffleArray(answers)); + }, [index, questions]); + + 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: [], + frage: q['Frage'] || q['frage'] || '(Kein Fragetext)', + typ: q['Typ'] || q['typ'] || 'MC', + bild: q['Bild'] && Array.isArray(q['Bild']) && q['Bild'].length ? q['Bild'][0].url : null, + antworten: shuffledAnswers, }; - ['A', 'B', 'C', 'D'].forEach((k) => { if (q[`Antwort ${k}`]) sanitized.antworten.push({ key: k, text: q[`Antwort ${k}`] }); }); + + const normalizeText = (t) => (t || '').toLowerCase().replace(/[äÄ]/g, 'ae').replace(/[öÖ]/g, 'oe').replace(/[üÜ]/g, 'ue').replace(/ß/g, 'ss').replace(/[^a-z0-9]/g, ''); const checkAnswer = () => { const rawCorrect = (q['Richtige Antwort'] || '').trim(); let correct, ok; 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); + ok = normalizeText(selected[0]) === normalizeText(rawCorrect); } else { correct = rawCorrect.split(',').map((s) => s.trim().toUpperCase()).filter(Boolean); const correctSet = new Set(correct); @@ -433,7 +454,7 @@ function SoloQuizPage({ params, navigate }) { setIsCorrect(ok); setShowFeedback(true); - results.push({ + const newResults = [...results, { Nutzername: params.name, Frage_ID: q.id, Fragenset: params.setName, @@ -443,13 +464,13 @@ function SoloQuizPage({ params, navigate }) { Session_ID: sessionId.current, Modus: 'Solo', Zeitstempel: new Date().toISOString(), - }); + }]; + setResults(newResults); }; const nextQuestion = () => { if (index + 1 >= questions.length) { - // Save all results, then navigate - api.post('/api/results', { answers: results }).catch(console.error); + api.post('/api/results', { answers: results }).catch((e) => console.error('Save error:', e)); navigate('solo-result', { results, setName: params.setName, name: params.name, questions }); return; } @@ -462,21 +483,21 @@ function SoloQuizPage({ params, navigate }) {
Frage {index + 1} / {questions.length}
-
+

{sanitized.frage}

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

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

} -
@@ -496,14 +517,14 @@ function SoloResultPage({ params, navigate }) { 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++; + const cat = (q && (q['Kategorie'] || q['kategorie'])) || 'Ohne Kategorie'; + const catName = typeof cat === 'object' && cat.value ? cat.value : cat; + if (!byCategory[catName]) byCategory[catName] = { total: 0, correct: 0 }; + byCategory[catName].total++; + if (r.Richtig) byCategory[catName].correct++; }); return ( @@ -524,7 +545,7 @@ function SoloResultPage({ params, navigate }) {
)}
- +
@@ -548,7 +569,6 @@ function MyResultsPage({ navigate }) { .catch((e) => setError(e.message)); }; - // Group by session const sessions = {}; if (results) { results.forEach((r) => { @@ -562,8 +582,8 @@ function MyResultsPage({ navigate }) {

📊 Meine Ergebnisse

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

{error}

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

Keine Ergebnisse gefunden.

} @@ -577,7 +597,7 @@ function MyResultsPage({ navigate }) {
- {s.modus} + {s.modus} {s.fragenset}
{s.date ? new Date(s.date).toLocaleDateString('de') : ''} diff --git a/public/js/present.js b/public/js/present.js index d47df19..20ec0fa 100644 --- a/public/js/present.js +++ b/public/js/present.js @@ -17,12 +17,14 @@ function PresentApp() { const [answerCount, setAnswerCount] = useState(0); const [result, setResult] = useState(null); const [leaderboard, setLeaderboard] = useState([]); + const [error, setError] = useState(''); const timerRef = useRef(null); const wsRef = useRef(null); useEffect(() => { const code = location.hash.replace('#', ''); - const token = prompt_alternative(); + const params = new URLSearchParams(location.search); + const token = params.get('token'); if (!code || !token) { setPhase('need-config'); @@ -79,15 +81,18 @@ function PresentApp() { clearInterval(timerRef.current); break; case 'error': + setError(msg.message); setPhase('error'); break; } }; + ws.onerror = () => setPhase('error'); + ws.onclose = () => { if (phase !== 'final') setPhase('error'); }; + return () => ws.close(); }, []); - // Timer useEffect(() => { clearInterval(timerRef.current); if (phase === 'question' && timeLimit > 0) { @@ -98,32 +103,31 @@ function PresentApp() { } }, [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.

+
🚨
+

Quizalarm — Präsentation

+

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

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

❌ Verbindungsfehler

+ {error &&

{error}

} +
+ ); + + if (phase === 'connect') return
Verbinde...
; if (phase === 'waiting') return (
-

⚡ Quizalarm

+
🚨
+

Quizalarm

{session?.setName}

-
{session?.code}
+
{session?.code}

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

{(session?.players || []).map((n) => ( @@ -141,7 +145,7 @@ function PresentApp() {
Frage {qIndex + 1} / {qTotal} {answerCount} / {session?.players?.length || 0} 💬 - {timeLimit > 0 && {Math.ceil(timeLeft)}s} + {timeLimit > 0 && {Math.ceil(timeLeft)}s}
{timeLimit > 0 && (
@@ -154,7 +158,7 @@ function PresentApp() {
{(question?.antworten || []).map((a) => (
- {a.key} {a.text} + {a.text}
))}
@@ -171,7 +175,7 @@ function PresentApp() { return (

Ergebnis

-

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

+

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

{Object.entries(result?.stats || {}).map(([k, v]) => (
@@ -185,8 +189,8 @@ function PresentApp() {

🏆 Leaderboard

{leaderboard.slice(0, 8).map((e, i) => ( -
- {e.rank}. {e.name} +
+ {i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${e.rank}.`} {e.name} {e.score}
))} @@ -198,10 +202,10 @@ function PresentApp() { if (phase === 'final') return (
-

🏁 Quiz beendet!

+

Quiz beendet!

{leaderboard.map((e, i) => ( -
+
{i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${e.rank}.`} {e.name} {e.score}
@@ -210,7 +214,7 @@ function PresentApp() {
); - return
Verbinde...
; + return
Verbinde...
; } ReactDOM.createRoot(document.getElementById('root')).render(); \ No newline at end of file diff --git a/public/present.html b/public/present.html index 0c7f1d4..0996295 100644 --- a/public/present.html +++ b/public/present.html @@ -5,6 +5,8 @@ Quizalarm — Präsentation + @@ -12,27 +14,37 @@ - +
diff --git a/server/baserow.js b/server/baserow.js index 4837286..7192e80 100644 --- a/server/baserow.js +++ b/server/baserow.js @@ -3,17 +3,24 @@ const { getEnvConfig } = require('./config'); async function baserowFetch(endpoint, options = {}) { const { baserowUrl, baserowToken } = getEnvConfig(); const url = `${baserowUrl}/api${endpoint}`; - const res = await fetch(url, { - ...options, - headers: { - Authorization: `Token ${baserowToken}`, - 'Content-Type': 'application/json', - ...(options.headers || {}), - }, - }); + console.log(`[baserow] ${options.method || 'GET'} ${url}`); + + const headers = { + Authorization: `Token ${baserowToken}`, + ...(options.headers || {}), + }; + // Only set Content-Type for requests with body + if (options.body) { + headers['Content-Type'] = 'application/json'; + } + + const res = await fetch(url, { ...options, headers }); + if (!res.ok) { const body = await res.text().catch(() => ''); - throw new Error(`Baserow ${res.status}: ${body.substring(0, 200)}`); + const msg = `Baserow ${res.status} ${res.statusText}: ${body.substring(0, 300)}`; + console.error(`[baserow] ERROR: ${msg}`); + throw new Error(msg); } return res.json(); } @@ -27,6 +34,7 @@ async function listRows(tableId, filters = {}) { url += `&filter__${encodeURIComponent(field)}__equal=${encodeURIComponent(value)}`; } const data = await baserowFetch(url); + console.log(`[baserow] Table ${tableId} page ${page}: ${(data.results || []).length} rows`); all = all.concat(data.results || []); if (!data.next) break; page++; @@ -36,7 +44,7 @@ async function listRows(tableId, filters = {}) { async function batchCreateRows(tableId, rows) { if (!rows.length) return; - // Baserow limit: 200 per batch + console.log(`[baserow] Batch create ${rows.length} rows in table ${tableId}`); for (let i = 0; i < rows.length; i += 200) { const chunk = rows.slice(i, i + 200); await baserowFetch(`/database/rows/table/${tableId}/batch/?user_field_names=true`, { diff --git a/server/index.js b/server/index.js index b5b1f48..65c0abf 100644 --- a/server/index.js +++ b/server/index.js @@ -4,6 +4,7 @@ const path = require('path'); const { getEnvConfig, readConfig, writeConfig } = require('./config'); const { listRows, batchCreateRows } = require('./baserow'); const { setupWebSocket, getSessions } = require('./live-session'); +const { sanitizeQuestion, shuffleArray } = require('./quiz-logic'); const app = express(); const server = http.createServer(app); @@ -11,7 +12,6 @@ const server = http.createServer(app); app.use(express.json({ limit: '5mb' })); app.use(express.static(path.join(__dirname, '..', 'public'))); -/* ---- Auth middleware ---- */ function requireAdmin(req, res, next) { if (req.headers['x-admin-token'] !== getEnvConfig().adminPassword) { return res.status(401).json({ error: 'Nicht autorisiert' }); @@ -25,17 +25,24 @@ function requireAdmin(req, res, next) { app.get('/api/sets', (req, res) => { const config = readConfig(); - res.json(config.sets.map((s) => ({ id: s.id, name: s.name }))); + console.log(`[api] GET /api/sets — ${(config.sets || []).length} sets`); + res.json((config.sets || []).map((s) => ({ id: s.id, name: s.name }))); }); app.get('/api/sets/:id/questions', async (req, res) => { try { const config = readConfig(); const set = config.sets.find((s) => s.id === req.params.id); - if (!set) return res.status(404).json({ error: 'Nicht gefunden' }); + if (!set) return res.status(404).json({ error: 'Fragenset nicht gefunden' }); + console.log(`[api] Loading questions for set "${set.name}" (table ${set.tableId})`); const rows = await listRows(set.tableId); + console.log(`[api] Loaded ${rows.length} questions`); + if (rows.length > 0) { + console.log(`[api] Fields: ${Object.keys(rows[0]).join(', ')}`); + } res.json(rows); } catch (e) { + console.error(`[api] Error loading questions:`, e.message); res.status(500).json({ error: e.message }); } }); @@ -44,9 +51,12 @@ app.post('/api/results', async (req, res) => { try { const config = readConfig(); if (!config.answersTableId) return res.status(400).json({ error: 'Antworten-Tabelle nicht konfiguriert' }); - await batchCreateRows(config.answersTableId, req.body.answers); + const answers = req.body.answers || []; + console.log(`[api] Saving ${answers.length} results`); + await batchCreateRows(config.answersTableId, answers); res.json({ success: true }); } catch (e) { + console.error(`[api] Error saving results:`, e.message); res.status(500).json({ error: e.message }); } }); @@ -58,6 +68,7 @@ app.get('/api/results/:name', async (req, res) => { const rows = await listRows(config.answersTableId, { Nutzername: req.params.name }); res.json(rows); } catch (e) { + console.error(`[api] Error loading results:`, e.message); res.status(500).json({ error: e.message }); } }); @@ -79,6 +90,7 @@ app.get('/api/admin/config', requireAdmin, (req, res) => res.json(readConfig())) app.post('/api/admin/config', requireAdmin, (req, res) => { try { writeConfig(req.body); + console.log('[api] Config updated'); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); @@ -93,6 +105,27 @@ app.get('/api/admin/stats', requireAdmin, async (req, res) => { if (!config.answersTableId) return res.json({ answers: [] }); const rows = await listRows(config.answersTableId); res.json({ answers: rows }); + } catch (e) { + console.error('[api] Stats error:', e.message); + res.status(500).json({ error: e.message }); + } +}); + +// Debug endpoint to test Baserow connection +app.get('/api/admin/debug-baserow', requireAdmin, async (req, res) => { + try { + const { baserowUrl, baserowToken } = getEnvConfig(); + const config = readConfig(); + const result = { baserowUrl, tokenLength: baserowToken.length, sets: config.sets, tests: [] }; + for (const set of config.sets) { + try { + const rows = await listRows(set.tableId); + result.tests.push({ set: set.name, tableId: set.tableId, rowCount: rows.length, sampleFields: rows.length > 0 ? Object.keys(rows[0]) : [] }); + } catch (e) { + result.tests.push({ set: set.name, tableId: set.tableId, error: e.message }); + } + } + res.json(result); } catch (e) { res.status(500).json({ error: e.message }); } @@ -106,4 +139,8 @@ app.get('*', (req, res) => res.sendFile(path.join(__dirname, '..', 'public', 'in /* ---- Start ---- */ setupWebSocket(server); const { port } = getEnvConfig(); -server.listen(port, '0.0.0.0', () => console.log(`[quizalarm] Laeuft auf Port ${port}`)); \ No newline at end of file +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 838d55d..40e3beb 100644 --- a/server/live-session.js +++ b/server/live-session.js @@ -5,10 +5,6 @@ const { calculateScore, sanitizeQuestion, parseCorrectAnswers } = require('./qui const sessions = new Map(); -/* ------------------------------------------------------------------ */ -/* Helpers */ -/* ------------------------------------------------------------------ */ - function generateCode() { const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; let code = ''; @@ -87,13 +83,13 @@ function setupWebSocket(server) { try { handleMessage(ws, JSON.parse(raw)); } catch (e) { + console.error('[ws] Message error:', e); send(ws, { type: 'error', message: 'Ungueltige Nachricht' }); } }); ws.on('close', () => handleDisconnect(ws)); }); - // Heartbeat every 30 s setInterval(() => { wss.clients.forEach((ws) => { if (!ws.isAlive) return ws.terminate(); @@ -108,6 +104,7 @@ function setupWebSocket(server) { /* ------------------------------------------------------------------ */ async function handleMessage(ws, msg) { + console.log(`[ws] Received: ${msg.type}`); switch (msg.type) { case 'admin-create': return adminCreate(ws, msg); @@ -115,6 +112,8 @@ async function handleMessage(ws, msg) { return adminJoin(ws, msg); case 'admin-start': return adminStart(ws, msg); + case 'admin-show-result': + return adminShowResult(ws, msg); case 'admin-next': return adminNext(ws, msg); case 'admin-end': @@ -126,7 +125,7 @@ async function handleMessage(ws, msg) { case 'answer': return playerAnswer(ws, msg); default: - send(ws, { type: 'error', message: 'Unbekannter Typ' }); + send(ws, { type: 'error', message: 'Unbekannter Typ: ' + msg.type }); } } @@ -144,10 +143,15 @@ async function adminCreate(ws, msg) { let questions; try { questions = await listRows(set.tableId); + console.log(`[live] Loaded ${questions.length} questions for set "${set.name}"`); + if (questions.length > 0) { + console.log(`[live] Sample fields:`, Object.keys(questions[0]).join(', ')); + } } catch (e) { - return send(ws, { type: 'error', message: 'Fehler beim Laden: ' + e.message }); + console.error('[live] Baserow error:', e.message); + return send(ws, { type: 'error', message: 'Fehler beim Laden der Fragen: ' + e.message }); } - if (!questions.length) return send(ws, { type: 'error', message: 'Keine Fragen gefunden' }); + if (!questions.length) return send(ws, { type: 'error', message: 'Keine Fragen in dieser Tabelle gefunden' }); const opts = { shuffle: msg.options.shuffle !== false, @@ -216,6 +220,14 @@ function adminStart(ws, msg) { sendNextQuestion(s); } +function adminShowResult(ws, msg) { + if (!isAdmin(msg.token)) return; + const s = sessions.get(msg.code); + if (!s || s.status !== 'active') return; + console.log(`[live] Admin forced show result for session ${s.code}`); + showResult(s); +} + function adminNext(ws, msg) { if (!isAdmin(msg.token)) return; const s = sessions.get(msg.code); @@ -236,8 +248,8 @@ async function adminEnd(ws, msg) { function playerJoin(ws, msg) { const s = sessions.get(msg.code); - if (!s) return send(ws, { type: 'error', message: 'Session nicht gefunden' }); - if (s.status !== 'waiting') return send(ws, { type: 'error', message: 'Session laeuft bereits' }); + if (!s) return send(ws, { type: 'error', message: 'Session nicht gefunden. Pruefe den Code.' }); + if (s.status !== 'waiting') return send(ws, { type: 'error', message: 'Session laeuft bereits oder ist beendet.' }); const name = (msg.name || '').trim(); if (!name) return send(ws, { type: 'error', message: 'Name darf nicht leer sein' }); @@ -247,7 +259,7 @@ function playerJoin(ws, msg) { if (existing.ws && existing.ws.readyState === WebSocket.OPEN) { return send(ws, { type: 'error', message: 'Name bereits online. Bitte waehle einen anderen.' }); } - existing.ws = ws; // Reconnect + existing.ws = ws; } else { s.players.set(name, { ws, score: 0, answers: [] }); } @@ -263,13 +275,13 @@ function playerAnswer(ws, msg) { if (!qa || qa.role !== 'player') return; const s = sessions.get(qa.code); if (!s || s.status !== 'active') return; - if (s.currentAnswers.has(qa.name)) return; // Already answered + if (s.currentAnswers.has(qa.name)) return; const elapsed = (Date.now() - s.questionStartTime) / 1000; const tl = s.options.scoring === 'time' ? s.options.timeLimit : 0; const remaining = tl > 0 ? Math.max(0, tl - elapsed) : 0; - if (tl > 0 && remaining <= 0) return; // Time up + if (tl > 0 && remaining <= 0) return; s.currentAnswers.set(qa.name, { answers: msg.answers || [], timeRemaining: remaining }); broadcastAdmins(s, { type: 'answer-count', count: s.currentAnswers.size, total: s.players.size }); @@ -292,12 +304,15 @@ function sendNextQuestion(s) { const q = s.questions[s.currentQuestionIndex]; const tl = s.options.scoring === 'time' ? s.options.timeLimit : 0; + const sanitized = sanitizeQuestion(q); + + console.log(`[live] Session ${s.code}: Sending question ${s.currentQuestionIndex + 1}/${s.questions.length}`); broadcastAll(s, { type: 'question', index: s.currentQuestionIndex, total: s.questions.length, - question: sanitizeQuestion(q), + question: sanitized, timeLimit: tl, }); @@ -317,11 +332,9 @@ function showResult(s) { const correct = parseCorrectAnswers(q); const tl = s.options.scoring === 'time' ? s.options.timeLimit : 0; - // Answer distribution const stats = {}; ['A', 'B', 'C', 'D'].forEach((k) => { if (q[`Antwort ${k}`]) stats[k] = 0; }); - // Score for each player who answered for (const [name, ans] of s.currentAnswers) { const player = s.players.get(name); const result = calculateScore(q, ans.answers, ans.timeRemaining, tl, s.options.scoring); @@ -336,7 +349,6 @@ function showResult(s) { }); } - // Players who didn't answer for (const [name, player] of s.players) { if (!s.currentAnswers.has(name)) { player.answers.push({ qId: q.id, idx: s.currentQuestionIndex, answers: [], correct: false, points: 0 }); @@ -350,7 +362,6 @@ function showResult(s) { const lb = leaderboard(s); - // Send personal results to players for (const [name, player] of s.players) { const last = player.answers[player.answers.length - 1]; send(player.ws, { @@ -372,10 +383,12 @@ async function endSession(s) { const config = readConfig(); if (config.answersTableId && s.results.length) { await batchCreateRows(config.answersTableId, s.results); - console.log(`[quizalarm] Session ${s.code}: ${s.results.length} Ergebnisse gespeichert`); + console.log(`[live] Session ${s.code}: ${s.results.length} Ergebnisse in Baserow gespeichert`); + } else { + console.log(`[live] Session ${s.code}: Keine Antworten-Tabelle konfiguriert oder keine Ergebnisse`); } } catch (e) { - console.error('[quizalarm] Fehler beim Speichern:', e.message); + console.error('[live] Fehler beim Speichern:', e.message); } setTimeout(() => sessions.delete(s.code), 300000); @@ -398,10 +411,6 @@ function handleDisconnect(ws) { } } -/* ------------------------------------------------------------------ */ -/* Exports */ -/* ------------------------------------------------------------------ */ - function getSessions() { return Array.from(sessions.values()).map(sessionInfo); } diff --git a/server/quiz-logic.js b/server/quiz-logic.js index cc23e04..34a54b1 100644 --- a/server/quiz-logic.js +++ b/server/quiz-logic.js @@ -1,6 +1,5 @@ /** * Normalize text for freitext comparison. - * Removes special characters, lowercases, collapses whitespace. * "Villingen-Schwenningen" == "villingen schwenningen" == "villingenschwenningen" */ function normalizeText(text) { @@ -38,19 +37,21 @@ function countOptions(question) { return n; } +function shuffleArray(arr) { + const a = [...arr]; + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return a; +} + /** * Calculate score for an answer. - * @param {object} question – Baserow row - * @param {string[]} selected – e.g. ['A','C'] or ['Berlin'] - * @param {number} timeRemaining – seconds left (0 if no timer) - * @param {number} totalTime – total seconds (0 if binary) - * @param {string} scoringMode – 'binary' | 'time' - * @returns {{ points: number, correct: boolean }} */ function calculateScore(question, selected, timeRemaining, totalTime, scoringMode) { if (!selected || selected.length === 0) return { points: 0, correct: false }; - // Base points let basis; if (scoringMode === 'time' && totalTime > 0) { const ratio = Math.max(0, Math.min(1, timeRemaining / totalTime)); @@ -62,13 +63,11 @@ function calculateScore(question, selected, timeRemaining, totalTime, scoringMod const typ = (question['Typ'] || 'MC').toLowerCase(); const correctAnswers = parseCorrectAnswers(question); - // Freitext if (typ === 'freitext') { const ok = checkFreitext(selected[0], correctAnswers[0]); return { points: ok ? basis : 0, correct: ok }; } - // MC / Wahr-Falsch const correctSet = new Set(correctAnswers); const totalCorrect = correctSet.size; const totalIncorrect = countOptions(question) - totalCorrect; @@ -91,7 +90,7 @@ function calculateScore(question, selected, timeRemaining, totalTime, scoringMod } /** - * Strip the correct answer from a question before sending to participants. + * Strip the correct answer and shuffle options before sending to participants. */ function sanitizeQuestion(question) { const typ = (question['Typ'] || 'MC').toLowerCase(); @@ -104,23 +103,22 @@ function sanitizeQuestion(question) { kategorie: question['Kategorie'] || '', }; - // Bild (Baserow file field → array of objects) const bild = question['Bild']; if (bild && Array.isArray(bild) && bild.length > 0) { sanitized.bild = bild[0].url; } - if (typ === 'freitext') { - // no options - } else { + if (typ !== 'freitext') { const keys = ['A', 'B', 'C', 'D']; for (const k of keys) { const val = question[`Antwort ${k}`]; if (val) sanitized.antworten.push({ key: k, text: val }); } + // Shuffle answer order + sanitized.antworten = shuffleArray(sanitized.antworten); } return sanitized; } -module.exports = { calculateScore, sanitizeQuestion, parseCorrectAnswers, normalizeText }; \ No newline at end of file +module.exports = { calculateScore, sanitizeQuestion, parseCorrectAnswers, normalizeText, shuffleArray }; \ No newline at end of file
NameRichtigGesamtQuotePunkte