diff --git a/public/img/logo.png b/public/img/logo.png index c7f0d4d..be2adbd 100644 Binary files a/public/img/logo.png and b/public/img/logo.png differ diff --git a/public/js/admin.js b/public/js/admin.js index 68c4131..4b9205c 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -35,7 +35,7 @@ function LoginPage({ onLogin }) { return (
- Quizalarm +
🚨

Quizalarm Admin

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

{err}

} @@ -250,7 +250,14 @@ function LiveSetupPage({ config, token, onSessionCreated }) { ws.onmessage = (e) => { const msg = JSON.parse(e.data); if (msg.type === 'session-created') { - onSessionCreated(msg.code, ws); + onSessionCreated(msg.code, ws, { + code: msg.code, + setName: msg.setName, + questionCount: msg.questionCount, + options: msg.options, + players: [], + playerCount: 0, + }); } else if (msg.type === 'error') { setError(msg.message); setLoading(false); @@ -298,11 +305,11 @@ function LiveSetupPage({ config, token, onSessionCreated }) { } /* ================================================================== */ -/* Live Control Page */ +/* Live Control Page (FIX: sends admin-join on mount) */ /* ================================================================== */ -function LiveControlPage({ token, code, ws }) { - const [session, setSession] = useState(null); +function LiveControlPage({ token, code, ws, initialSession }) { + const [session, setSession] = useState(initialSession || null); const [phase, setPhase] = useState('waiting'); const [answerCount, setAnswerCount] = useState(0); const [result, setResult] = useState(null); @@ -313,20 +320,26 @@ function LiveControlPage({ token, code, ws }) { const handler = (e) => { const msg = JSON.parse(e.data); + console.log('[admin-control] Received:', msg.type); switch (msg.type) { case 'session-created': - setSession({ code: msg.code, setName: msg.setName, questionCount: msg.questionCount, options: msg.options, players: [] }); + setSession({ code: msg.code, setName: msg.setName, questionCount: msg.questionCount, options: msg.options, players: [], playerCount: 0 }); + setPhase('waiting'); break; case 'admin-joined': setSession(msg.session); + setLeaderboardData(msg.session.leaderboard || []); if (msg.session.status === 'waiting') setPhase('waiting'); else if (msg.session.status === 'ended') setPhase('ended'); - else setPhase('active'); - setLeaderboardData(msg.session.leaderboard); + else if (msg.session.status === 'showing-result') setPhase('result'); + else setPhase('question'); break; case 'player-joined': case 'player-left': - setSession((s) => s ? { ...s, players: msg.players, playerCount: msg.playerCount } : s); + setSession((s) => { + if (!s) return { code, players: msg.players, playerCount: msg.playerCount }; + return { ...s, players: msg.players, playerCount: msg.playerCount }; + }); break; case 'game-start': setPhase('active'); @@ -355,42 +368,67 @@ function LiveControlPage({ token, code, ws }) { }; ws.addEventListener('message', handler); - return () => ws.removeEventListener('message', handler); - }, [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 })); + // 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 = () => { + ws.send(JSON.stringify({ type: 'admin-join', token, code })); + ws.removeEventListener('open', onOpen); + }; + ws.addEventListener('open', onOpen); + } + + return () => ws.removeEventListener('message', handler); + }, [ws, code, token]); + + const start = () => { + console.log('[admin-control] Sending admin-start'); + 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 })); + }; const presentUrl = `${location.origin}/present?token=${encodeURIComponent(token)}#${code}`; + const playerCount = session?.players?.length || session?.playerCount || 0; return (

Session-Code

{code}
-

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

+

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

📺 Präsentations-Fenster öffnen
-

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

+

Teilnehmer ({playerCount})

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

Noch keine Teilnehmer...

} + {playerCount === 0 &&

Noch keine Teilnehmer...

}
{phase === 'waiting' && ( - )} {phase === 'question' && (
-

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

+

Antworten: {answerCount} / {playerCount}

)} @@ -448,6 +486,7 @@ function AdminApp() { const [config, setConfig] = useState({ sets: [], answersTableId: null }); const [liveCode, setLiveCode] = useState(null); const [liveWs, setLiveWs] = useState(null); + const [liveInitialSession, setLiveInitialSession] = useState(null); useEffect(() => { if (token) { @@ -460,9 +499,10 @@ function AdminApp() { try { await api.post('/api/admin/config', c); } catch (e) { console.error(e); } }; - const handleSessionCreated = (code, ws) => { + const handleSessionCreated = (code, ws, sessionData) => { setLiveCode(code); setLiveWs(ws); + setLiveInitialSession(sessionData); setTab('live-control'); }; @@ -491,7 +531,7 @@ function AdminApp() {
{tab === 'config' && } {tab === 'live' && } - {tab === 'live-control' && } + {tab === 'live-control' && } {tab === 'stats' && }
diff --git a/public/js/app.js b/public/js/app.js index 9969948..cf5763e 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -26,6 +26,23 @@ function shuffleArray(arr) { 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; + if (typeof field === 'object' && field.value !== undefined) return field.value; + return String(field); +} + +function normalizeText(t) { + return (t || '').toLowerCase() + .replace(/[äÄ]/g, 'ae').replace(/[öÖ]/g, 'oe').replace(/[üÜ]/g, 'ue').replace(/ß/g, 'ss') + .replace(/[^a-z0-9]/g, ''); +} + /* ================================================================== */ /* Shared Components */ /* ================================================================== */ @@ -123,7 +140,7 @@ function AnswerGrid({ question, selected, onToggle, disabled }) { function HomePage({ navigate }) { return (
- Quizalarm +
🚨

Quizalarm

Lernen. Quizzen. Wissen.

@@ -402,6 +419,7 @@ function SoloQuizPage({ params, navigate }) { 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)); @@ -412,7 +430,6 @@ function SoloQuizPage({ params, navigate }) { }).catch((e) => setError(e.message)); }, []); - // Shuffle answers when question changes useEffect(() => { if (questions.length === 0 || index >= questions.length) return; const q = questions[index]; @@ -427,16 +444,14 @@ function SoloQuizPage({ params, navigate }) { if (!questions.length) return
Lade Fragen...
; const q = questions[index]; - const typ = (q['Typ'] || 'MC').toLowerCase(); + const typ = fieldVal(q['Typ'], 'MC').toLowerCase(); const sanitized = { - frage: q['Frage'] || q['frage'] || '(Kein Fragetext)', - typ: q['Typ'] || q['typ'] || 'MC', + frage: q['Frage'] || '(Kein Fragetext)', + typ: fieldVal(q['Typ'], 'MC'), bild: q['Bild'] && Array.isArray(q['Bild']) && q['Bild'].length ? q['Bild'][0].url : null, antworten: shuffledAnswers, }; - 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; @@ -520,11 +535,10 @@ function SoloResultPage({ params, navigate }) { const byCategory = {}; results.forEach((r, i) => { const q = questions[i]; - 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++; + const cat = fieldVal(q?.['Kategorie'], 'Ohne Kategorie') || 'Ohne Kategorie'; + if (!byCategory[cat]) byCategory[cat] = { total: 0, correct: 0 }; + byCategory[cat].total++; + if (r.Richtig) byCategory[cat].correct++; }); return ( @@ -573,7 +587,7 @@ function MyResultsPage({ navigate }) { if (results) { results.forEach((r) => { const sid = r['Session_ID'] || 'Unbekannt'; - if (!sessions[sid]) sessions[sid] = { modus: r['Modus'], fragenset: r['Fragenset'], date: r['Zeitstempel'], answers: [] }; + if (!sessions[sid]) sessions[sid] = { modus: fieldVal(r['Modus'], ''), fragenset: r['Fragenset'], date: r['Zeitstempel'], answers: [] }; sessions[sid].answers.push(r); }); } diff --git a/server/quiz-logic.js b/server/quiz-logic.js index 34a54b1..bc0b0f4 100644 --- a/server/quiz-logic.js +++ b/server/quiz-logic.js @@ -14,13 +14,28 @@ 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; + if (typeof field === 'object' && field.value !== undefined) return field.value; + return String(field); +} + +function getTyp(question) { + return extractFieldValue(question['Typ'], 'MC').toLowerCase(); +} + function checkFreitext(userAnswer, correctAnswer) { return normalizeText(userAnswer) === normalizeText(correctAnswer); } function parseCorrectAnswers(question) { const raw = (question['Richtige Antwort'] || '').trim(); - const typ = (question['Typ'] || 'MC').toLowerCase(); + const typ = getTyp(question); if (typ === 'freitext') return [raw]; return raw .split(',') @@ -60,7 +75,7 @@ function calculateScore(question, selected, timeRemaining, totalTime, scoringMod basis = 1000; } - const typ = (question['Typ'] || 'MC').toLowerCase(); + const typ = getTyp(question); const correctAnswers = parseCorrectAnswers(question); if (typ === 'freitext') { @@ -93,14 +108,14 @@ function calculateScore(question, selected, timeRemaining, totalTime, scoringMod * Strip the correct answer and shuffle options before sending to participants. */ function sanitizeQuestion(question) { - const typ = (question['Typ'] || 'MC').toLowerCase(); + const typ = getTyp(question); const sanitized = { id: question.id, frage: question['Frage'] || '', - typ: question['Typ'] || 'MC', + typ: extractFieldValue(question['Typ'], 'MC'), bild: null, antworten: [], - kategorie: question['Kategorie'] || '', + kategorie: extractFieldValue(question['Kategorie'], ''), }; const bild = question['Bild']; @@ -114,11 +129,10 @@ function sanitizeQuestion(question) { 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, shuffleArray }; \ No newline at end of file +module.exports = { calculateScore, sanitizeQuestion, parseCorrectAnswers, normalizeText, shuffleArray, extractFieldValue }; \ No newline at end of file