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

This commit is contained in:
2026-04-16 17:53:12 +02:00
parent 247cca0803
commit 4b6e43ba65
8 changed files with 625 additions and 436 deletions
+46 -46
View File
@@ -28,17 +28,17 @@ Diese Tabelle speichert ALLE Ergebnisse (Live + Solo). Es gibt nur eine einzige.
2. Lösche die automatisch erstellten Beispiel-Spalten 2. Lösche die automatisch erstellten Beispiel-Spalten
3. Erstelle folgende Felder: 3. Erstelle folgende Felder:
| Nr. | Feldname | Feldtyp | Optionen / Hinweis | | Nr. | Feldname | Feldtyp | Optionen / Hinweis |
|-----|----------------|---------------------|---------------------------------------------| |-----|---------------|------------------|-------------------------------------|
| 1 | `Nutzername` | Single line text | | | 1 | `Nutzername` | Single line text | |
| 2 | `Frage_ID` | Number (Integer) | | | 2 | `Frage_ID` | Number (Integer) | |
| 3 | `Fragenset` | Single line text | | | 3 | `Fragenset` | Single line text | |
| 4 | `Antwort` | Single line text | Enthält z.B. `A,C` oder `Berlin` | | 4 | `Antwort` | Single line text | Enthält z.B. `A,C` oder `Berlin` |
| 5 | `Richtig` | Boolean | | | 5 | `Richtig` | Boolean | |
| 6 | `Punkte` | Number (Integer) | | | 6 | `Punkte` | Number (Integer) | |
| 7 | `Session_ID` | Single line text | z.B. `LIVE-M3K7X` oder `SOLO-K8P2Q` | | 7 | `Session_ID` | Single line text | z.B. `LIVE-M3K7X` oder `SOLO-K8P2Q` |
| 8 | `Modus` | Single select | Optionen anlegen: `Live`, `Solo` | | 8 | `Modus` | Single select | Optionen anlegen: `Live`, `Solo` |
| 9 | `Zeitstempel` | Date | ✅ "Include time" aktivieren | | 9 | `Zeitstempel` | Date | ✅ "Include time" aktivieren |
1. **Table-ID notieren**: Klicke auf die Tabelle → die URL enthält `/database/TABLE_ID/...` 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 3. Lösche die automatisch erstellten Beispiel-Spalten
4. Erstelle folgende Felder: 4. Erstelle folgende Felder:
| Nr. | Feldname | Feldtyp | Optionen / Hinweis | | Nr. | Feldname | Feldtyp | Optionen / Hinweis |
|-----|-------------------|---------------------|------------------------------------------------------------| |-----|--------------------|------------------|---------------------------------------------------|
| 1 | `Frage` | Long text | Der vollständige Fragetext | | 1 | `Frage` | Long text | Der vollständige Fragetext |
| 2 | `Typ` | Single select | Optionen anlegen: `MC`, `Wahr/Falsch`, `Freitext` | | 2 | `Typ` | Single select | Optionen anlegen: `MC`, `Wahr/Falsch`, `Freitext` |
| 3 | `Bild` | File | Optional — Bild zur Frage hochladen | | 3 | `Bild` | File | Optional — Bild zur Frage hochladen |
| 4 | `Antwort A` | Single line text | Bei Wahr/Falsch: `Wahr` | | 4 | `Antwort A` | Single line text | Bei Wahr/Falsch: `Wahr` |
| 5 | `Antwort B` | Single line text | Bei Wahr/Falsch: `Falsch` | | 5 | `Antwort B` | Single line text | Bei Wahr/Falsch: `Falsch` |
| 6 | `Antwort C` | Single line text | Leer lassen bei Wahr/Falsch oder 2 Antworten | | 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 | | 7 | `Antwort D` | Single line text | Leer lassen bei weniger als 4 Antworten |
| 8 | `Richtige Antwort`| Single line text | Siehe Ausfüllregeln unten | | 8 | `Richtige Antwort` | Single line text | Siehe Ausfüllregeln unten |
| 9 | `Kategorie` | Single select | Optional — z.B. `Geografie`, `Technik`, `Recht` | | 9 | `Kategorie` | Single select | Optional — z.B. `Geografie`, `Technik`, `Recht` |
1. **Table-ID notieren**: URL enthält `/database/TABLE_ID/...` 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" ## 4. Ausfüllregeln für "Richtige Antwort"
| Fragetyp | Beispiel-Wert | Erklärung | | Fragetyp | Beispiel-Wert | Erklärung |
|------------------|----------------------|---------------------------------------------------| |----------------|---------------|------------------------------------------------|
| MC (1 richtig) | `A` | Nur der Buchstabe der richtigen Antwort | | MC (1 richtig) | `A` | Nur der Buchstabe der richtigen Antwort |
| MC (mehrere) | `A,C` | Kommagetrennt, ohne Leerzeichen | | MC (mehrere) | `A,C` | Kommagetrennt, ohne Leerzeichen |
| Wahr/Falsch | `A` | `A` = Wahr, `B` = Falsch | | Wahr/Falsch | `A` | `A` = Wahr, `B` = Falsch |
| Freitext | `Berlin` | Das erwartete Wort (Groß/Kleinschreibung egal) | | Freitext | `Berlin` | Das erwartete Wort (Groß/Kleinschreibung egal) |
### Freitext-Toleranz ### Freitext-Toleranz
@@ -102,39 +102,39 @@ Die App normalisiert Eingaben automatisch:
### MC mit 4 Antworten ### MC mit 4 Antworten
| Frage | Typ | Antwort A | Antwort B | Antwort C | Antwort D | Richtige Antwort | Kategorie | | 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 | | Wie heißt die Hauptstadt von Baden-Württemberg? | MC | Stuttgart | Karlsruhe | Freiburg | Mannheim | A | Geografie |
### MC mit mehreren richtigen Antworten ### MC mit mehreren richtigen Antworten
| Frage | Typ | Antwort A | Antwort B | Antwort C | Antwort D | Richtige Antwort | Kategorie | | Frage | Typ | Antwort A | Antwort B | Antwort C | Antwort D | Richtige Antwort | Kategorie |
|-------|-----|-----------|-----------|-----------|-----------|-----------------|-----------| |----------------------------------|-----|-----------|-----------|-----------|-----------|------------------|-----------|
| Welche sind Programmiersprachen? | MC | Python | HTML | Java | Photoshop | A,C | IT | | Welche sind Programmiersprachen? | MC | Python | HTML | Java | Photoshop | A,C | IT |
### MC mit 3 Antworten ### MC mit 3 Antworten
| Frage | Typ | Antwort A | Antwort B | Antwort C | Antwort D | Richtige Antwort | Kategorie | | 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 | | Welche Farbe hat ein Stoppschild? | MC | Rot | Blau | Grün | *(leer)* | A | Verkehr |
### Wahr/Falsch ### Wahr/Falsch
| Frage | Typ | Antwort A | Antwort B | Antwort C | Antwort D | Richtige Antwort | Kategorie | | 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 | | Die Erde ist eine Scheibe. | Wahr/Falsch | Wahr | Falsch | *(leer)* | *(leer)* | B | Allgemein |
### Freitext ### Freitext
| Frage | Typ | Antwort A | Antwort B | Antwort C | Antwort D | Richtige Antwort | Kategorie | | 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 | | Wie heißt die Hauptstadt von Frankreich? | Freitext | *(leer)* | *(leer)* | *(leer)* | *(leer)* | Paris | Geografie |
### Mit Bild ### Mit Bild
| Frage | Typ | Bild | Antwort A | Antwort B | Antwort C | Antwort D | Richtige Antwort | Kategorie | | 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 | | Was zeigt dieses Verkehrszeichen? | MC | *(Bild hochladen)* | Vorfahrt | Stopp | Einbahnstraße | Parkverbot | B | Verkehr |
--- ---
+49 -22
View File
@@ -250,6 +250,8 @@ function LiveSetupPage({ config, token, onSessionCreated }) {
ws.onmessage = (e) => { ws.onmessage = (e) => {
const msg = JSON.parse(e.data); const msg = JSON.parse(e.data);
if (msg.type === 'session-created') { if (msg.type === 'session-created') {
// IMPORTANT: Release WS control before handing off
ws.onmessage = null;
onSessionCreated(msg.code, ws, { onSessionCreated(msg.code, ws, {
code: msg.code, code: msg.code,
setName: msg.setName, 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 }) { function LiveControlPage({ token, code, ws, initialSession }) {
@@ -314,19 +316,31 @@ function LiveControlPage({ token, code, ws, initialSession }) {
const [answerCount, setAnswerCount] = useState(0); const [answerCount, setAnswerCount] = useState(0);
const [result, setResult] = useState(null); const [result, setResult] = useState(null);
const [leaderboardData, setLeaderboardData] = useState([]); const [leaderboardData, setLeaderboardData] = useState([]);
const refreshRef = useRef(null);
useEffect(() => { useEffect(() => {
if (!ws) return; 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); const msg = JSON.parse(e.data);
console.log('[admin-control] Received:', msg.type); console.log('[admin-control] Received:', msg.type, msg);
switch (msg.type) { switch (msg.type) {
case 'session-created': 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'); setPhase('waiting');
break; break;
case 'admin-joined': case 'admin-joined':
console.log('[admin-control] Session synced:', msg.session.players, 'count:', msg.session.playerCount);
setSession(msg.session); setSession(msg.session);
setLeaderboardData(msg.session.leaderboard || []); setLeaderboardData(msg.session.leaderboard || []);
if (msg.session.status === 'waiting') setPhase('waiting'); 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 if (msg.session.status === 'showing-result') setPhase('result');
else setPhase('question'); else setPhase('question');
break; break;
case 'player-joined': case 'player-joined':
case 'player-left': case 'player-left':
console.log('[admin-control] Player update:', msg.players, 'count:', msg.playerCount);
setSession((s) => { setSession((s) => {
if (!s) return { code, players: msg.players, playerCount: msg.playerCount }; const base = s || { code };
return { ...s, players: msg.players, playerCount: msg.playerCount }; return { ...base, players: msg.players, playerCount: msg.playerCount };
}); });
break; break;
case 'game-start': case 'game-start':
setPhase('active'); setPhase('active');
break; break;
case 'question': case 'question':
setPhase('question'); setPhase('question');
setAnswerCount(0); setAnswerCount(0);
setResult(null); setResult(null);
break; break;
case 'answer-count': case 'answer-count':
setAnswerCount(msg.count); setAnswerCount(msg.count);
break; break;
case 'question-result': case 'question-result':
setResult(msg); setResult(msg);
setLeaderboardData(msg.leaderboard); setLeaderboardData(msg.leaderboard);
setPhase('result'); setPhase('result');
break; break;
case 'game-end': case 'game-end':
setLeaderboardData(msg.leaderboard); setLeaderboardData(msg.leaderboard);
setPhase('ended'); setPhase('ended');
break; break;
case 'error': case 'error':
console.error('Admin error:', msg.message); console.error('[admin-control] Error:', msg.message);
break; 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 // Safety net: periodically re-sync session state every 5 seconds while waiting
if (ws.readyState === WebSocket.OPEN) { refreshRef.current = setInterval(() => {
ws.send(JSON.stringify({ type: 'admin-join', token, code })); if (ws.readyState === WebSocket.OPEN) {
} else {
const onOpen = () => {
ws.send(JSON.stringify({ type: 'admin-join', token, code })); ws.send(JSON.stringify({ type: 'admin-join', token, code }));
ws.removeEventListener('open', onOpen); }
}; }, 5000);
ws.addEventListener('open', onOpen);
}
return () => ws.removeEventListener('message', handler); return () => {
clearInterval(refreshRef.current);
ws.onmessage = null;
};
}, [ws, code, token]); }, [ws, code, token]);
// Stop polling once game starts
useEffect(() => {
if (phase !== 'waiting') {
clearInterval(refreshRef.current);
}
}, [phase]);
const start = () => { const start = () => {
console.log('[admin-control] Sending admin-start'); console.log('[admin-control] Starting game');
ws.send(JSON.stringify({ type: 'admin-start', token, code })); ws.send(JSON.stringify({ type: 'admin-start', token, code }));
}; };
const showResultAction = () => { const showResultAction = () => {
console.log('[admin-control] Sending admin-show-result');
ws.send(JSON.stringify({ type: 'admin-show-result', token, code })); ws.send(JSON.stringify({ type: 'admin-show-result', token, code }));
}; };
const next = () => { const next = () => {
console.log('[admin-control] Sending admin-next');
ws.send(JSON.stringify({ type: 'admin-next', token, code })); ws.send(JSON.stringify({ type: 'admin-next', token, code }));
}; };
const end = () => { const end = () => {
console.log('[admin-control] Sending admin-end');
ws.send(JSON.stringify({ type: 'admin-end', token, code })); ws.send(JSON.stringify({ type: 'admin-end', token, code }));
}; };
+243 -174
View File
@@ -13,23 +13,14 @@ const api = {
}, },
}; };
function wsUrl() { function wsUrl() { return `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}`; }
return `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}`;
}
function shuffleArray(arr) { function shuffleArray(arr) {
const a = [...arr]; const a = [...arr];
for (let i = a.length - 1; i > 0; 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]]; }
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a; 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) { function fieldVal(field, fallback) {
if (!field) return fallback || ''; if (!field) return fallback || '';
if (typeof field === 'string') return field; if (typeof field === 'string') return field;
@@ -51,7 +42,7 @@ function ImageModal({ src, onClose }) {
if (!src) return null; if (!src) return null;
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-50 p-4" onClick={onClose}> <div className="fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-50 p-4" onClick={onClose}>
<img src={src} className="max-w-full max-h-full object-contain rounded-lg" alt="Vergroesserung" /> <img src={src} className="max-w-full max-h-full object-contain rounded-lg" alt="" />
</div> </div>
); );
} }
@@ -88,44 +79,53 @@ function Leaderboard({ entries, highlight, compact }) {
const ANSWER_COLORS = { A: 'answer-a', B: 'answer-b', C: 'answer-c', D: 'answer-d' }; 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 typ = (question.typ || 'MC').toLowerCase();
const isWF = typ === 'wahr/falsch'; const isWF = typ === 'wahr/falsch';
if (typ === 'freitext') { if (typ === 'freitext') {
if (feedback) {
return (
<div className="space-y-3">
<div className={`p-4 rounded-xl text-center text-xl font-bold text-white ${feedback.isCorrect ? 'bg-green-600 ring-4 ring-green-400' : 'bg-red-700 ring-4 ring-red-400'}`}>
{feedback.isCorrect ? '✓ ' : '✗ '}{selected[0] || '(keine Antwort)'}
</div>
{!feedback.isCorrect && feedback.correctText && (
<div className="p-4 rounded-xl text-center text-xl font-bold bg-green-600 text-white ring-4 ring-green-400"> {feedback.correctText}</div>
)}
</div>
);
}
return ( return (
<input <input type="text" className="w-full p-4 rounded-xl bg-gray-700 text-white text-xl text-center border-2 border-gray-600 focus:border-red-500 outline-none"
type="text" placeholder="Deine Antwort..." value={selected[0] || ''} onChange={(e) => onToggle([e.target.value])} disabled={disabled} autoFocus />
className="w-full p-4 rounded-xl bg-gray-700 text-white text-xl text-center border-2 border-gray-600 focus:border-red-500 outline-none"
placeholder="Deine Antwort..."
value={selected[0] || ''}
onChange={(e) => onToggle([e.target.value])}
disabled={disabled}
autoFocus
/>
); );
} }
return ( return (
<div className={`grid gap-3 ${question.antworten.length <= 2 ? 'grid-cols-1 sm:grid-cols-2' : 'grid-cols-1 sm:grid-cols-2'}`}> <div className={`grid gap-3 ${question.antworten.length <= 2 ? 'grid-cols-1 sm:grid-cols-2' : 'grid-cols-1 sm:grid-cols-2'}`}>
{question.antworten.map((a) => { {question.antworten.map((a) => {
const active = selected.includes(a.key); const isSelected = 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`; 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 ( return (
<button <button key={a.key} className={cls} disabled={disabled || !!feedback}
key={a.key}
className={cls}
disabled={disabled}
onClick={() => { onClick={() => {
if (disabled) return; if (disabled || feedback) return;
if (isWF) { if (isWF) onToggle([a.key]);
onToggle([a.key]); else onToggle(isSelected ? selected.filter((s) => s !== a.key) : [...selected, a.key]);
} else { }}>
onToggle(active ? selected.filter((s) => s !== a.key) : [...selected, a.key]); {icon}{a.text}
}
}}
>
{a.text}
</button> </button>
); );
})} })}
@@ -133,6 +133,14 @@ function AnswerGrid({ question, selected, onToggle, disabled }) {
); );
} }
function BackButton({ onClick, label }) {
return (
<button onClick={onClick} className="text-gray-400 hover:text-white text-sm flex items-center gap-1">
<span></span> <span>{label || 'Beenden'}</span>
</button>
);
}
/* ================================================================== */ /* ================================================================== */
/* Home Page */ /* Home Page */
/* ================================================================== */ /* ================================================================== */
@@ -161,12 +169,10 @@ function JoinPage({ navigate }) {
const [code, setCode] = useState(''); const [code, setCode] = useState('');
const [name, setName] = useState(''); const [name, setName] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const handleJoin = () => { const handleJoin = () => {
if (!code.trim() || !name.trim()) return setError('Code und Name erforderlich'); if (!code.trim() || !name.trim()) return setError('Code und Name erforderlich');
navigate('live-play', { code: code.trim().toUpperCase(), name: name.trim() }); navigate('live-play', { code: code.trim().toUpperCase(), name: name.trim() });
}; };
return ( return (
<div className="min-h-screen flex flex-col items-center justify-center p-6"> <div className="min-h-screen flex flex-col items-center justify-center p-6">
<h2 className="text-3xl font-bold mb-6">🎮 Live-Quiz beitreten</h2> <h2 className="text-3xl font-bold mb-6">🎮 Live-Quiz beitreten</h2>
@@ -207,66 +213,28 @@ function LivePlayPage({ params, navigate }) {
useEffect(() => { useEffect(() => {
const ws = new WebSocket(wsUrl()); const ws = new WebSocket(wsUrl());
wsRef.current = ws; 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) => { ws.onmessage = (e) => {
const msg = JSON.parse(e.data); const msg = JSON.parse(e.data);
switch (msg.type) { switch (msg.type) {
case 'joined': case 'joined': setSession(msg.session); setPhase('waiting'); break;
setSession(msg.session); case 'player-joined': case 'player-left':
setPhase('waiting'); setSession((s) => s ? { ...s, players: msg.players, playerCount: msg.playerCount } : s); break;
break; case 'game-start': setQTotal(msg.totalQuestions); setPhase('playing'); 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': case 'question':
setQuestion(msg.question); setQuestion(msg.question); setQIndex(msg.index); setQTotal(msg.total);
setQIndex(msg.index); setTimeLimit(msg.timeLimit); setTimeLeft(msg.timeLimit);
setQTotal(msg.total); setSelected([]); setAnswered(false); setResult(null); setPhase('question'); break;
setTimeLimit(msg.timeLimit); case 'answer-received': setAnswered(true); break;
setTimeLeft(msg.timeLimit); case 'time-up': setAnswered(true); clearInterval(timerRef.current); break;
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': case 'question-result':
setResult(msg); setResult(msg); setLeaderboardData(msg.leaderboard); setPhase('result'); clearInterval(timerRef.current); break;
setLeaderboardData(msg.leaderboard);
setPhase('result');
clearInterval(timerRef.current);
break;
case 'game-end': case 'game-end':
setLeaderboardData(msg.leaderboard); setLeaderboardData(msg.leaderboard); setPhase('final'); clearInterval(timerRef.current); break;
setPhase('final'); case 'error': setError(msg.message); setPhase('error'); break;
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(); }; return () => { clearInterval(timerRef.current); ws.close(); };
}, []); }, []);
@@ -275,8 +243,7 @@ function LivePlayPage({ params, navigate }) {
if (phase === 'question' && timeLimit > 0 && !answered) { if (phase === 'question' && timeLimit > 0 && !answered) {
const start = Date.now(); const start = Date.now();
timerRef.current = setInterval(() => { timerRef.current = setInterval(() => {
const elapsed = (Date.now() - start) / 1000; const left = Math.max(0, timeLimit - (Date.now() - start) / 1000);
const left = Math.max(0, timeLimit - elapsed);
setTimeLeft(left); setTimeLeft(left);
if (left <= 0) clearInterval(timerRef.current); if (left <= 0) clearInterval(timerRef.current);
}, 100); }, 100);
@@ -289,6 +256,8 @@ function LivePlayPage({ params, navigate }) {
setAnswered(true); setAnswered(true);
}; };
const leave = () => { if (wsRef.current) wsRef.current.close(); navigate('home'); };
if (phase === 'error') return ( if (phase === 'error') return (
<div className="min-h-screen flex flex-col items-center justify-center p-6"> <div className="min-h-screen flex flex-col items-center justify-center p-6">
<p className="text-red-400 text-xl mb-4"> {error}</p> <p className="text-red-400 text-xl mb-4"> {error}</p>
@@ -298,8 +267,9 @@ function LivePlayPage({ params, navigate }) {
if (phase === 'disconnected') return ( if (phase === 'disconnected') return (
<div className="min-h-screen flex flex-col items-center justify-center p-6"> <div className="min-h-screen flex flex-col items-center justify-center p-6">
<p className="text-yellow-400 text-xl mb-4">Verbindung verloren...</p> <p className="text-yellow-400 text-xl mb-4">Verbindung verloren</p>
<button onClick={() => navigate('join')} className="py-3 px-6 bg-gray-700 rounded-xl">Erneut beitreten</button> <p className="text-gray-400 mb-4">Du kannst mit demselben Namen wieder beitreten.</p>
<button onClick={() => navigate('join')} className="py-3 px-6 bg-red-600 rounded-xl font-bold">Erneut beitreten</button>
</div> </div>
); );
@@ -307,6 +277,7 @@ function LivePlayPage({ params, navigate }) {
if (phase === 'waiting') return ( if (phase === 'waiting') return (
<div className="min-h-screen flex flex-col items-center justify-center p-6"> <div className="min-h-screen flex flex-col items-center justify-center p-6">
<div className="self-start mb-4"><BackButton onClick={leave} label="Verlassen" /></div>
<h2 className="text-3xl font-bold mb-2"> Warteraum</h2> <h2 className="text-3xl font-bold mb-2"> Warteraum</h2>
<p className="text-gray-400 mb-4">{session?.setName}</p> <p className="text-gray-400 mb-4">{session?.setName}</p>
<p className="text-lg mb-4">{session?.playerCount || 0} Teilnehmer</p> <p className="text-lg mb-4">{session?.playerCount || 0} Teilnehmer</p>
@@ -321,33 +292,42 @@ function LivePlayPage({ params, navigate }) {
if (phase === 'question') return ( if (phase === 'question') return (
<div className="min-h-screen flex flex-col p-4 max-w-2xl mx-auto"> <div className="min-h-screen flex flex-col p-4 max-w-2xl mx-auto">
<div className="text-center text-sm text-gray-400 mb-2">Frage {qIndex + 1} / {qTotal}</div> <div className="flex justify-between items-center mb-2">
<BackButton onClick={leave} label="Verlassen" />
<span className="text-sm text-gray-400">Frage {qIndex + 1} / {qTotal}</span>
</div>
{timeLimit > 0 && <Timer timeLeft={timeLeft} total={timeLimit} />} {timeLimit > 0 && <Timer timeLeft={timeLeft} total={timeLimit} />}
<h2 className="text-xl font-bold mb-4 text-center">{question?.frage}</h2> <h2 className="text-xl font-bold mb-4 text-center">{question?.frage}</h2>
{question?.bild && <img src={question.bild} className="max-h-48 mx-auto rounded-lg mb-4 cursor-pointer" alt="" onClick={() => setEnlargedImg(question.bild)} />} {question?.bild && <img src={question.bild} className="max-h-48 mx-auto rounded-lg mb-4 cursor-pointer" alt="" onClick={() => setEnlargedImg(question.bild)} />}
<AnswerGrid question={question} selected={selected} onToggle={setSelected} disabled={answered} /> <AnswerGrid question={question} selected={selected} onToggle={setSelected} disabled={answered} />
{!answered && ( {!answered && <button onClick={submitAnswer} disabled={selected.length === 0} className="mt-4 py-3 bg-red-600 hover:bg-red-700 disabled:opacity-40 rounded-xl text-lg font-bold w-full">Antwort senden</button>}
<button onClick={submitAnswer} disabled={selected.length === 0} className="mt-4 py-3 bg-red-600 hover:bg-red-700 disabled:opacity-40 rounded-xl text-lg font-bold w-full shadow-lg shadow-red-900/30">Antwort senden</button>
)}
{answered && <p className="mt-4 text-center text-amber-400 animate-pulse"> Antwort gesendet warte auf Ergebnis...</p>} {answered && <p className="mt-4 text-center text-amber-400 animate-pulse"> Antwort gesendet warte auf Ergebnis...</p>}
<ImageModal src={enlargedImg} onClose={() => setEnlargedImg(null)} /> <ImageModal src={enlargedImg} onClose={() => setEnlargedImg(null)} />
</div> </div>
); );
if (phase === 'result') return ( if (phase === 'result') {
<div className="min-h-screen flex flex-col items-center p-4 max-w-md mx-auto"> const liveFeedback = result ? {
<div className={`text-4xl mb-4 ${result?.yourResult?.correct ? 'text-green-400' : 'text-red-400'}`}> correctKeys: result.correctAnswer || [],
{result?.yourResult?.correct ? '✅ Richtig!' : '❌ Falsch'} correctText: question?.typ?.toLowerCase() === 'freitext' ? (result.correctAnswer || [])[0] : null,
isCorrect: result.yourResult?.correct,
} : null;
return (
<div className="min-h-screen flex flex-col p-4 max-w-2xl mx-auto">
<div className="text-center text-sm text-gray-400 mb-2">Frage {qIndex + 1} / {qTotal}</div>
<h2 className="text-xl font-bold mb-4 text-center">{question?.frage}</h2>
{question?.bild && <img src={question.bild} className="max-h-36 mx-auto rounded-lg mb-4" alt="" />}
{question && <AnswerGrid question={question} selected={selected} disabled feedback={liveFeedback} />}
<div className={`text-3xl text-center mt-4 font-bold ${result?.yourResult?.correct ? 'text-green-400' : 'text-red-400'}`}>
{result?.yourResult?.correct ? '✅ Richtig!' : '❌ Falsch'}
</div>
<p className="text-xl text-center font-bold mt-1">+{result?.yourResult?.points || 0} Punkte</p>
<p className="text-gray-400 text-center mb-2">Gesamt: {result?.yourResult?.totalScore || 0}</p>
<Leaderboard entries={leaderboardData} highlight={params.name} compact />
<p className="mt-4 text-gray-500 animate-pulse text-center">Warte auf nächste Frage...</p>
</div> </div>
<p className="text-2xl font-bold mb-1">+{result?.yourResult?.points || 0} Punkte</p> );
<p className="text-gray-400 mb-4">Gesamt: {result?.yourResult?.totalScore || 0}</p> }
{result?.correctAnswer && (
<p className="bg-gray-800 py-2 px-4 rounded-lg mb-4">Richtige Antwort: <strong className="text-amber-400">{result.correctAnswer.join(', ')}</strong></p>
)}
<Leaderboard entries={leaderboardData} highlight={params.name} compact />
<p className="mt-4 text-gray-500 animate-pulse">Warte auf nächste Frage...</p>
</div>
);
if (phase === 'final') return ( if (phase === 'final') return (
<div className="min-h-screen flex flex-col items-center justify-center p-4"> <div className="min-h-screen flex flex-col items-center justify-center p-4">
@@ -361,7 +341,7 @@ function LivePlayPage({ params, navigate }) {
} }
/* ================================================================== */ /* ================================================================== */
/* Solo Select Page */ /* Solo Select Page — with progress check */
/* ================================================================== */ /* ================================================================== */
function SoloSelectPage({ navigate }) { function SoloSelectPage({ navigate }) {
@@ -369,19 +349,51 @@ function SoloSelectPage({ navigate }) {
const [name, setName] = useState(''); const [name, setName] = useState('');
const [doShuffle, setDoShuffle] = useState(true); const [doShuffle, setDoShuffle] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [checking, setChecking] = useState(false);
const [progressDialog, setProgressDialog] = useState(null);
useEffect(() => { useEffect(() => { api.get('/api/sets').then(setSets).catch((e) => setError(e.message)); }, []);
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'); 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 ( return (
<div className="min-h-screen flex flex-col items-center p-6 pt-12"> <div className="min-h-screen flex flex-col items-center p-6 pt-12">
<h2 className="text-3xl font-bold mb-6">📚 Solo-Lernen</h2> <div className="w-full max-w-md"><BackButton onClick={() => navigate('home')} label="Zurück" /></div>
<h2 className="text-3xl font-bold mb-6 mt-2">📚 Solo-Lernen</h2>
<div className="w-full max-w-md space-y-4"> <div className="w-full max-w-md space-y-4">
<input className="w-full p-4 rounded-xl bg-gray-800 text-white text-center text-xl border-2 border-gray-700 focus:border-amber-500 outline-none" placeholder="Dein Name" value={name} onChange={(e) => setName(e.target.value)} /> <input className="w-full p-4 rounded-xl bg-gray-800 text-white text-center text-xl border-2 border-gray-700 focus:border-amber-500 outline-none" placeholder="Dein Name" value={name} onChange={(e) => setName(e.target.value)} />
<label className="flex items-center gap-3 text-gray-300 cursor-pointer"> <label className="flex items-center gap-3 text-gray-300 cursor-pointer">
@@ -389,44 +401,68 @@ function SoloSelectPage({ navigate }) {
Fragen mischen Fragen mischen
</label> </label>
{error && <p className="text-red-400">{error}</p>} {error && <p className="text-red-400">{error}</p>}
<h3 className="text-lg font-semibold mt-4">Fragenset wählen:</h3>
{sets.length === 0 && <p className="text-gray-500">Keine Fragensets konfiguriert. Bitte im Admin-Bereich einrichten.</p>} {/* Progress dialog */}
{sets.map((s) => ( {progressDialog && (
<button key={s.id} onClick={() => start(s.id, s.name)} className="w-full py-4 bg-amber-600 hover:bg-amber-700 rounded-xl text-lg font-bold transition-all shadow-lg shadow-amber-900/30">{s.name}</button> <div className="bg-gray-800 rounded-xl p-5 border-2 border-amber-500 space-y-3">
))} <h3 className="font-bold text-lg text-amber-400">📌 Gespeicherter Fortschritt</h3>
<button onClick={() => navigate('home')} className="w-full py-3 text-gray-400 hover:text-white"> Zurück</button> <p>{progressDialog.setName}: <strong>{progressDialog.totalAnswered}</strong> / {progressDialog.totalQuestions} Fragen beantwortet</p>
<p className="text-sm text-gray-400">{progressDialog.totalCorrect} davon richtig</p>
<div className="flex gap-3">
<button onClick={continueSession} className="flex-1 py-3 bg-amber-600 hover:bg-amber-700 rounded-xl font-bold">Weitermachen</button>
<button onClick={startFresh} className="flex-1 py-3 bg-gray-600 hover:bg-gray-500 rounded-xl font-bold">Neue Runde</button>
</div>
</div>
)}
{!progressDialog && (
<>
<h3 className="text-lg font-semibold mt-4">Fragenset wählen:</h3>
{sets.length === 0 && <p className="text-gray-500">Keine Fragensets konfiguriert.</p>}
{sets.map((s) => (
<button key={s.id} onClick={() => startSet(s.id, s.name)} disabled={checking}
className="w-full py-4 bg-amber-600 hover:bg-amber-700 disabled:opacity-50 rounded-xl text-lg font-bold transition-all shadow-lg shadow-amber-900/30">
{checking ? 'Prüfe...' : s.name}
</button>
))}
</>
)}
</div> </div>
</div> </div>
); );
} }
/* ================================================================== */ /* ================================================================== */
/* Solo Quiz Page */ /* Solo Quiz Page — immediate save per answer + continue support */
/* ================================================================== */ /* ================================================================== */
function SoloQuizPage({ params, navigate }) { function SoloQuizPage({ params, navigate }) {
const [allQuestions, setAllQuestions] = useState([]);
const [questions, setQuestions] = useState([]); const [questions, setQuestions] = useState([]);
const [index, setIndex] = useState(0); const [index, setIndex] = useState(0);
const [selected, setSelected] = useState([]); const [selected, setSelected] = useState([]);
const [showFeedback, setShowFeedback] = useState(false); const [showFeedback, setShowFeedback] = useState(false);
const [isCorrect, setIsCorrect] = useState(false); const [isCorrect, setIsCorrect] = useState(false);
const [correctAnswer, setCorrectAnswer] = useState([]); const [correctKeys, setCorrectKeys] = useState([]);
const [correctText, setCorrectText] = useState('');
const [results, setResults] = useState([]); const [results, setResults] = useState([]);
const [priorResults] = useState(params.priorAnswers || []);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [enlargedImg, setEnlargedImg] = useState(null); const [enlargedImg, setEnlargedImg] = useState(null);
const [shuffledAnswers, setShuffledAnswers] = useState([]); 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(() => { useEffect(() => {
api.get(`/api/sets/${params.setId}/questions`).then((qs) => { api.get(`/api/sets/${params.setId}/questions`).then((qs) => {
console.log(`Loaded ${qs.length} questions, sample Typ:`, qs[0]?.['Typ']); setAllQuestions(qs);
if (params.shuffle) { // Filter out already answered questions
for (let i = qs.length - 1; i > 0; i--) { let remaining = qs.filter((q) => !priorIds.current.has(q.id));
const j = Math.floor(Math.random() * (i + 1)); if (params.shuffle && remaining.length > 0) {
[qs[i], qs[j]] = [qs[j], qs[i]]; remaining = shuffleArray(remaining);
}
} }
setQuestions(qs); setQuestions(remaining);
}).catch((e) => setError(e.message)); }).catch((e) => setError(e.message));
}, []); }, []);
@@ -434,13 +470,27 @@ function SoloQuizPage({ params, navigate }) {
if (questions.length === 0 || index >= questions.length) return; if (questions.length === 0 || index >= questions.length) return;
const q = questions[index]; const q = questions[index];
const answers = []; const answers = [];
['A', 'B', 'C', 'D'].forEach((k) => { ['A', 'B', 'C', 'D'].forEach((k) => { if (q[`Antwort ${k}`]) answers.push({ key: k, text: q[`Antwort ${k}`] }); });
if (q[`Antwort ${k}`]) answers.push({ key: k, text: q[`Antwort ${k}`] });
});
setShuffledAnswers(shuffleArray(answers)); setShuffledAnswers(shuffleArray(answers));
}, [index, questions]); }, [index, questions]);
if (error) return <div className="min-h-screen flex items-center justify-center text-red-400 text-xl p-6 text-center">{error}</div>; if (error) return <div className="min-h-screen flex items-center justify-center text-red-400 text-xl p-6 text-center">{error}</div>;
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 (
<div className="min-h-screen flex flex-col items-center justify-center p-6">
<h2 className="text-2xl font-bold mb-4"> Alle Fragen bereits beantwortet!</h2>
<div className="space-y-3 w-full max-w-sm">
<button onClick={() => navigate('solo-result', { results: allResults, setName: params.setName, name: params.name, questions: allQuestions })}
className="w-full py-3 bg-amber-600 rounded-xl font-bold">Ergebnis anzeigen</button>
<button onClick={() => navigate('solo-quiz', { ...params, continueSessionId: null, priorAnswers: null })}
className="w-full py-3 bg-gray-600 rounded-xl font-bold">Neue Runde starten</button>
<button onClick={() => navigate('home')} className="w-full py-3 text-gray-400 hover:text-white"> Zur Startseite</button>
</div>
</div>
);
}
if (!questions.length) return <div className="min-h-screen flex items-center justify-center animate-pulse text-xl text-amber-400">Lade Fragen...</div>; if (!questions.length) return <div className="min-h-screen flex items-center justify-center animate-pulse text-xl text-amber-400">Lade Fragen...</div>;
const q = questions[index]; const q = questions[index];
@@ -452,11 +502,18 @@ function SoloQuizPage({ params, navigate }) {
antworten: shuffledAnswers, 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 checkAnswer = () => {
const rawCorrect = (q['Richtige Antwort'] || '').trim(); const rawCorrect = (q['Richtige Antwort'] || '').trim();
let correct, ok; let correct, ok;
if (typ === 'freitext') { if (typ === 'freitext') {
correct = [rawCorrect]; correct = []; setCorrectText(rawCorrect);
ok = normalizeText(selected[0]) === normalizeText(rawCorrect); ok = normalizeText(selected[0]) === normalizeText(rawCorrect);
} else { } else {
correct = rawCorrect.split(',').map((s) => s.trim().toUpperCase()).filter(Boolean); 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)); ok = correct.length === selectedSet.size && correct.every((c) => selectedSet.has(c));
} }
setCorrectAnswer(correct); setCorrectKeys(correct);
setIsCorrect(ok); setIsCorrect(ok);
setShowFeedback(true); setShowFeedback(true);
const newResults = [...results, { const answerText = selected.map((key) => {
Nutzername: params.name, const ans = shuffledAnswers.find((a) => a.key === key);
Frage_ID: q.id, return ans ? ans.text : key;
Fragenset: params.setName, }).join(', ');
Antwort: selected.join(','),
Richtig: ok, const answerData = {
Punkte: ok ? 1000 : 0, Nutzername: params.name, Frage_ID: q.id, Fragenset: params.setName,
Session_ID: sessionId.current, Antwort: answerText, Richtig: ok, Punkte: ok ? 1000 : 0,
Modus: 'Solo', Session_ID: sessionId.current, Modus: 'Solo', Zeitstempel: new Date().toISOString(),
Zeitstempel: new Date().toISOString(), };
}];
setResults(newResults); // 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 = () => { const nextQuestion = () => {
if (index + 1 >= questions.length) { if (index + 1 >= questions.length) {
api.post('/api/results', { answers: results }).catch((e) => console.error('Save error:', e)); const allResults = [
navigate('solo-result', { results, setName: params.setName, name: params.name, questions }); ...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; return;
} }
setIndex(index + 1); setIndex(index + 1);
setSelected([]); setSelected([]);
setShowFeedback(false); setShowFeedback(false);
setCorrectKeys([]);
setCorrectText('');
}; };
return ( return (
<div className="min-h-screen flex flex-col p-4 max-w-2xl mx-auto"> <div className="min-h-screen flex flex-col p-4 max-w-2xl mx-auto">
<div className="text-center text-sm text-gray-400 mb-2">Frage {index + 1} / {questions.length}</div> <div className="flex justify-between items-center mb-2">
<BackButton onClick={() => navigate('home')} />
<span className="text-sm text-gray-400">Frage {totalProgress} / {totalAll}</span>
</div>
<div className="w-full bg-gray-700 rounded-full h-2 mb-4"> <div className="w-full bg-gray-700 rounded-full h-2 mb-4">
<div className="bg-amber-500 h-full rounded-full transition-all" style={{ width: `${((index + 1) / questions.length) * 100}%` }} /> <div className="bg-amber-500 h-full rounded-full transition-all" style={{ width: `${(totalProgress / totalAll) * 100}%` }} />
</div> </div>
<h2 className="text-xl font-bold mb-4 text-center">{sanitized.frage}</h2> <h2 className="text-xl font-bold mb-4 text-center">{sanitized.frage}</h2>
{sanitized.bild && <img src={sanitized.bild} className="max-h-48 mx-auto rounded-lg mb-4 cursor-pointer" alt="" onClick={() => setEnlargedImg(sanitized.bild)} />} {sanitized.bild && <img src={sanitized.bild} className="max-h-48 mx-auto rounded-lg mb-4 cursor-pointer" alt="" onClick={() => setEnlargedImg(sanitized.bild)} />}
<AnswerGrid question={sanitized} selected={selected} onToggle={setSelected} disabled={showFeedback} /> <AnswerGrid question={sanitized} selected={selected} onToggle={setSelected} disabled={showFeedback} feedback={feedback} />
{!showFeedback && ( {!showFeedback && (
<button onClick={checkAnswer} disabled={selected.length === 0} className="mt-4 py-3 bg-amber-600 hover:bg-amber-700 disabled:opacity-40 rounded-xl text-lg font-bold w-full shadow-lg shadow-amber-900/30">Prüfen</button> <button onClick={checkAnswer} disabled={selected.length === 0} className="mt-4 py-3 bg-amber-600 hover:bg-amber-700 disabled:opacity-40 rounded-xl text-lg font-bold w-full shadow-lg shadow-amber-900/30">Prüfen</button>
)} )}
@@ -511,8 +582,8 @@ function SoloQuizPage({ params, navigate }) {
<div className={`text-2xl font-bold ${isCorrect ? 'text-green-400' : 'text-red-400'}`}> <div className={`text-2xl font-bold ${isCorrect ? 'text-green-400' : 'text-red-400'}`}>
{isCorrect ? '✅ Richtig!' : '❌ Falsch'} {isCorrect ? '✅ Richtig!' : '❌ Falsch'}
</div> </div>
{!isCorrect && <p className="bg-gray-800 py-2 px-4 rounded-lg inline-block">Richtig: <strong className="text-amber-400">{correctAnswer.join(', ')}</strong></p>} {saving && <p className="text-gray-500 text-sm animate-pulse">Speichere...</p>}
<button onClick={nextQuestion} className="w-full py-3 bg-red-600 hover:bg-red-700 rounded-xl text-lg font-bold shadow-lg shadow-red-900/30"> <button onClick={nextQuestion} disabled={saving} className="w-full py-3 bg-red-600 hover:bg-red-700 disabled:opacity-50 rounded-xl text-lg font-bold shadow-lg shadow-red-900/30">
{index + 1 >= questions.length ? 'Ergebnis anzeigen' : 'Nächste Frage →'} {index + 1 >= questions.length ? 'Ergebnis anzeigen' : 'Nächste Frage →'}
</button> </button>
</div> </div>
@@ -523,7 +594,7 @@ function SoloQuizPage({ params, navigate }) {
} }
/* ================================================================== */ /* ================================================================== */
/* Solo Result Page */ /* Solo Result Page — matches by Frage_ID */
/* ================================================================== */ /* ================================================================== */
function SoloResultPage({ params, navigate }) { function SoloResultPage({ params, navigate }) {
@@ -532,9 +603,13 @@ function SoloResultPage({ params, navigate }) {
const correct = results.filter((r) => r.Richtig).length; const correct = results.filter((r) => r.Richtig).length;
const pct = total > 0 ? Math.round((correct / total) * 100) : 0; 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 = {}; const byCategory = {};
results.forEach((r, i) => { results.forEach((r) => {
const q = questions[i]; const q = qMap[r.Frage_ID];
const cat = fieldVal(q?.['Kategorie'], 'Ohne Kategorie') || 'Ohne Kategorie'; const cat = fieldVal(q?.['Kategorie'], 'Ohne Kategorie') || 'Ohne Kategorie';
if (!byCategory[cat]) byCategory[cat] = { total: 0, correct: 0 }; if (!byCategory[cat]) byCategory[cat] = { total: 0, correct: 0 };
byCategory[cat].total++; byCategory[cat].total++;
@@ -579,8 +654,7 @@ function MyResultsPage({ navigate }) {
if (!name.trim()) return; if (!name.trim()) return;
setError(''); setError('');
api.get(`/api/results/${encodeURIComponent(name.trim())}`) api.get(`/api/results/${encodeURIComponent(name.trim())}`)
.then(setResults) .then(setResults).catch((e) => setError(e.message));
.catch((e) => setError(e.message));
}; };
const sessions = {}; const sessions = {};
@@ -594,7 +668,8 @@ function MyResultsPage({ navigate }) {
return ( return (
<div className="min-h-screen flex flex-col items-center p-6 pt-12"> <div className="min-h-screen flex flex-col items-center p-6 pt-12">
<h2 className="text-3xl font-bold mb-6">📊 Meine Ergebnisse</h2> <div className="w-full max-w-md"><BackButton onClick={() => navigate('home')} label="Zurück" /></div>
<h2 className="text-3xl font-bold mb-6 mt-2">📊 Meine Ergebnisse</h2>
<div className="w-full max-w-md space-y-3"> <div className="w-full max-w-md space-y-3">
<input className="w-full p-4 rounded-xl bg-gray-800 text-white text-center border-2 border-gray-700 focus:border-red-500 outline-none" placeholder="Dein Name" value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && search()} /> <input className="w-full p-4 rounded-xl bg-gray-800 text-white text-center border-2 border-gray-700 focus:border-red-500 outline-none" placeholder="Dein Name" value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && search()} />
<button onClick={search} className="w-full py-3 bg-red-600 hover:bg-red-700 rounded-xl font-bold shadow-lg shadow-red-900/30">Suchen</button> <button onClick={search} className="w-full py-3 bg-red-600 hover:bg-red-700 rounded-xl font-bold shadow-lg shadow-red-900/30">Suchen</button>
@@ -605,7 +680,7 @@ function MyResultsPage({ navigate }) {
<div className="w-full max-w-md mt-6 space-y-4"> <div className="w-full max-w-md mt-6 space-y-4">
{Object.entries(sessions).reverse().map(([sid, s]) => { {Object.entries(sessions).reverse().map(([sid, s]) => {
const total = s.answers.length; 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); const points = s.answers.reduce((sum, a) => sum + (a['Punkte'] || 0), 0);
return ( return (
<div key={sid} className="bg-gray-800 rounded-xl p-4"> <div key={sid} className="bg-gray-800 rounded-xl p-4">
@@ -616,13 +691,12 @@ function MyResultsPage({ navigate }) {
</div> </div>
<span className="text-sm text-gray-400">{s.date ? new Date(s.date).toLocaleDateString('de') : ''}</span> <span className="text-sm text-gray-400">{s.date ? new Date(s.date).toLocaleDateString('de') : ''}</span>
</div> </div>
<p>{correct}/{total} richtig {points} Punkte</p> <p>{cor}/{total} richtig {points} Punkte</p>
</div> </div>
); );
})} })}
</div> </div>
)} )}
<button onClick={() => navigate('home')} className="mt-6 py-3 px-6 text-gray-400 hover:text-white"> Zurück</button>
</div> </div>
); );
} }
@@ -634,12 +708,7 @@ function MyResultsPage({ navigate }) {
function App() { function App() {
const [page, setPage] = useState('home'); const [page, setPage] = useState('home');
const [params, setParams] = useState({}); 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) { switch (page) {
case 'home': return <HomePage navigate={navigate} />; case 'home': return <HomePage navigate={navigate} />;
+47 -25
View File
@@ -4,8 +4,6 @@ function wsUrl() {
return `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}`; return `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}`;
} }
const COLORS = { A: 'answer-a', B: 'answer-b', C: 'answer-c', D: 'answer-d' };
function PresentApp() { function PresentApp() {
const [phase, setPhase] = useState('connect'); const [phase, setPhase] = useState('connect');
const [session, setSession] = useState(null); const [session, setSession] = useState(null);
@@ -19,7 +17,6 @@ function PresentApp() {
const [leaderboard, setLeaderboard] = useState([]); const [leaderboard, setLeaderboard] = useState([]);
const [error, setError] = useState(''); const [error, setError] = useState('');
const timerRef = useRef(null); const timerRef = useRef(null);
const wsRef = useRef(null);
useEffect(() => { useEffect(() => {
const code = location.hash.replace('#', ''); const code = location.hash.replace('#', '');
@@ -32,7 +29,6 @@ function PresentApp() {
} }
const ws = new WebSocket(wsUrl()); const ws = new WebSocket(wsUrl());
wsRef.current = ws;
ws.onopen = () => { ws.onopen = () => {
ws.send(JSON.stringify({ type: 'present-join', token, code })); ws.send(JSON.stringify({ type: 'present-join', token, code }));
@@ -88,7 +84,6 @@ function PresentApp() {
}; };
ws.onerror = () => setPhase('error'); ws.onerror = () => setPhase('error');
ws.onclose = () => { if (phase !== 'final') setPhase('error'); };
return () => ws.close(); return () => ws.close();
}, []); }, []);
@@ -106,9 +101,9 @@ function PresentApp() {
if (phase === 'need-config') { if (phase === 'need-config') {
return ( return (
<div className="min-h-screen flex flex-col items-center justify-center p-8"> <div className="min-h-screen flex flex-col items-center justify-center p-8">
<img src="/img/logo.png" alt="Quizalarm" className="w-24 h-24 mb-2" /> <div className="text-6xl mb-4">🚨</div>
<h1 className="text-3xl font-bold mb-4 fire-gradient">Quizalarm Präsentation</h1> <h1 className="text-3xl font-bold mb-4 fire-gradient">Quizalarm Präsentation</h1>
<p className="text-gray-400 mb-4 text-center">Öffne diesen Link aus dem Admin-Panel.<br />Die URL muss <code className="bg-gray-800 px-2 py-1 rounded">?token=PASSWORT#CODE</code> enthalten.</p> <p className="text-gray-400 mb-4 text-center">Öffne diesen Link aus dem Admin-Panel.</p>
</div> </div>
); );
} }
@@ -124,7 +119,7 @@ function PresentApp() {
if (phase === 'waiting') return ( if (phase === 'waiting') return (
<div className="min-h-screen flex flex-col items-center justify-center p-8"> <div className="min-h-screen flex flex-col items-center justify-center p-8">
<img src="/img/logo.png" alt="Quizalarm" className="w-24 h-24 mb-2" /> <div className="text-6xl mb-4">🚨</div>
<h1 className="text-4xl font-extrabold mb-4 fire-gradient">Quizalarm</h1> <h1 className="text-4xl font-extrabold mb-4 fire-gradient">Quizalarm</h1>
<p className="text-gray-400 text-xl mb-6">{session?.setName}</p> <p className="text-gray-400 text-xl mb-6">{session?.setName}</p>
<div className="text-8xl font-mono font-extrabold tracking-widest text-red-500 mb-8">{session?.code}</div> <div className="text-8xl font-mono font-extrabold tracking-widest text-red-500 mb-8">{session?.code}</div>
@@ -152,15 +147,18 @@ function PresentApp() {
<div className={`${barColor} h-full rounded-full transition-all duration-200`} style={{ width: `${pct}%` }} /> <div className={`${barColor} h-full rounded-full transition-all duration-200`} style={{ width: `${pct}%` }} />
</div> </div>
)} )}
<h2 className="text-4xl font-bold text-center mb-8 flex-shrink-0">{question?.frage}</h2> <h2 className="text-4xl font-bold text-center mb-8">{question?.frage}</h2>
{question?.bild && <img src={question.bild} className="max-h-64 mx-auto rounded-xl mb-8" alt="" />} {question?.bild && <img src={question.bild} className="max-h-64 mx-auto rounded-xl mb-8" alt="" />}
{question?.typ?.toLowerCase() !== 'freitext' && ( {question?.typ?.toLowerCase() !== 'freitext' && (
<div className="grid grid-cols-2 gap-4 flex-1 max-h-96"> <div className="grid grid-cols-2 gap-4 flex-1 max-h-96">
{(question?.antworten || []).map((a) => ( {(question?.antworten || []).map((a) => {
<div key={a.key} className={`${COLORS[a.key]} rounded-2xl flex items-center justify-center p-6 text-2xl font-bold`}> const colors = { A: 'bg-red-600', B: 'bg-blue-600', C: 'bg-amber-600', D: 'bg-green-600' };
{a.text} return (
</div> <div key={a.key} className={`${colors[a.key] || 'bg-gray-600'} rounded-2xl flex items-center justify-center p-6 text-2xl font-bold`}>
))} {a.text}
</div>
);
})}
</div> </div>
)} )}
{question?.typ?.toLowerCase() === 'freitext' && ( {question?.typ?.toLowerCase() === 'freitext' && (
@@ -171,20 +169,44 @@ function PresentApp() {
} }
if (phase === 'result') { if (phase === 'result') {
const maxStat = Math.max(1, ...Object.values(result?.stats || {})); const correctKeys = new Set(result?.correctAnswer || []);
return ( return (
<div className="min-h-screen flex flex-col p-8"> <div className="min-h-screen flex flex-col p-8">
<h2 className="text-3xl font-bold text-center mb-2">Ergebnis</h2> <h2 className="text-2xl text-gray-400 text-center mb-2">Frage {qIndex + 1} / {qTotal}</h2>
<p className="text-center text-xl text-amber-400 mb-6">Richtig: {result?.correctAnswer?.join(', ')}</p> <h2 className="text-3xl font-bold text-center mb-6">{question?.frage}</h2>
<div className="flex gap-4 justify-center items-end mb-8 h-48"> {question?.bild && <img src={question.bild} className="max-h-48 mx-auto rounded-xl mb-6" alt="" />}
{Object.entries(result?.stats || {}).map(([k, v]) => (
<div key={k} className="flex flex-col items-center flex-1 max-w-32"> {/* Answer options with feedback colors + vote counts */}
<span className="text-xl font-bold mb-1">{v}</span> {question?.typ?.toLowerCase() !== 'freitext' && (
<div className={`${COLORS[k]} w-full rounded-t-lg transition-all`} style={{ height: `${(v / maxStat) * 150}px`, minHeight: '8px' }} /> <div className="grid grid-cols-2 gap-4 mb-8">
<span className="text-lg font-bold mt-1">{k}</span> {(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 (
<div key={a.key} className={`${cls} rounded-2xl p-6 text-2xl font-bold flex items-center justify-between`}>
<span>{isCorrect ? '✓ ' : ''}{a.text}</span>
<span className="text-3xl ml-4 opacity-80">{count}</span>
</div>
);
})}
</div>
)}
{question?.typ?.toLowerCase() === 'freitext' && (
<div className="text-center mb-8">
<div className="inline-block bg-green-600 ring-4 ring-green-400 rounded-2xl px-8 py-4 text-2xl font-bold">
{result?.correctAnswer?.[0]}
</div> </div>
))} </div>
</div> )}
{/* Leaderboard */}
<div className="flex-1"> <div className="flex-1">
<h3 className="text-2xl font-bold text-center mb-4">🏆 Leaderboard</h3> <h3 className="text-2xl font-bold text-center mb-4">🏆 Leaderboard</h3>
<div className="max-w-xl mx-auto"> <div className="max-w-xl mx-auto">
+9 -2
View File
@@ -9,7 +9,6 @@ async function baserowFetch(endpoint, options = {}) {
Authorization: `Token ${baserowToken}`, Authorization: `Token ${baserowToken}`,
...(options.headers || {}), ...(options.headers || {}),
}; };
// Only set Content-Type for requests with body
if (options.body) { if (options.body) {
headers['Content-Type'] = 'application/json'; headers['Content-Type'] = 'application/json';
} }
@@ -42,6 +41,14 @@ async function listRows(tableId, filters = {}) {
return all; 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) { async function batchCreateRows(tableId, rows) {
if (!rows.length) return; if (!rows.length) return;
console.log(`[baserow] Batch create ${rows.length} rows in table ${tableId}`); console.log(`[baserow] Batch create ${rows.length} rows in table ${tableId}`);
@@ -54,4 +61,4 @@ async function batchCreateRows(tableId, rows) {
} }
} }
module.exports = { listRows, batchCreateRows }; module.exports = { listRows, createRow, batchCreateRows };
+153 -29
View File
@@ -2,9 +2,9 @@ const express = require('express');
const http = require('http'); const http = require('http');
const path = require('path'); const path = require('path');
const { getEnvConfig, readConfig, writeConfig } = require('./config'); const { getEnvConfig, readConfig, writeConfig } = require('./config');
const { listRows, batchCreateRows } = require('./baserow'); const { listRows, createRow, batchCreateRows } = require('./baserow');
const { setupWebSocket, getSessions } = require('./live-session'); const { setupWebSocket, getSessions } = require('./live-session');
const { sanitizeQuestion, shuffleArray } = require('./quiz-logic'); const { extractFieldValue } = require('./quiz-logic');
const app = express(); const app = express();
const server = http.createServer(app); const server = http.createServer(app);
@@ -19,13 +19,77 @@ function requireAdmin(req, res, next) {
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 */ /* Public API */
/* ============================================================ */ /* ============================================================ */
app.get('/api/sets', (req, res) => { app.get('/api/sets', (req, res) => {
const config = readConfig(); 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 }))); 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 config = readConfig();
const set = config.sets.find((s) => s.id === req.params.id); const set = config.sets.find((s) => s.id === req.params.id);
if (!set) return res.status(404).json({ error: 'Fragenset 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})`); let rows = await listRows(set.tableId);
const rows = await listRows(set.tableId); rows = rewriteImageUrls(rows);
console.log(`[api] Loaded ${rows.length} questions`);
if (rows.length > 0) {
console.log(`[api] Fields: ${Object.keys(rows[0]).join(', ')}`);
}
res.json(rows); res.json(rows);
} catch (e) { } catch (e) {
console.error(`[api] Error loading questions:`, e.message);
res.status(500).json({ error: 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) => { app.post('/api/results', async (req, res) => {
try { try {
const config = readConfig(); const config = readConfig();
if (!config.answersTableId) return res.status(400).json({ error: 'Antworten-Tabelle nicht konfiguriert' }); if (!config.answersTableId) return res.status(400).json({ error: 'Antworten-Tabelle nicht konfiguriert' });
const answers = req.body.answers || []; await batchCreateRows(config.answersTableId, req.body.answers || []);
console.log(`[api] Saving ${answers.length} results`);
await batchCreateRows(config.answersTableId, answers);
res.json({ success: true }); res.json({ success: true });
} catch (e) { } catch (e) {
console.error(`[api] Error saving results:`, e.message);
res.status(500).json({ error: 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 }); const rows = await listRows(config.answersTableId, { Nutzername: req.params.name });
res.json(rows); res.json(rows);
} catch (e) { } catch (e) {
console.error(`[api] Error loading results:`, e.message);
res.status(500).json({ error: 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.get('/api/admin/config', requireAdmin, (req, res) => res.json(readConfig()));
app.post('/api/admin/config', requireAdmin, (req, res) => { app.post('/api/admin/config', requireAdmin, (req, res) => {
try { try { writeConfig(req.body); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); }
writeConfig(req.body);
console.log('[api] Config updated');
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/sessions', requireAdmin, (req, res) => res.json(getSessions()));
app.get('/api/admin/stats', requireAdmin, async (req, res) => { 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); const rows = await listRows(config.answersTableId);
res.json({ answers: rows }); res.json({ answers: rows });
} catch (e) { } catch (e) {
console.error('[api] Stats error:', e.message);
res.status(500).json({ 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) => { app.get('/api/admin/debug-baserow', requireAdmin, async (req, res) => {
try { try {
const { baserowUrl, baserowToken } = getEnvConfig(); 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('/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('/present', (req, res) => res.sendFile(path.join(__dirname, '..', 'public', 'present.html')));
app.get('*', (req, res) => res.sendFile(path.join(__dirname, '..', 'public', 'index.html'))); app.get('*', (req, res) => res.sendFile(path.join(__dirname, '..', 'public', 'index.html')));
/* ---- Start ---- */
setupWebSocket(server); setupWebSocket(server);
const { port } = getEnvConfig(); const { port } = getEnvConfig();
server.listen(port, '0.0.0.0', () => { server.listen(port, '0.0.0.0', () => {
console.log(`[quizalarm] Server laeuft auf Port ${port}`); console.log(`[quizalarm] Server laeuft auf Port ${port}`);
console.log(`[quizalarm] Baserow URL: ${getEnvConfig().baserowUrl}`); console.log(`[quizalarm] Baserow URL: ${getEnvConfig().baserowUrl}`);
console.log(`[quizalarm] Config: ${JSON.stringify(readConfig())}`);
}); });
+75 -122
View File
@@ -27,10 +27,6 @@ function broadcastAdmins(session, data) {
for (const ws of session.presents) send(ws, data); for (const ws of session.presents) send(ws, data);
} }
function broadcastPlayers(session, data) {
for (const [, p] of session.players) send(p.ws, data);
}
function playerList(session) { function playerList(session) {
return Array.from(session.players.keys()); return Array.from(session.players.keys());
} }
@@ -44,15 +40,9 @@ function leaderboard(session) {
function sessionInfo(s) { function sessionInfo(s) {
return { return {
code: s.code, code: s.code, setName: s.setName, status: s.status, options: s.options,
setName: s.setName, questionCount: s.questions.length, currentQuestionIndex: s.currentQuestionIndex,
status: s.status, playerCount: s.players.size, players: playerList(s), leaderboard: leaderboard(s),
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; return token === getEnvConfig().adminPassword;
} }
/* ------------------------------------------------------------------ */
/* WebSocket setup */
/* ------------------------------------------------------------------ */
function setupWebSocket(server) { function setupWebSocket(server) {
const wss = new WebSocketServer({ server }); const wss = new WebSocketServer({ server });
wss.on('connection', (ws) => { wss.on('connection', (ws) => {
ws.isAlive = true; ws.isAlive = true;
ws.on('pong', () => (ws.isAlive = true)); ws.on('pong', () => (ws.isAlive = true));
ws.on('message', (raw) => { ws.on('message', (raw) => {
try { try { handleMessage(ws, JSON.parse(raw)); }
handleMessage(ws, JSON.parse(raw)); catch (e) { console.error('[ws] Error:', e); send(ws, { type: 'error', message: 'Ungueltige Nachricht' }); }
} catch (e) {
console.error('[ws] Message error:', e);
send(ws, { type: 'error', message: 'Ungueltige Nachricht' });
}
}); });
ws.on('close', () => handleDisconnect(ws)); ws.on('close', () => handleDisconnect(ws));
}); });
setInterval(() => { setInterval(() => {
wss.clients.forEach((ws) => { wss.clients.forEach((ws) => {
if (!ws.isAlive) return ws.terminate(); if (!ws.isAlive) return ws.terminate();
@@ -99,98 +79,54 @@ function setupWebSocket(server) {
}, 30000); }, 30000);
} }
/* ------------------------------------------------------------------ */
/* Message router */
/* ------------------------------------------------------------------ */
async function handleMessage(ws, msg) { async function handleMessage(ws, msg) {
console.log(`[ws] Received: ${msg.type}`);
switch (msg.type) { switch (msg.type) {
case 'admin-create': case 'admin-create': return adminCreate(ws, msg);
return adminCreate(ws, msg); case 'admin-join': return adminJoin(ws, msg);
case 'admin-join': case 'admin-start': return adminStart(ws, msg);
return adminJoin(ws, msg); case 'admin-show-result': return adminShowResult(ws, msg);
case 'admin-start': case 'admin-next': return adminNext(ws, msg);
return adminStart(ws, msg); case 'admin-end': return adminEnd(ws, msg);
case 'admin-show-result': case 'present-join': return presentJoin(ws, msg);
return adminShowResult(ws, msg); case 'join': return playerJoin(ws, msg);
case 'admin-next': case 'answer': return playerAnswer(ws, msg);
return adminNext(ws, msg); default: send(ws, { type: 'error', message: 'Unbekannter Typ' });
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 });
} }
} }
/* ------------------------------------------------------------------ */ /* ---- Admin ---- */
/* Admin handlers */
/* ------------------------------------------------------------------ */
async function adminCreate(ws, msg) { async function adminCreate(ws, msg) {
if (!isAdmin(msg.token)) return send(ws, { type: 'error', message: 'Nicht autorisiert' }); if (!isAdmin(msg.token)) return send(ws, { type: 'error', message: 'Nicht autorisiert' });
const config = readConfig(); const config = readConfig();
const set = config.sets.find((s) => s.id === msg.setId); const set = config.sets.find((s) => s.id === msg.setId);
if (!set) return send(ws, { type: 'error', message: 'Fragenset nicht gefunden' }); if (!set) return send(ws, { type: 'error', message: 'Fragenset nicht gefunden' });
let questions; let questions;
try { try { questions = await listRows(set.tableId); }
questions = await listRows(set.tableId); catch (e) { return send(ws, { type: 'error', message: 'Fehler: ' + e.message }); }
console.log(`[live] Loaded ${questions.length} questions for set "${set.name}"`); if (!questions.length) return send(ws, { type: 'error', message: 'Keine Fragen gefunden' });
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' });
const opts = { const opts = {
shuffle: msg.options.shuffle !== false, shuffle: msg.options.shuffle !== false,
scoring: msg.options.scoring || 'binary', scoring: msg.options.scoring || 'binary',
timeLimit: parseInt(msg.options.timeLimit) || 30, timeLimit: parseInt(msg.options.timeLimit) || 30,
}; };
if (opts.shuffle) questions = shuffle(questions); if (opts.shuffle) questions = shuffle(questions);
const code = generateCode(); const code = generateCode();
const session = { const session = {
code, code, sessionId: 'LIVE-' + Date.now().toString(36).toUpperCase(),
sessionId: 'LIVE-' + Date.now().toString(36).toUpperCase(), setId: set.id, setName: set.name, tableId: set.tableId, options: opts,
setId: set.id, questions, currentQuestionIndex: -1, status: 'waiting',
setName: set.name, players: new Map(), admins: new Set([ws]), presents: new Set(),
tableId: set.tableId, currentAnswers: new Map(), questionStartTime: null, timer: null,
options: opts, currentSanitized: null, results: [],
questions,
currentQuestionIndex: -1,
status: 'waiting',
players: new Map(),
admins: new Set([ws]),
presents: new Set(),
currentAnswers: new Map(),
questionStartTime: null,
timer: null,
results: [],
}; };
sessions.set(code, session); sessions.set(code, session);
ws._qa = { code, role: 'admin' }; 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) { function adminJoin(ws, msg) {
@@ -224,7 +160,6 @@ function adminShowResult(ws, msg) {
if (!isAdmin(msg.token)) return; if (!isAdmin(msg.token)) return;
const s = sessions.get(msg.code); const s = sessions.get(msg.code);
if (!s || s.status !== 'active') return; if (!s || s.status !== 'active') return;
console.log(`[live] Admin forced show result for session ${s.code}`);
showResult(s); showResult(s);
} }
@@ -242,31 +177,65 @@ async function adminEnd(ws, msg) {
await endSession(s); await endSession(s);
} }
/* ------------------------------------------------------------------ */ /* ---- Player ---- */
/* Player handlers */
/* ------------------------------------------------------------------ */
function playerJoin(ws, msg) { function playerJoin(ws, msg) {
const s = sessions.get(msg.code); const s = sessions.get(msg.code);
if (!s) return send(ws, { type: 'error', message: 'Session nicht gefunden. Pruefe den 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 oder ist beendet.' });
const name = (msg.name || '').trim(); const name = (msg.name || '').trim();
if (!name) return send(ws, { type: 'error', message: 'Name darf nicht leer sein' }); if (!name) return send(ws, { type: 'error', message: 'Name darf nicht leer sein' });
// --- Reconnect existing player ---
if (s.players.has(name)) { if (s.players.has(name)) {
const existing = s.players.get(name); const existing = s.players.get(name);
if (existing.ws && existing.ws.readyState === WebSocket.OPEN) { if (existing.ws && existing.ws.readyState === WebSocket.OPEN) {
return send(ws, { type: 'error', message: 'Name bereits online. Bitte waehle einen anderen.' }); return send(ws, { type: 'error', message: 'Name bereits online. Bitte waehle einen anderen.' });
} }
// Reconnect
existing.ws = ws; existing.ws = ws;
} else { ws._qa = { code: msg.code, role: 'player', name };
s.players.set(name, { ws, score: 0, answers: [] });
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 }; 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); 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 elapsed = (Date.now() - s.questionStartTime) / 1000;
const tl = s.options.scoring === 'time' ? s.options.timeLimit : 0; const tl = s.options.scoring === 'time' ? s.options.timeLimit : 0;
const remaining = tl > 0 ? Math.max(0, tl - elapsed) : 0; const remaining = tl > 0 ? Math.max(0, tl - elapsed) : 0;
if (tl > 0 && remaining <= 0) return; if (tl > 0 && remaining <= 0) return;
s.currentAnswers.set(qa.name, { answers: msg.answers || [], timeRemaining: remaining }); 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); if (s.currentAnswers.size >= s.players.size) showResult(s);
} }
/* ------------------------------------------------------------------ */ /* ---- Game flow ---- */
/* Game flow */
/* ------------------------------------------------------------------ */
function sendNextQuestion(s) { function sendNextQuestion(s) {
s.currentQuestionIndex++; s.currentQuestionIndex++;
@@ -305,15 +271,11 @@ function sendNextQuestion(s) {
const q = s.questions[s.currentQuestionIndex]; const q = s.questions[s.currentQuestionIndex];
const tl = s.options.scoring === 'time' ? s.options.timeLimit : 0; const tl = s.options.scoring === 'time' ? s.options.timeLimit : 0;
const sanitized = sanitizeQuestion(q); const sanitized = sanitizeQuestion(q);
s.currentSanitized = sanitized; // Store for reconnecting players
console.log(`[live] Session ${s.code}: Sending question ${s.currentQuestionIndex + 1}/${s.questions.length}`);
broadcastAll(s, { broadcastAll(s, {
type: 'question', type: 'question', index: s.currentQuestionIndex, total: s.questions.length,
index: s.currentQuestionIndex, question: sanitized, timeLimit: tl,
total: s.questions.length,
question: sanitized,
timeLimit: tl,
}); });
clearTimeout(s.timer); clearTimeout(s.timer);
@@ -383,32 +345,23 @@ async function endSession(s) {
const config = readConfig(); const config = readConfig();
if (config.answersTableId && s.results.length) { if (config.answersTableId && s.results.length) {
await batchCreateRows(config.answersTableId, s.results); await batchCreateRows(config.answersTableId, s.results);
console.log(`[live] Session ${s.code}: ${s.results.length} Ergebnisse in Baserow gespeichert`); console.log(`[live] Session ${s.code}: ${s.results.length} Ergebnisse gespeichert`);
} else {
console.log(`[live] Session ${s.code}: Keine Antworten-Tabelle konfiguriert oder keine Ergebnisse`);
} }
} catch (e) { } catch (e) {
console.error('[live] Fehler beim Speichern:', e.message); console.error('[live] Speicherfehler:', e.message);
} }
setTimeout(() => sessions.delete(s.code), 300000); setTimeout(() => sessions.delete(s.code), 300000);
} }
/* ------------------------------------------------------------------ */
/* Disconnect */
/* ------------------------------------------------------------------ */
function handleDisconnect(ws) { function handleDisconnect(ws) {
const qa = ws._qa; const qa = ws._qa;
if (!qa) return; if (!qa) return;
const s = sessions.get(qa.code); const s = sessions.get(qa.code);
if (!s) return; if (!s) return;
if (qa.role === 'admin') s.admins.delete(ws); if (qa.role === 'admin') s.admins.delete(ws);
else if (qa.role === 'present') s.presents.delete(ws); else if (qa.role === 'present') s.presents.delete(ws);
else if (qa.role === 'player') { // Players stay in the session (they can reconnect)
broadcastAll(s, { type: 'player-left', name: qa.name, playerCount: s.players.size, players: playerList(s) });
}
} }
function getSessions() { function getSessions() {
+3 -16
View File
@@ -1,7 +1,3 @@
/**
* Normalize text for freitext comparison.
* "Villingen-Schwenningen" == "villingen schwenningen" == "villingenschwenningen"
*/
function normalizeText(text) { function normalizeText(text) {
if (!text) return ''; if (!text) return '';
return text return text
@@ -14,10 +10,6 @@ function normalizeText(text) {
.trim(); .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) { function extractFieldValue(field, fallback) {
if (!field) return fallback || ''; if (!field) return fallback || '';
if (typeof field === 'string') return field; if (typeof field === 'string') return field;
@@ -61,9 +53,6 @@ function shuffleArray(arr) {
return a; return a;
} }
/**
* Calculate score for an answer.
*/
function calculateScore(question, selected, timeRemaining, totalTime, scoringMode) { function calculateScore(question, selected, timeRemaining, totalTime, scoringMode) {
if (!selected || selected.length === 0) return { points: 0, correct: false }; 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 }; return { points, correct: fullyCorrect };
} }
/**
* Strip the correct answer and shuffle options before sending to participants.
*/
function sanitizeQuestion(question) { function sanitizeQuestion(question) {
const typ = getTyp(question); const typ = getTyp(question);
const sanitized = { const sanitized = {
@@ -118,9 +104,10 @@ function sanitizeQuestion(question) {
kategorie: extractFieldValue(question['Kategorie'], ''), kategorie: extractFieldValue(question['Kategorie'], ''),
}; };
// Rewrite image URL to use proxy
const bild = question['Bild']; const bild = question['Bild'];
if (bild && Array.isArray(bild) && bild.length > 0) { if (bild && Array.isArray(bild) && bild.length > 0 && bild[0].url) {
sanitized.bild = bild[0].url; sanitized.bild = `/api/img?url=${encodeURIComponent(bild[0].url)}`;
} }
if (typ !== 'freitext') { if (typ !== 'freitext') {