Initial: Quizalarm Quiz-Plattform
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
@@ -0,0 +1,149 @@
|
||||
# Quizalarm — Baserow Tabellenstruktur
|
||||
|
||||
Diese Anleitung beschreibt, wie die benötigten Tabellen in Baserow angelegt werden.
|
||||
|
||||
---
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- Baserow erreichbar unter `https://br.orfel.de`
|
||||
- Ein API-Token erstellt unter: **Settings → API Tokens → Create Token**
|
||||
- Rechte: mindestens `create`, `read`, `update` für alle relevanten Tabellen
|
||||
|
||||
---
|
||||
|
||||
## 1. Datenbank anlegen
|
||||
|
||||
1. In Baserow einloggen
|
||||
2. Klicke auf **+ Create database**
|
||||
3. Name: `Quizalarm`
|
||||
|
||||
---
|
||||
|
||||
## 2. Antworten-Tabelle anlegen
|
||||
|
||||
Diese Tabelle speichert ALLE Ergebnisse (Live + Solo). Es gibt nur eine einzige.
|
||||
|
||||
1. In der Datenbank `Quizalarm` → **+ Add table** → Name: `Antworten`
|
||||
2. Lösche die automatisch erstellten Beispiel-Spalten
|
||||
3. Erstelle folgende Felder:
|
||||
|
||||
| Nr. | Feldname | Feldtyp | Optionen / Hinweis |
|
||||
|-----|----------------|---------------------|---------------------------------------------|
|
||||
| 1 | `Nutzername` | Single line text | |
|
||||
| 2 | `Frage_ID` | Number (Integer) | |
|
||||
| 3 | `Fragenset` | Single line text | |
|
||||
| 4 | `Antwort` | Single line text | Enthält z.B. `A,C` oder `Berlin` |
|
||||
| 5 | `Richtig` | Boolean | |
|
||||
| 6 | `Punkte` | Number (Integer) | |
|
||||
| 7 | `Session_ID` | Single line text | z.B. `LIVE-M3K7X` oder `SOLO-K8P2Q` |
|
||||
| 8 | `Modus` | Single select | Optionen anlegen: `Live`, `Solo` |
|
||||
| 9 | `Zeitstempel` | Date | ✅ "Include time" aktivieren |
|
||||
|
||||
1. **Table-ID notieren**: Klicke auf die Tabelle → die URL enthält `/database/TABLE_ID/...`
|
||||
|
||||
---
|
||||
|
||||
## 3. Fragenset-Tabellen anlegen
|
||||
|
||||
Für jedes Fragenset wird eine eigene Tabelle erstellt. Der Aufbau ist immer identisch.
|
||||
|
||||
### Schritt-für-Schritt
|
||||
|
||||
1. In der Datenbank `Quizalarm` → **+ Add table**
|
||||
2. Name = Name des Fragensets (z.B. `IT-Grundlagen`, `Erste Hilfe`, `Brandschutz`)
|
||||
3. Lösche die automatisch erstellten Beispiel-Spalten
|
||||
4. Erstelle folgende Felder:
|
||||
|
||||
| Nr. | Feldname | Feldtyp | Optionen / Hinweis |
|
||||
|-----|-------------------|---------------------|------------------------------------------------------------|
|
||||
| 1 | `Frage` | Long text | Der vollständige Fragetext |
|
||||
| 2 | `Typ` | Single select | Optionen anlegen: `MC`, `Wahr/Falsch`, `Freitext` |
|
||||
| 3 | `Bild` | File | Optional — Bild zur Frage hochladen |
|
||||
| 4 | `Antwort A` | Single line text | Bei Wahr/Falsch: `Wahr` |
|
||||
| 5 | `Antwort B` | Single line text | Bei Wahr/Falsch: `Falsch` |
|
||||
| 6 | `Antwort C` | Single line text | Leer lassen bei Wahr/Falsch oder 2 Antworten |
|
||||
| 7 | `Antwort D` | Single line text | Leer lassen bei weniger als 4 Antworten |
|
||||
| 8 | `Richtige Antwort`| Single line text | Siehe Ausfüllregeln unten |
|
||||
| 9 | `Kategorie` | Single select | Optional — z.B. `Geografie`, `Technik`, `Recht` |
|
||||
|
||||
1. **Table-ID notieren**: URL enthält `/database/TABLE_ID/...`
|
||||
|
||||
---
|
||||
|
||||
## 4. Ausfüllregeln für "Richtige Antwort"
|
||||
|
||||
| Fragetyp | Beispiel-Wert | Erklärung |
|
||||
|------------------|----------------------|---------------------------------------------------|
|
||||
| MC (1 richtig) | `A` | Nur der Buchstabe der richtigen Antwort |
|
||||
| MC (mehrere) | `A,C` | Kommagetrennt, ohne Leerzeichen |
|
||||
| Wahr/Falsch | `A` | `A` = Wahr, `B` = Falsch |
|
||||
| Freitext | `Berlin` | Das erwartete Wort (Groß/Kleinschreibung egal) |
|
||||
|
||||
### Freitext-Toleranz
|
||||
|
||||
Die App normalisiert Eingaben automatisch:
|
||||
|
||||
- Groß/Kleinschreibung wird ignoriert
|
||||
- Sonderzeichen (Bindestriche, Leerzeichen etc.) werden entfernt
|
||||
- Umlaute werden umgewandelt (ä→ae, ö→oe, ü→ue, ß→ss)
|
||||
|
||||
**Beispiel:** Richtige Antwort = `Villingen-Schwenningen`
|
||||
|
||||
- ✅ `villingen schwenningen`
|
||||
- ✅ `villingenschwenningen`
|
||||
- ✅ `Villingen Schwenningen`
|
||||
- ❌ `Villingen` (unvollständig)
|
||||
- ❌ `Stuttgart` (falsch)
|
||||
|
||||
---
|
||||
|
||||
## 5. Beispielfragen
|
||||
|
||||
### MC mit 4 Antworten
|
||||
|
||||
| Frage | Typ | Antwort A | Antwort B | Antwort C | Antwort D | Richtige Antwort | Kategorie |
|
||||
|-------|-----|-----------|-----------|-----------|-----------|-----------------|-----------|
|
||||
| Wie heißt die Hauptstadt von Baden-Württemberg? | MC | Stuttgart | Karlsruhe | Freiburg | Mannheim | A | Geografie |
|
||||
|
||||
### MC mit mehreren richtigen Antworten
|
||||
|
||||
| Frage | Typ | Antwort A | Antwort B | Antwort C | Antwort D | Richtige Antwort | Kategorie |
|
||||
|-------|-----|-----------|-----------|-----------|-----------|-----------------|-----------|
|
||||
| Welche sind Programmiersprachen? | MC | Python | HTML | Java | Photoshop | A,C | IT |
|
||||
|
||||
### MC mit 3 Antworten
|
||||
|
||||
| Frage | Typ | Antwort A | Antwort B | Antwort C | Antwort D | Richtige Antwort | Kategorie |
|
||||
|-------|-----|-----------|-----------|-----------|-----------|-----------------|-----------|
|
||||
| Welche Farbe hat ein Stoppschild? | MC | Rot | Blau | Grün | *(leer)* | A | Verkehr |
|
||||
|
||||
### Wahr/Falsch
|
||||
|
||||
| Frage | Typ | Antwort A | Antwort B | Antwort C | Antwort D | Richtige Antwort | Kategorie |
|
||||
|-------|-----|-----------|-----------|-----------|-----------|-----------------|-----------|
|
||||
| Die Erde ist eine Scheibe. | Wahr/Falsch | Wahr | Falsch | *(leer)* | *(leer)* | B | Allgemein |
|
||||
|
||||
### Freitext
|
||||
|
||||
| Frage | Typ | Antwort A | Antwort B | Antwort C | Antwort D | Richtige Antwort | Kategorie |
|
||||
|-------|-----|-----------|-----------|-----------|-----------|-----------------|-----------|
|
||||
| Wie heißt die Hauptstadt von Frankreich? | Freitext | *(leer)* | *(leer)* | *(leer)* | *(leer)* | Paris | Geografie |
|
||||
|
||||
### Mit Bild
|
||||
|
||||
| Frage | Typ | Bild | Antwort A | Antwort B | Antwort C | Antwort D | Richtige Antwort | Kategorie |
|
||||
|-------|-----|------|-----------|-----------|-----------|-----------|-----------------|-----------|
|
||||
| Was zeigt dieses Verkehrszeichen? | MC | *(Bild hochladen)* | Vorfahrt | Stopp | Einbahnstraße | Parkverbot | B | Verkehr |
|
||||
|
||||
---
|
||||
|
||||
## 6. Checkliste
|
||||
|
||||
- [ ] Datenbank `Quizalarm` angelegt
|
||||
- [ ] Tabelle `Antworten` mit allen 9 Feldern erstellt
|
||||
- [ ] Mindestens 1 Fragenset-Tabelle mit allen 9 Feldern erstellt
|
||||
- [ ] Testfragen eingetragen
|
||||
- [ ] API-Token erstellt (Settings → API Tokens)
|
||||
- [ ] Alle Table-IDs notiert
|
||||
- [ ] Table-IDs im Admin-Panel unter `https://quizalarm.orfel.de/admin` hinterlegt
|
||||
@@ -0,0 +1,7 @@
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --production
|
||||
COPY . .
|
||||
EXPOSE 7849
|
||||
CMD ["node", "server/index.js"]
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "quizalarm",
|
||||
"version": "1.0.0",
|
||||
"description": "Hybrid-Quiz-Plattform mit Baserow-Backend",
|
||||
"main": "server/index.js",
|
||||
"scripts": {
|
||||
"start": "node server/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.21.0",
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Quizalarm — Admin</title>
|
||||
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
|
||||
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
|
||||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
|
||||
<body class="bg-gray-100 text-gray-900 min-h-screen">
|
||||
<div id="root"></div>
|
||||
<script type="text/babel" src="/js/admin.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,72 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Quizalarm</title>
|
||||
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
|
||||
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
|
||||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
.answer-a {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.answer-a:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.answer-b {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.answer-b:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.answer-c {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.answer-c:hover {
|
||||
background: #d97706;
|
||||
}
|
||||
|
||||
.answer-d {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.answer-d:hover {
|
||||
background: #16a34a;
|
||||
}
|
||||
|
||||
.answer-a.selected,
|
||||
.answer-b.selected,
|
||||
.answer-c.selected,
|
||||
.answer-d.selected {
|
||||
outline: 4px solid white;
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
.answer-a.disabled,
|
||||
.answer-b.disabled,
|
||||
.answer-c.disabled,
|
||||
.answer-d.disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="bg-gray-900 text-white min-h-screen">
|
||||
<div id="root"></div>
|
||||
<script type="text/babel" src="/js/app.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,477 @@
|
||||
const { useState, useEffect, useRef, useCallback } = React;
|
||||
|
||||
const api = {
|
||||
token: null,
|
||||
async get(url) {
|
||||
const r = await fetch(url, { headers: { 'X-Admin-Token': this.token } });
|
||||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || r.statusText);
|
||||
return r.json();
|
||||
},
|
||||
async post(url, data) {
|
||||
const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Admin-Token': this.token }, body: JSON.stringify(data) });
|
||||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || r.statusText);
|
||||
return r.json();
|
||||
},
|
||||
};
|
||||
|
||||
function wsUrl() {
|
||||
return `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}`;
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* Login */
|
||||
/* ================================================================== */
|
||||
|
||||
function LoginPage({ onLogin }) {
|
||||
const [pw, setPw] = useState('');
|
||||
const [err, setErr] = useState('');
|
||||
const submit = async () => {
|
||||
try {
|
||||
const res = await api.post('/api/admin/login', { password: pw });
|
||||
api.token = res.token;
|
||||
onLogin(res.token);
|
||||
} catch (e) { setErr(e.message); }
|
||||
};
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<div className="bg-white rounded-2xl shadow-lg p-8 w-full max-w-sm">
|
||||
<h1 className="text-2xl font-bold mb-6 text-center">🔐 Admin-Login</h1>
|
||||
<input type="password" className="w-full p-3 border rounded-lg mb-3 text-center" placeholder="Passwort" value={pw} onChange={(e) => setPw(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && submit()} autoFocus />
|
||||
{err && <p className="text-red-500 text-sm mb-3 text-center">{err}</p>}
|
||||
<button onClick={submit} className="w-full py-3 bg-indigo-600 text-white rounded-lg font-bold hover:bg-indigo-700">Anmelden</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* Config Page */
|
||||
/* ================================================================== */
|
||||
|
||||
function ConfigPage({ config, setConfig, saveConfig }) {
|
||||
const [newSetName, setNewSetName] = useState('');
|
||||
const [newSetTableId, setNewSetTableId] = useState('');
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
const addSet = () => {
|
||||
if (!newSetName || !newSetTableId) return;
|
||||
const updated = {
|
||||
...config,
|
||||
sets: [...(config.sets || []), { id: Date.now().toString(36), name: newSetName, tableId: parseInt(newSetTableId) }],
|
||||
};
|
||||
setConfig(updated);
|
||||
saveConfig(updated);
|
||||
setNewSetName('');
|
||||
setNewSetTableId('');
|
||||
setMsg('Fragenset hinzugefügt');
|
||||
setTimeout(() => setMsg(''), 2000);
|
||||
};
|
||||
|
||||
const removeSet = (id) => {
|
||||
const updated = { ...config, sets: config.sets.filter((s) => s.id !== id) };
|
||||
setConfig(updated);
|
||||
saveConfig(updated);
|
||||
};
|
||||
|
||||
const updateAnswersTable = (val) => {
|
||||
const updated = { ...config, answersTableId: val ? parseInt(val) : null };
|
||||
setConfig(updated);
|
||||
saveConfig(updated);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl shadow p-6">
|
||||
<h2 className="text-xl font-bold mb-4">📋 Fragensets</h2>
|
||||
{(config.sets || []).map((s) => (
|
||||
<div key={s.id} className="flex items-center justify-between py-2 border-b last:border-0">
|
||||
<div><span className="font-semibold">{s.name}</span> <span className="text-gray-400 text-sm">(Table ID: {s.tableId})</span></div>
|
||||
<button onClick={() => removeSet(s.id)} className="text-red-500 hover:text-red-700 text-sm">Entfernen</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-4 flex gap-2 flex-wrap">
|
||||
<input className="flex-1 min-w-0 p-2 border rounded-lg" placeholder="Name" value={newSetName} onChange={(e) => setNewSetName(e.target.value)} />
|
||||
<input className="w-32 p-2 border rounded-lg" placeholder="Table ID" type="number" value={newSetTableId} onChange={(e) => setNewSetTableId(e.target.value)} />
|
||||
<button onClick={addSet} className="px-4 py-2 bg-indigo-600 text-white rounded-lg font-bold hover:bg-indigo-700">+</button>
|
||||
</div>
|
||||
{msg && <p className="text-green-600 text-sm mt-2">{msg}</p>}
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow p-6">
|
||||
<h2 className="text-xl font-bold mb-4">💾 Antworten-Tabelle</h2>
|
||||
<div className="flex gap-2 items-center">
|
||||
<span className="text-gray-600">Table ID:</span>
|
||||
<input className="w-32 p-2 border rounded-lg" type="number" value={config.answersTableId || ''} onChange={(e) => updateAnswersTable(e.target.value)} placeholder="Table ID" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* Stats Page */
|
||||
/* ================================================================== */
|
||||
|
||||
function StatsPage() {
|
||||
const [data, setData] = useState(null);
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/api/admin/stats').then(setData).catch(console.error);
|
||||
}, []);
|
||||
|
||||
if (!data) return <p className="text-center py-8">Lade Statistiken...</p>;
|
||||
if (!data.answers.length) return <p className="text-center py-8 text-gray-500">Noch keine Ergebnisse vorhanden.</p>;
|
||||
|
||||
// Group by session
|
||||
const sessions = {};
|
||||
data.answers.forEach((a) => {
|
||||
const sid = a['Session_ID'] || 'Unbekannt';
|
||||
if (!sessions[sid]) sessions[sid] = { modus: a['Modus'], fragenset: a['Fragenset'], date: a['Zeitstempel'], answers: [] };
|
||||
sessions[sid].answers.push(a);
|
||||
});
|
||||
|
||||
// Group by user
|
||||
const users = {};
|
||||
data.answers.forEach((a) => {
|
||||
const u = a['Nutzername'] || 'Unbekannt';
|
||||
if (!users[u]) users[u] = { total: 0, correct: 0, points: 0 };
|
||||
users[u].total++;
|
||||
if (a['Richtig']) users[u].correct++;
|
||||
users[u].points += a['Punkte'] || 0;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl shadow p-6">
|
||||
<h2 className="text-xl font-bold mb-4">📊 Übersicht</h2>
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div className="bg-indigo-50 rounded-lg p-4">
|
||||
<div className="text-2xl font-bold text-indigo-600">{Object.keys(sessions).length}</div>
|
||||
<div className="text-sm text-gray-500">Sessions</div>
|
||||
</div>
|
||||
<div className="bg-emerald-50 rounded-lg p-4">
|
||||
<div className="text-2xl font-bold text-emerald-600">{Object.keys(users).length}</div>
|
||||
<div className="text-sm text-gray-500">Teilnehmer</div>
|
||||
</div>
|
||||
<div className="bg-amber-50 rounded-lg p-4">
|
||||
<div className="text-2xl font-bold text-amber-600">{data.answers.length}</div>
|
||||
<div className="text-sm text-gray-500">Antworten</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow p-6">
|
||||
<h3 className="text-lg font-bold mb-3">👤 Pro Teilnehmer</h3>
|
||||
<input className="w-full p-2 border rounded-lg mb-3" placeholder="Filtern..." value={filter} onChange={(e) => setFilter(e.target.value)} />
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b"><th className="text-left py-2">Name</th><th>Richtig</th><th>Gesamt</th><th>Quote</th><th>Punkte</th></tr></thead>
|
||||
<tbody>
|
||||
{Object.entries(users)
|
||||
.filter(([u]) => !filter || u.toLowerCase().includes(filter.toLowerCase()))
|
||||
.sort(([, a], [, b]) => b.points - a.points)
|
||||
.map(([u, v]) => (
|
||||
<tr key={u} className="border-b last:border-0">
|
||||
<td className="py-2 font-medium">{u}</td>
|
||||
<td className="text-center text-green-600">{v.correct}</td>
|
||||
<td className="text-center">{v.total}</td>
|
||||
<td className="text-center">{Math.round((v.correct / v.total) * 100)}%</td>
|
||||
<td className="text-center font-bold">{v.points}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow p-6">
|
||||
<h3 className="text-lg font-bold mb-3">📁 Sessions</h3>
|
||||
{Object.entries(sessions).reverse().map(([sid, s]) => {
|
||||
const total = s.answers.length;
|
||||
const correct = s.answers.filter((a) => a['Richtig']).length;
|
||||
const uniqueUsers = new Set(s.answers.map((a) => a['Nutzername'])).size;
|
||||
return (
|
||||
<div key={sid} className="border-b last:border-0 py-3">
|
||||
<div className="flex justify-between">
|
||||
<span><span className={`text-xs px-2 py-0.5 rounded-full text-white ${s.modus === 'Live' ? 'bg-indigo-600' : 'bg-emerald-600'}`}>{s.modus}</span> <strong>{s.fragenset}</strong></span>
|
||||
<span className="text-sm text-gray-400">{s.date ? new Date(s.date).toLocaleString('de') : ''}</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-1">{uniqueUsers} Teilnehmer — {correct}/{total} richtig gesamt</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* Live Setup Page */
|
||||
/* ================================================================== */
|
||||
|
||||
function LiveSetupPage({ config, token, onSessionCreated }) {
|
||||
const [setId, setSetId] = useState('');
|
||||
const [shuffle, setShuffle] = useState(true);
|
||||
const [scoring, setScoring] = useState('binary');
|
||||
const [timeLimit, setTimeLimit] = useState(30);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const create = () => {
|
||||
if (!setId) return setError('Bitte ein Fragenset waehlen');
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const ws = new WebSocket(wsUrl());
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({ type: 'admin-create', token, setId, options: { shuffle, scoring, timeLimit } }));
|
||||
};
|
||||
ws.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'session-created') {
|
||||
onSessionCreated(msg.code, ws);
|
||||
} else if (msg.type === 'error') {
|
||||
setError(msg.message);
|
||||
setLoading(false);
|
||||
ws.close();
|
||||
}
|
||||
};
|
||||
ws.onerror = () => { setError('WebSocket Fehler'); setLoading(false); };
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow p-6 max-w-md mx-auto">
|
||||
<h2 className="text-xl font-bold mb-4">🎮 Live-Session erstellen</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Fragenset</label>
|
||||
<select className="w-full p-3 border rounded-lg" value={setId} onChange={(e) => setSetId(e.target.value)}>
|
||||
<option value="">— Bitte wählen —</option>
|
||||
{(config.sets || []).map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={shuffle} onChange={() => setShuffle(!shuffle)} className="w-5 h-5 accent-indigo-600" />
|
||||
Fragen mischen
|
||||
</label>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Punktevergabe</label>
|
||||
<select className="w-full p-3 border rounded-lg" value={scoring} onChange={(e) => setScoring(e.target.value)}>
|
||||
<option value="binary">Binär (1000 / 0)</option>
|
||||
<option value="time">Zeitbasiert (schneller = mehr Punkte)</option>
|
||||
</select>
|
||||
</div>
|
||||
{scoring === 'time' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Sekunden pro Frage</label>
|
||||
<input type="number" className="w-full p-3 border rounded-lg" value={timeLimit} onChange={(e) => setTimeLimit(parseInt(e.target.value) || 30)} min={5} max={120} />
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="text-red-500 text-sm">{error}</p>}
|
||||
<button onClick={create} disabled={loading} className="w-full py-3 bg-indigo-600 text-white rounded-lg font-bold hover:bg-indigo-700 disabled:opacity-50">
|
||||
{loading ? 'Erstelle...' : 'Session erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* Live Control Page */
|
||||
/* ================================================================== */
|
||||
|
||||
function LiveControlPage({ token, code, ws }) {
|
||||
const [session, setSession] = useState(null);
|
||||
const [phase, setPhase] = useState('waiting');
|
||||
const [answerCount, setAnswerCount] = useState(0);
|
||||
const [result, setResult] = useState(null);
|
||||
const [leaderboardData, setLeaderboardData] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ws) return;
|
||||
|
||||
const handler = (e) => {
|
||||
const msg = JSON.parse(e.data);
|
||||
switch (msg.type) {
|
||||
case 'session-created':
|
||||
setSession({ code: msg.code, setName: msg.setName, questionCount: msg.questionCount, options: msg.options });
|
||||
break;
|
||||
case 'admin-joined':
|
||||
setSession(msg.session);
|
||||
if (msg.session.status === 'waiting') setPhase('waiting');
|
||||
else if (msg.session.status === 'ended') setPhase('ended');
|
||||
else setPhase('active');
|
||||
setLeaderboardData(msg.session.leaderboard);
|
||||
break;
|
||||
case 'player-joined':
|
||||
case 'player-left':
|
||||
setSession((s) => s ? { ...s, players: msg.players, playerCount: msg.playerCount } : s);
|
||||
break;
|
||||
case 'game-start':
|
||||
setPhase('active');
|
||||
break;
|
||||
case 'question':
|
||||
setPhase('question');
|
||||
setAnswerCount(0);
|
||||
setResult(null);
|
||||
break;
|
||||
case 'answer-count':
|
||||
setAnswerCount(msg.count);
|
||||
break;
|
||||
case 'question-result':
|
||||
setResult(msg);
|
||||
setLeaderboardData(msg.leaderboard);
|
||||
setPhase('result');
|
||||
break;
|
||||
case 'game-end':
|
||||
setLeaderboardData(msg.leaderboard);
|
||||
setPhase('ended');
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
ws.addEventListener('message', handler);
|
||||
return () => ws.removeEventListener('message', handler);
|
||||
}, [ws]);
|
||||
|
||||
const start = () => ws.send(JSON.stringify({ type: 'admin-start', token, code }));
|
||||
const next = () => ws.send(JSON.stringify({ type: 'admin-next', token, code }));
|
||||
const end = () => ws.send(JSON.stringify({ type: 'admin-end', token, code }));
|
||||
const showResult = () => {
|
||||
// Force show result by telling server (same as next but for current question)
|
||||
// We can reuse admin-next if status is 'active' → actually we need showResult
|
||||
// For now, the master clicks "weiter" after everyone answered or time is up
|
||||
};
|
||||
|
||||
const presentUrl = `${location.origin}/present#${code}`;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-lg mx-auto">
|
||||
<div className="bg-white rounded-xl shadow p-6 text-center">
|
||||
<h2 className="text-xl font-bold mb-2">Session-Code</h2>
|
||||
<div className="text-5xl font-mono font-extrabold tracking-widest text-indigo-600 mb-2">{code}</div>
|
||||
<p className="text-gray-400 text-sm">{session?.setName} — {session?.questionCount || '?'} Fragen</p>
|
||||
<a href={presentUrl} target="_blank" rel="noopener" className="inline-block mt-2 text-sm text-indigo-500 hover:underline">📺 Präsentations-Fenster öffnen</a>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow p-6">
|
||||
<h3 className="font-bold mb-2">Teilnehmer ({session?.players?.length || 0})</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(session?.players || []).map((n) => <span key={n} className="px-3 py-1 bg-gray-100 rounded-full text-sm">{n}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{phase === 'waiting' && (
|
||||
<button onClick={start} disabled={!session?.players?.length} className="w-full py-4 bg-green-600 text-white rounded-xl text-xl font-bold hover:bg-green-700 disabled:opacity-50">
|
||||
▶ Quiz starten
|
||||
</button>
|
||||
)}
|
||||
|
||||
{phase === 'question' && (
|
||||
<div className="bg-white rounded-xl shadow p-6 text-center">
|
||||
<p className="text-lg mb-2">Antworten: <strong>{answerCount}</strong> / {session?.players?.length || 0}</p>
|
||||
<button onClick={next} className="w-full py-3 bg-indigo-600 text-white rounded-xl font-bold hover:bg-indigo-700 mt-2">Ergebnis zeigen ➜</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'result' && (
|
||||
<div className="bg-white rounded-xl shadow p-6">
|
||||
<h3 className="font-bold text-center mb-3">Ergebnis</h3>
|
||||
{result?.stats && (
|
||||
<div className="grid grid-cols-2 gap-2 mb-4">
|
||||
{Object.entries(result.stats).map(([k, v]) => (
|
||||
<div key={k} className={`p-2 rounded-lg text-white text-center ${k === 'A' ? 'bg-red-500' : k === 'B' ? 'bg-blue-500' : k === 'C' ? 'bg-amber-500' : 'bg-green-500'}`}>
|
||||
{k}: <strong>{v}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-center text-sm text-gray-400 mb-2">Richtig: {result?.correctAnswer?.join(', ')}</p>
|
||||
<button onClick={next} className="w-full py-3 bg-indigo-600 text-white rounded-xl font-bold hover:bg-indigo-700">Nächste Frage ➜</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'ended' && (
|
||||
<div className="bg-white rounded-xl shadow p-6 text-center">
|
||||
<h3 className="text-xl font-bold mb-4">🏁 Quiz beendet</h3>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{leaderboardData.length > 0 && (
|
||||
<div className="bg-white rounded-xl shadow p-6">
|
||||
<h3 className="font-bold mb-2 text-center">🏆 Leaderboard</h3>
|
||||
{leaderboardData.map((e) => (
|
||||
<div key={e.name} className="flex justify-between py-2 px-3 bg-gray-50 rounded-lg mb-1">
|
||||
<span>{e.rank}. {e.name}</span><span className="font-bold">{e.score}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase !== 'ended' && phase !== 'waiting' && (
|
||||
<button onClick={end} className="w-full py-3 bg-red-500 text-white rounded-xl font-bold hover:bg-red-600">⏹ Session beenden</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* Admin App */
|
||||
/* ================================================================== */
|
||||
|
||||
function AdminApp() {
|
||||
const [token, setToken] = useState(null);
|
||||
const [tab, setTab] = useState('config');
|
||||
const [config, setConfig] = useState({ sets: [], answersTableId: null });
|
||||
const [liveCode, setLiveCode] = useState(null);
|
||||
const [liveWs, setLiveWs] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
api.token = token;
|
||||
api.get('/api/admin/config').then(setConfig).catch(console.error);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const saveConfig = async (c) => {
|
||||
try { await api.post('/api/admin/config', c); } catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
const handleSessionCreated = (code, ws) => {
|
||||
setLiveCode(code);
|
||||
setLiveWs(ws);
|
||||
setTab('live-control');
|
||||
};
|
||||
|
||||
if (!token) return <LoginPage onLogin={setToken} />;
|
||||
|
||||
const tabs = [
|
||||
{ id: 'config', label: '⚙️ Konfiguration' },
|
||||
{ id: 'live', label: '🎮 Live-Session' },
|
||||
{ id: 'stats', label: '📊 Statistiken' },
|
||||
];
|
||||
if (liveCode) tabs.splice(2, 0, { id: 'live-control', label: '🎛️ Steuerung' });
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<div className="bg-white shadow">
|
||||
<div className="max-w-3xl mx-auto px-4 py-3 flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold">⚡ Quizalarm Admin</h1>
|
||||
<a href="/" className="text-gray-400 hover:text-gray-600 text-sm">← Zur App</a>
|
||||
</div>
|
||||
<div className="max-w-3xl mx-auto px-4 flex gap-1 overflow-x-auto">
|
||||
{tabs.map((t) => (
|
||||
<button key={t.id} onClick={() => setTab(t.id)} className={`px-4 py-2 text-sm font-medium rounded-t-lg whitespace-nowrap ${tab === t.id ? 'bg-gray-100 text-indigo-600 border-b-2 border-indigo-600' : 'text-gray-500 hover:text-gray-700'}`}>{t.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-w-3xl mx-auto p-4">
|
||||
{tab === 'config' && <ConfigPage config={config} setConfig={setConfig} saveConfig={saveConfig} />}
|
||||
{tab === 'live' && <LiveSetupPage config={config} token={token} onSessionCreated={handleSessionCreated} />}
|
||||
{tab === 'live-control' && <LiveControlPage token={token} code={liveCode} ws={liveWs} />}
|
||||
{tab === 'stats' && <StatsPage />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<AdminApp />);
|
||||
@@ -0,0 +1,622 @@
|
||||
const { useState, useEffect, useRef, useCallback } = React;
|
||||
|
||||
/* ================================================================== */
|
||||
/* API Helper */
|
||||
/* ================================================================== */
|
||||
const api = {
|
||||
async get(url) {
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || r.statusText);
|
||||
return r.json();
|
||||
},
|
||||
async post(url, data) {
|
||||
const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) });
|
||||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || r.statusText);
|
||||
return r.json();
|
||||
},
|
||||
};
|
||||
|
||||
function wsUrl() {
|
||||
return `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}`;
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* Shared Components */
|
||||
/* ================================================================== */
|
||||
|
||||
function ImageModal({ src, onClose }) {
|
||||
if (!src) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-80 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="Vergrößerung" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Timer({ timeLeft, total }) {
|
||||
if (!total) return null;
|
||||
const pct = Math.max(0, (timeLeft / total) * 100);
|
||||
const color = pct > 50 ? 'bg-green-500' : pct > 25 ? 'bg-yellow-500' : 'bg-red-500';
|
||||
return (
|
||||
<div className="w-full bg-gray-700 rounded-full h-4 mb-4 overflow-hidden">
|
||||
<div className={`${color} h-full rounded-full transition-all duration-200`} style={{ width: `${pct}%` }} />
|
||||
<div className="text-center text-sm mt-1">{Math.ceil(timeLeft)}s</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Leaderboard({ entries, highlight, compact }) {
|
||||
if (!entries || !entries.length) return null;
|
||||
const show = compact ? entries.slice(0, 5) : entries;
|
||||
return (
|
||||
<div className="bg-gray-800 rounded-xl p-4 mt-4">
|
||||
<h3 className="text-lg font-bold mb-2 text-center">🏆 Leaderboard</h3>
|
||||
{show.map((e) => (
|
||||
<div key={e.name} className={`flex justify-between py-2 px-3 rounded-lg mb-1 ${e.name === highlight ? 'bg-indigo-600' : 'bg-gray-700'}`}>
|
||||
<span className="font-medium">{e.rank}. {e.name}</span>
|
||||
<span className="font-bold">{e.score}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ANSWER_COLORS = { A: 'answer-a', B: 'answer-b', C: 'answer-c', D: 'answer-d' };
|
||||
|
||||
function AnswerGrid({ question, selected, onToggle, disabled }) {
|
||||
const typ = (question.typ || 'MC').toLowerCase();
|
||||
const isWF = typ === 'wahr/falsch';
|
||||
|
||||
if (typ === 'freitext') {
|
||||
return (
|
||||
<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-indigo-500 outline-none"
|
||||
placeholder="Deine Antwort..."
|
||||
value={selected[0] || ''}
|
||||
onChange={(e) => onToggle([e.target.value])}
|
||||
disabled={disabled}
|
||||
autoFocus
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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) => {
|
||||
const active = selected.includes(a.key);
|
||||
const cls = `${ANSWER_COLORS[a.key]} ${active ? 'selected' : ''} ${disabled ? 'disabled' : ''} text-white font-bold py-4 px-6 rounded-xl text-lg cursor-pointer transition-all text-center`;
|
||||
return (
|
||||
<button
|
||||
key={a.key}
|
||||
className={cls}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
if (isWF) {
|
||||
onToggle([a.key]);
|
||||
} else {
|
||||
onToggle(active ? selected.filter((s) => s !== a.key) : [...selected, a.key]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="opacity-70 mr-2">{a.key}</span> {a.text}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* Home Page */
|
||||
/* ================================================================== */
|
||||
|
||||
function HomePage({ navigate }) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center p-6">
|
||||
<h1 className="text-5xl font-extrabold mb-2 text-transparent bg-clip-text bg-gradient-to-r from-indigo-400 to-pink-500">⚡ Quizalarm</h1>
|
||||
<p className="text-gray-400 mb-10 text-lg">Lernen. Quizzen. Wissen.</p>
|
||||
<div className="w-full max-w-sm space-y-4">
|
||||
<button onClick={() => navigate('join')} className="w-full py-4 bg-indigo-600 hover:bg-indigo-700 rounded-xl text-xl font-bold transition-all">🎮 Live-Quiz beitreten</button>
|
||||
<button onClick={() => navigate('solo-select')} className="w-full py-4 bg-emerald-600 hover:bg-emerald-700 rounded-xl text-xl font-bold transition-all">📚 Solo-Lernen</button>
|
||||
<button onClick={() => navigate('my-results')} className="w-full py-4 bg-gray-700 hover:bg-gray-600 rounded-xl text-xl font-bold transition-all">📊 Meine Ergebnisse</button>
|
||||
</div>
|
||||
<a href="/admin" className="mt-8 text-gray-500 hover:text-gray-300 text-sm">Admin-Bereich →</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* Join Page */
|
||||
/* ================================================================== */
|
||||
|
||||
function JoinPage({ navigate }) {
|
||||
const [code, setCode] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleJoin = () => {
|
||||
if (!code.trim() || !name.trim()) return setError('Code und Name erforderlich');
|
||||
navigate('live-play', { code: code.trim().toUpperCase(), name: name.trim() });
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
<div className="w-full max-w-sm space-y-4">
|
||||
<input className="w-full p-4 rounded-xl bg-gray-800 text-white text-center text-2xl tracking-widest border-2 border-gray-700 focus:border-indigo-500 outline-none uppercase" placeholder="CODE" value={code} onChange={(e) => setCode(e.target.value)} maxLength={6} />
|
||||
<input className="w-full p-4 rounded-xl bg-gray-800 text-white text-center text-xl border-2 border-gray-700 focus:border-indigo-500 outline-none" placeholder="Dein Name" value={name} onChange={(e) => setName(e.target.value)} maxLength={20} onKeyDown={(e) => e.key === 'Enter' && handleJoin()} />
|
||||
{error && <p className="text-red-400 text-center">{error}</p>}
|
||||
<button onClick={handleJoin} className="w-full py-4 bg-indigo-600 hover:bg-indigo-700 rounded-xl text-xl font-bold">Beitreten</button>
|
||||
<button onClick={() => navigate('home')} className="w-full py-3 text-gray-400 hover:text-white">← Zurück</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* Live Play Page */
|
||||
/* ================================================================== */
|
||||
|
||||
function LivePlayPage({ params, navigate }) {
|
||||
const [phase, setPhase] = useState('connecting');
|
||||
const [session, setSession] = useState(null);
|
||||
const [question, setQuestion] = useState(null);
|
||||
const [qIndex, setQIndex] = useState(0);
|
||||
const [qTotal, setQTotal] = useState(0);
|
||||
const [timeLimit, setTimeLimit] = useState(0);
|
||||
const [timeLeft, setTimeLeft] = useState(0);
|
||||
const [selected, setSelected] = useState([]);
|
||||
const [answered, setAnswered] = useState(false);
|
||||
const [result, setResult] = useState(null);
|
||||
const [leaderboardData, setLeaderboardData] = useState([]);
|
||||
const [error, setError] = useState('');
|
||||
const [enlargedImg, setEnlargedImg] = useState(null);
|
||||
const wsRef = useRef(null);
|
||||
const timerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const ws = new WebSocket(wsUrl());
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({ type: 'join', code: params.code, name: params.name }));
|
||||
};
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data);
|
||||
switch (msg.type) {
|
||||
case 'joined':
|
||||
setSession(msg.session);
|
||||
setPhase('waiting');
|
||||
break;
|
||||
case 'player-joined':
|
||||
case 'player-left':
|
||||
setSession((s) => s ? { ...s, players: msg.players, playerCount: msg.playerCount } : s);
|
||||
break;
|
||||
case 'game-start':
|
||||
setQTotal(msg.totalQuestions);
|
||||
setPhase('playing');
|
||||
break;
|
||||
case 'question':
|
||||
setQuestion(msg.question);
|
||||
setQIndex(msg.index);
|
||||
setQTotal(msg.total);
|
||||
setTimeLimit(msg.timeLimit);
|
||||
setTimeLeft(msg.timeLimit);
|
||||
setSelected([]);
|
||||
setAnswered(false);
|
||||
setResult(null);
|
||||
setPhase('question');
|
||||
break;
|
||||
case 'answer-received':
|
||||
setAnswered(true);
|
||||
break;
|
||||
case 'time-up':
|
||||
setAnswered(true);
|
||||
clearInterval(timerRef.current);
|
||||
break;
|
||||
case 'question-result':
|
||||
setResult(msg);
|
||||
setLeaderboardData(msg.leaderboard);
|
||||
setPhase('result');
|
||||
clearInterval(timerRef.current);
|
||||
break;
|
||||
case 'game-end':
|
||||
setLeaderboardData(msg.leaderboard);
|
||||
setPhase('final');
|
||||
clearInterval(timerRef.current);
|
||||
break;
|
||||
case 'error':
|
||||
setError(msg.message);
|
||||
setPhase('error');
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (phase !== 'final' && phase !== 'error') setPhase('disconnected');
|
||||
};
|
||||
|
||||
return () => { clearInterval(timerRef.current); ws.close(); };
|
||||
}, []);
|
||||
|
||||
// Timer countdown
|
||||
useEffect(() => {
|
||||
clearInterval(timerRef.current);
|
||||
if (phase === 'question' && timeLimit > 0 && !answered) {
|
||||
const start = Date.now();
|
||||
timerRef.current = setInterval(() => {
|
||||
const elapsed = (Date.now() - start) / 1000;
|
||||
const left = Math.max(0, timeLimit - elapsed);
|
||||
setTimeLeft(left);
|
||||
if (left <= 0) clearInterval(timerRef.current);
|
||||
}, 100);
|
||||
}
|
||||
}, [phase, timeLimit, answered]);
|
||||
|
||||
const submitAnswer = () => {
|
||||
if (answered || !wsRef.current) return;
|
||||
wsRef.current.send(JSON.stringify({ type: 'answer', answers: selected }));
|
||||
setAnswered(true);
|
||||
};
|
||||
|
||||
if (phase === 'error') return (
|
||||
<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>
|
||||
<button onClick={() => navigate('join')} className="py-3 px-6 bg-gray-700 rounded-xl">Zurück</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (phase === 'disconnected') return (
|
||||
<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>
|
||||
<button onClick={() => navigate('join')} className="py-3 px-6 bg-gray-700 rounded-xl">Erneut beitreten</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (phase === 'connecting') return <div className="min-h-screen flex items-center justify-center"><p className="text-xl animate-pulse">Verbinde...</p></div>;
|
||||
|
||||
if (phase === 'waiting') return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center p-6">
|
||||
<h2 className="text-3xl font-bold mb-2">⏳ Warteraum</h2>
|
||||
<p className="text-gray-400 mb-4">{session?.setName}</p>
|
||||
<p className="text-lg mb-4">{session?.playerCount || 0} Teilnehmer</p>
|
||||
<div className="flex flex-wrap gap-2 justify-center max-w-md">
|
||||
{(session?.players || []).map((n) => (
|
||||
<span key={n} className={`px-3 py-1 rounded-full text-sm ${n === params.name ? 'bg-indigo-600' : 'bg-gray-700'}`}>{n}</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-6 text-gray-500 animate-pulse">Warte auf den Start...</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (phase === 'question') 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>
|
||||
{timeLimit > 0 && <Timer timeLeft={timeLeft} total={timeLimit} />}
|
||||
<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)} />}
|
||||
<AnswerGrid question={question} selected={selected} onToggle={setSelected} disabled={answered} />
|
||||
{!answered && (
|
||||
<button onClick={submitAnswer} disabled={selected.length === 0} className="mt-4 py-3 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-40 rounded-xl text-lg font-bold w-full">Antwort senden</button>
|
||||
)}
|
||||
{answered && <p className="mt-4 text-center text-green-400 animate-pulse">✓ Antwort gesendet — warte auf Ergebnis...</p>}
|
||||
<ImageModal src={enlargedImg} onClose={() => setEnlargedImg(null)} />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (phase === 'result') return (
|
||||
<div className="min-h-screen flex flex-col items-center p-4 max-w-md mx-auto">
|
||||
<div className={`text-4xl mb-4 ${result?.yourResult?.correct ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{result?.yourResult?.correct ? '✅ Richtig!' : '❌ Falsch'}
|
||||
</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>{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 (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center p-4">
|
||||
<h2 className="text-3xl font-bold mb-6">🏁 Quiz beendet!</h2>
|
||||
<Leaderboard entries={leaderboardData} highlight={params.name} />
|
||||
<button onClick={() => navigate('home')} className="mt-6 py-3 px-6 bg-gray-700 hover:bg-gray-600 rounded-xl">Zur Startseite</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* Solo Select Page */
|
||||
/* ================================================================== */
|
||||
|
||||
function SoloSelectPage({ navigate }) {
|
||||
const [sets, setSets] = useState([]);
|
||||
const [name, setName] = useState('');
|
||||
const [shuffle, setShuffle] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/api/sets').then(setSets).catch((e) => setError(e.message));
|
||||
}, []);
|
||||
|
||||
const start = (setId, setName2) => {
|
||||
if (!name.trim()) return setError('Bitte Name eingeben');
|
||||
navigate('solo-quiz', { setId, setName: setName2, name: name.trim(), shuffle });
|
||||
};
|
||||
|
||||
return (
|
||||
<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 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-emerald-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">
|
||||
<input type="checkbox" checked={shuffle} onChange={() => setShuffle(!shuffle)} className="w-5 h-5 accent-emerald-500" />
|
||||
Fragen mischen
|
||||
</label>
|
||||
{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.</p>}
|
||||
{sets.map((s) => (
|
||||
<button key={s.id} onClick={() => start(s.id, s.name)} className="w-full py-4 bg-emerald-700 hover:bg-emerald-600 rounded-xl text-lg font-bold transition-all">{s.name}</button>
|
||||
))}
|
||||
<button onClick={() => navigate('home')} className="w-full py-3 text-gray-400 hover:text-white">← Zurück</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* Solo Quiz Page */
|
||||
/* ================================================================== */
|
||||
|
||||
function SoloQuizPage({ params, navigate }) {
|
||||
const [questions, setQuestions] = useState([]);
|
||||
const [index, setIndex] = useState(0);
|
||||
const [selected, setSelected] = useState([]);
|
||||
const [showFeedback, setShowFeedback] = useState(false);
|
||||
const [isCorrect, setIsCorrect] = useState(false);
|
||||
const [correctAnswer, setCorrectAnswer] = useState([]);
|
||||
const [results, setResults] = useState([]);
|
||||
const [error, setError] = useState('');
|
||||
const [enlargedImg, setEnlargedImg] = useState(null);
|
||||
const sessionId = useRef('SOLO-' + Date.now().toString(36).toUpperCase());
|
||||
|
||||
useEffect(() => {
|
||||
api.get(`/api/sets/${params.setId}/questions`).then((qs) => {
|
||||
if (params.shuffle) {
|
||||
for (let i = qs.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[qs[i], qs[j]] = [qs[j], qs[i]];
|
||||
}
|
||||
}
|
||||
setQuestions(qs);
|
||||
}).catch((e) => setError(e.message));
|
||||
}, []);
|
||||
|
||||
if (error) return <div className="min-h-screen flex items-center justify-center text-red-400 text-xl">{error}</div>;
|
||||
if (!questions.length) return <div className="min-h-screen flex items-center justify-center animate-pulse text-xl">Lade Fragen...</div>;
|
||||
|
||||
const q = questions[index];
|
||||
const typ = (q['Typ'] || 'MC').toLowerCase();
|
||||
const sanitized = {
|
||||
frage: q['Frage'],
|
||||
typ: q['Typ'] || 'MC',
|
||||
bild: q['Bild'] && q['Bild'].length ? q['Bild'][0].url : null,
|
||||
antworten: [],
|
||||
};
|
||||
['A', 'B', 'C', 'D'].forEach((k) => { if (q[`Antwort ${k}`]) sanitized.antworten.push({ key: k, text: q[`Antwort ${k}`] }); });
|
||||
|
||||
const checkAnswer = () => {
|
||||
const rawCorrect = (q['Richtige Antwort'] || '').trim();
|
||||
let correct, ok;
|
||||
if (typ === 'freitext') {
|
||||
correct = [rawCorrect];
|
||||
// Normalize comparison
|
||||
const normalize = (t) => (t || '').toLowerCase().replace(/[^a-zäöüß0-9]/g, '').replace(/[ä]/g, 'ae').replace(/[ö]/g, 'oe').replace(/[ü]/g, 'ue').replace(/ß/g, 'ss');
|
||||
ok = normalize(selected[0]) === normalize(rawCorrect);
|
||||
} else {
|
||||
correct = rawCorrect.split(',').map((s) => s.trim().toUpperCase()).filter(Boolean);
|
||||
const correctSet = new Set(correct);
|
||||
const selectedSet = new Set(selected.map((s) => s.toUpperCase()));
|
||||
ok = correct.length === selectedSet.size && correct.every((c) => selectedSet.has(c));
|
||||
}
|
||||
|
||||
setCorrectAnswer(correct);
|
||||
setIsCorrect(ok);
|
||||
setShowFeedback(true);
|
||||
|
||||
results.push({
|
||||
Nutzername: params.name,
|
||||
Frage_ID: q.id,
|
||||
Fragenset: params.setName,
|
||||
Antwort: selected.join(','),
|
||||
Richtig: ok,
|
||||
Punkte: ok ? 1000 : 0,
|
||||
Session_ID: sessionId.current,
|
||||
Modus: 'Solo',
|
||||
Zeitstempel: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
const nextQuestion = () => {
|
||||
if (index + 1 >= questions.length) {
|
||||
// Save all results, then navigate
|
||||
api.post('/api/results', { answers: results }).catch(console.error);
|
||||
navigate('solo-result', { results, setName: params.setName, name: params.name, questions });
|
||||
return;
|
||||
}
|
||||
setIndex(index + 1);
|
||||
setSelected([]);
|
||||
setShowFeedback(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<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="w-full bg-gray-700 rounded-full h-2 mb-4">
|
||||
<div className="bg-emerald-500 h-full rounded-full transition-all" style={{ width: `${((index + 1) / questions.length) * 100}%` }} />
|
||||
</div>
|
||||
<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)} />}
|
||||
<AnswerGrid question={sanitized} selected={selected} onToggle={setSelected} disabled={showFeedback} />
|
||||
{!showFeedback && (
|
||||
<button onClick={checkAnswer} disabled={selected.length === 0} className="mt-4 py-3 bg-emerald-600 hover:bg-emerald-700 disabled:opacity-40 rounded-xl text-lg font-bold w-full">Prüfen</button>
|
||||
)}
|
||||
{showFeedback && (
|
||||
<div className="mt-4 text-center space-y-3">
|
||||
<div className={`text-2xl font-bold ${isCorrect ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{isCorrect ? '✅ Richtig!' : '❌ Falsch'}
|
||||
</div>
|
||||
{!isCorrect && <p className="bg-gray-800 py-2 px-4 rounded-lg inline-block">Richtig: <strong>{correctAnswer.join(', ')}</strong></p>}
|
||||
<button onClick={nextQuestion} className="w-full py-3 bg-indigo-600 hover:bg-indigo-700 rounded-xl text-lg font-bold">
|
||||
{index + 1 >= questions.length ? 'Ergebnis anzeigen' : 'Nächste Frage →'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<ImageModal src={enlargedImg} onClose={() => setEnlargedImg(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* Solo Result Page */
|
||||
/* ================================================================== */
|
||||
|
||||
function SoloResultPage({ params, navigate }) {
|
||||
const { results, setName, name, questions } = params;
|
||||
const total = results.length;
|
||||
const correct = results.filter((r) => r.Richtig).length;
|
||||
const pct = total > 0 ? Math.round((correct / total) * 100) : 0;
|
||||
|
||||
// Group by category
|
||||
const byCategory = {};
|
||||
results.forEach((r, i) => {
|
||||
const q = questions[i];
|
||||
const cat = q?.['Kategorie'] || 'Ohne Kategorie';
|
||||
if (!byCategory[cat]) byCategory[cat] = { total: 0, correct: 0 };
|
||||
byCategory[cat].total++;
|
||||
if (r.Richtig) byCategory[cat].correct++;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center p-6 pt-12">
|
||||
<h2 className="text-3xl font-bold mb-2">📊 Ergebnis</h2>
|
||||
<p className="text-gray-400 mb-4">{setName} — {name}</p>
|
||||
<div className={`text-6xl font-extrabold mb-2 ${pct >= 70 ? 'text-green-400' : pct >= 40 ? 'text-yellow-400' : 'text-red-400'}`}>{pct}%</div>
|
||||
<p className="text-xl mb-6">{correct} / {total} richtig</p>
|
||||
{Object.keys(byCategory).length > 1 && (
|
||||
<div className="w-full max-w-md bg-gray-800 rounded-xl p-4 mb-4">
|
||||
<h3 className="font-bold mb-2">Nach Kategorie:</h3>
|
||||
{Object.entries(byCategory).map(([cat, v]) => (
|
||||
<div key={cat} className="flex justify-between py-1 border-b border-gray-700 last:border-0">
|
||||
<span>{cat}</span>
|
||||
<span className={v.correct / v.total >= 0.7 ? 'text-green-400' : 'text-red-400'}>{v.correct}/{v.total}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full max-w-md space-y-3">
|
||||
<button onClick={() => navigate('solo-select')} className="w-full py-3 bg-emerald-600 hover:bg-emerald-700 rounded-xl font-bold">Nochmal spielen</button>
|
||||
<button onClick={() => navigate('home')} className="w-full py-3 bg-gray-700 hover:bg-gray-600 rounded-xl font-bold">Zur Startseite</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* My Results Page */
|
||||
/* ================================================================== */
|
||||
|
||||
function MyResultsPage({ navigate }) {
|
||||
const [name, setName] = useState('');
|
||||
const [results, setResults] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const search = () => {
|
||||
if (!name.trim()) return;
|
||||
setError('');
|
||||
api.get(`/api/results/${encodeURIComponent(name.trim())}`)
|
||||
.then(setResults)
|
||||
.catch((e) => setError(e.message));
|
||||
};
|
||||
|
||||
// Group by session
|
||||
const sessions = {};
|
||||
if (results) {
|
||||
results.forEach((r) => {
|
||||
const sid = r['Session_ID'] || 'Unbekannt';
|
||||
if (!sessions[sid]) sessions[sid] = { modus: r['Modus'], fragenset: r['Fragenset'], date: r['Zeitstempel'], answers: [] };
|
||||
sessions[sid].answers.push(r);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<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 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-indigo-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-indigo-600 hover:bg-indigo-700 rounded-xl font-bold">Suchen</button>
|
||||
{error && <p className="text-red-400 text-center">{error}</p>}
|
||||
</div>
|
||||
{results && results.length === 0 && <p className="mt-4 text-gray-500">Keine Ergebnisse gefunden.</p>}
|
||||
{results && results.length > 0 && (
|
||||
<div className="w-full max-w-md mt-6 space-y-4">
|
||||
{Object.entries(sessions).reverse().map(([sid, s]) => {
|
||||
const total = s.answers.length;
|
||||
const correct = s.answers.filter((a) => a['Richtig']).length;
|
||||
const points = s.answers.reduce((sum, a) => sum + (a['Punkte'] || 0), 0);
|
||||
return (
|
||||
<div key={sid} className="bg-gray-800 rounded-xl p-4">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${s.modus === 'Live' ? 'bg-indigo-600' : 'bg-emerald-600'}`}>{s.modus}</span>
|
||||
<span className="ml-2 font-semibold">{s.fragenset}</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-400">{s.date ? new Date(s.date).toLocaleDateString('de') : ''}</span>
|
||||
</div>
|
||||
<p>{correct}/{total} richtig — {points} Punkte</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<button onClick={() => navigate('home')} className="mt-6 py-3 px-6 text-gray-400 hover:text-white">← Zurück</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* App Router */
|
||||
/* ================================================================== */
|
||||
|
||||
function App() {
|
||||
const [page, setPage] = useState('home');
|
||||
const [params, setParams] = useState({});
|
||||
|
||||
const navigate = useCallback((p, par = {}) => {
|
||||
setPage(p);
|
||||
setParams(par);
|
||||
window.scrollTo(0, 0);
|
||||
}, []);
|
||||
|
||||
switch (page) {
|
||||
case 'home': return <HomePage navigate={navigate} />;
|
||||
case 'join': return <JoinPage navigate={navigate} />;
|
||||
case 'live-play': return <LivePlayPage params={params} navigate={navigate} />;
|
||||
case 'solo-select': return <SoloSelectPage navigate={navigate} />;
|
||||
case 'solo-quiz': return <SoloQuizPage params={params} navigate={navigate} />;
|
||||
case 'solo-result': return <SoloResultPage params={params} navigate={navigate} />;
|
||||
case 'my-results': return <MyResultsPage navigate={navigate} />;
|
||||
default: return <HomePage navigate={navigate} />;
|
||||
}
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
||||
@@ -0,0 +1,216 @@
|
||||
const { useState, useEffect, useRef } = React;
|
||||
|
||||
function wsUrl() {
|
||||
return `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}`;
|
||||
}
|
||||
|
||||
const COLORS = { A: 'answer-a', B: 'answer-b', C: 'answer-c', D: 'answer-d' };
|
||||
|
||||
function PresentApp() {
|
||||
const [phase, setPhase] = useState('connect');
|
||||
const [session, setSession] = useState(null);
|
||||
const [question, setQuestion] = useState(null);
|
||||
const [qIndex, setQIndex] = useState(0);
|
||||
const [qTotal, setQTotal] = useState(0);
|
||||
const [timeLimit, setTimeLimit] = useState(0);
|
||||
const [timeLeft, setTimeLeft] = useState(0);
|
||||
const [answerCount, setAnswerCount] = useState(0);
|
||||
const [result, setResult] = useState(null);
|
||||
const [leaderboard, setLeaderboard] = useState([]);
|
||||
const timerRef = useRef(null);
|
||||
const wsRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const code = location.hash.replace('#', '');
|
||||
const token = prompt_alternative();
|
||||
|
||||
if (!code || !token) {
|
||||
setPhase('need-config');
|
||||
return;
|
||||
}
|
||||
|
||||
const ws = new WebSocket(wsUrl());
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({ type: 'present-join', token, code }));
|
||||
};
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data);
|
||||
switch (msg.type) {
|
||||
case 'present-joined':
|
||||
setSession(msg.session);
|
||||
setLeaderboard(msg.session.leaderboard || []);
|
||||
setPhase('waiting');
|
||||
break;
|
||||
case 'player-joined':
|
||||
case 'player-left':
|
||||
setSession((s) => s ? { ...s, players: msg.players, playerCount: msg.playerCount } : s);
|
||||
break;
|
||||
case 'game-start':
|
||||
setQTotal(msg.totalQuestions);
|
||||
break;
|
||||
case 'question':
|
||||
setQuestion(msg.question);
|
||||
setQIndex(msg.index);
|
||||
setQTotal(msg.total);
|
||||
setTimeLimit(msg.timeLimit);
|
||||
setTimeLeft(msg.timeLimit);
|
||||
setAnswerCount(0);
|
||||
setResult(null);
|
||||
setPhase('question');
|
||||
break;
|
||||
case 'answer-count':
|
||||
setAnswerCount(msg.count);
|
||||
break;
|
||||
case 'time-up':
|
||||
clearInterval(timerRef.current);
|
||||
break;
|
||||
case 'question-result':
|
||||
setResult(msg);
|
||||
setLeaderboard(msg.leaderboard);
|
||||
setPhase('result');
|
||||
clearInterval(timerRef.current);
|
||||
break;
|
||||
case 'game-end':
|
||||
setLeaderboard(msg.leaderboard);
|
||||
setPhase('final');
|
||||
clearInterval(timerRef.current);
|
||||
break;
|
||||
case 'error':
|
||||
setPhase('error');
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return () => ws.close();
|
||||
}, []);
|
||||
|
||||
// Timer
|
||||
useEffect(() => {
|
||||
clearInterval(timerRef.current);
|
||||
if (phase === 'question' && timeLimit > 0) {
|
||||
const start = Date.now();
|
||||
timerRef.current = setInterval(() => {
|
||||
setTimeLeft(Math.max(0, timeLimit - (Date.now() - start) / 1000));
|
||||
}, 100);
|
||||
}
|
||||
}, [phase, timeLimit]);
|
||||
|
||||
// Simple token retrieval (read from URL param or sessionStorage)
|
||||
function prompt_alternative() {
|
||||
const params = new URLSearchParams(location.search);
|
||||
let t = params.get('token');
|
||||
if (!t) {
|
||||
try { t = sessionStorage.getItem('qa_token'); } catch (e) { }
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
if (phase === 'need-config') {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center p-8">
|
||||
<h1 className="text-3xl font-bold mb-4">📺 Präsentation</h1>
|
||||
<p className="text-gray-400 mb-4">Öffne diesen Link aus dem Admin-Panel oder füge <code>?token=PASSWORT#CODE</code> zur URL hinzu.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === 'error') return <div className="min-h-screen flex items-center justify-center text-red-400 text-3xl">Verbindungsfehler</div>;
|
||||
|
||||
if (phase === 'waiting') return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center p-8">
|
||||
<h1 className="text-4xl font-extrabold mb-4 text-transparent bg-clip-text bg-gradient-to-r from-indigo-400 to-pink-500">⚡ Quizalarm</h1>
|
||||
<p className="text-gray-400 text-xl mb-6">{session?.setName}</p>
|
||||
<div className="text-8xl font-mono font-extrabold tracking-widest text-indigo-400 mb-8">{session?.code}</div>
|
||||
<p className="text-2xl mb-4">{session?.players?.length || 0} Teilnehmer</p>
|
||||
<div className="flex flex-wrap gap-3 justify-center max-w-2xl">
|
||||
{(session?.players || []).map((n) => (
|
||||
<span key={n} className="px-4 py-2 bg-gray-800 rounded-full text-lg">{n}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (phase === 'question') {
|
||||
const pct = timeLimit > 0 ? (timeLeft / timeLimit) * 100 : 100;
|
||||
const barColor = pct > 50 ? 'bg-green-500' : pct > 25 ? 'bg-yellow-500' : 'bg-red-500';
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col p-8">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<span className="text-gray-400 text-xl">Frage {qIndex + 1} / {qTotal}</span>
|
||||
<span className="text-xl">{answerCount} / {session?.players?.length || 0} 💬</span>
|
||||
{timeLimit > 0 && <span className="text-3xl font-bold">{Math.ceil(timeLeft)}s</span>}
|
||||
</div>
|
||||
{timeLimit > 0 && (
|
||||
<div className="w-full bg-gray-700 rounded-full h-3 mb-6 overflow-hidden">
|
||||
<div className={`${barColor} h-full rounded-full transition-all duration-200`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
)}
|
||||
<h2 className="text-4xl font-bold text-center mb-8 flex-shrink-0">{question?.frage}</h2>
|
||||
{question?.bild && <img src={question.bild} className="max-h-64 mx-auto rounded-xl mb-8" alt="" />}
|
||||
{question?.typ?.toLowerCase() !== 'freitext' && (
|
||||
<div className="grid grid-cols-2 gap-4 flex-1 max-h-96">
|
||||
{(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`}>
|
||||
<span className="opacity-60 mr-3 text-3xl">{a.key}</span> {a.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{question?.typ?.toLowerCase() === 'freitext' && (
|
||||
<div className="text-center text-2xl text-gray-400">✏️ Freitext-Antwort</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === 'result') {
|
||||
const maxStat = Math.max(1, ...Object.values(result?.stats || {}));
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col p-8">
|
||||
<h2 className="text-3xl font-bold text-center mb-2">Ergebnis</h2>
|
||||
<p className="text-center text-xl text-green-400 mb-6">Richtig: {result?.correctAnswer?.join(', ')}</p>
|
||||
<div className="flex gap-4 justify-center items-end mb-8 h-48">
|
||||
{Object.entries(result?.stats || {}).map(([k, v]) => (
|
||||
<div key={k} className="flex flex-col items-center flex-1 max-w-32">
|
||||
<span className="text-xl font-bold mb-1">{v}</span>
|
||||
<div className={`${COLORS[k]} w-full rounded-t-lg transition-all`} style={{ height: `${(v / maxStat) * 150}px`, minHeight: '8px' }} />
|
||||
<span className="text-lg font-bold mt-1">{k}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-2xl font-bold text-center mb-4">🏆 Leaderboard</h3>
|
||||
<div className="max-w-xl mx-auto">
|
||||
{leaderboard.slice(0, 8).map((e, i) => (
|
||||
<div key={e.name} className={`flex justify-between py-3 px-5 rounded-xl mb-2 text-xl ${i === 0 ? 'bg-yellow-600' : i === 1 ? 'bg-gray-500' : i === 2 ? 'bg-amber-700' : 'bg-gray-800'}`}>
|
||||
<span className="font-bold">{e.rank}. {e.name}</span>
|
||||
<span className="font-extrabold">{e.score}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === 'final') return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center p-8">
|
||||
<h2 className="text-5xl font-extrabold mb-8">🏁 Quiz beendet!</h2>
|
||||
<div className="w-full max-w-xl">
|
||||
{leaderboard.map((e, i) => (
|
||||
<div key={e.name} className={`flex justify-between py-4 px-6 rounded-xl mb-2 text-2xl ${i === 0 ? 'bg-yellow-600' : i === 1 ? 'bg-gray-500' : i === 2 ? 'bg-amber-700' : 'bg-gray-800'}`}>
|
||||
<span className="font-bold">{i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${e.rank}.`} {e.name}</span>
|
||||
<span className="font-extrabold">{e.score}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return <div className="min-h-screen flex items-center justify-center animate-pulse text-2xl">Verbinde...</div>;
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<PresentApp />);
|
||||
@@ -0,0 +1,40 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Quizalarm — Präsentation</title>
|
||||
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
|
||||
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
|
||||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
.answer-a {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.answer-b {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.answer-c {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.answer-d {
|
||||
background: #22c55e;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="bg-gray-900 text-white min-h-screen">
|
||||
<div id="root"></div>
|
||||
<script type="text/babel" src="/js/present.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,49 @@
|
||||
const { getEnvConfig } = require('./config');
|
||||
|
||||
async function baserowFetch(endpoint, options = {}) {
|
||||
const { baserowUrl, baserowToken } = getEnvConfig();
|
||||
const url = `${baserowUrl}/api${endpoint}`;
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: `Token ${baserowToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`Baserow ${res.status}: ${body.substring(0, 200)}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function listRows(tableId, filters = {}) {
|
||||
let all = [];
|
||||
let page = 1;
|
||||
while (true) {
|
||||
let url = `/database/rows/table/${tableId}/?user_field_names=true&size=200&page=${page}`;
|
||||
for (const [field, value] of Object.entries(filters)) {
|
||||
url += `&filter__${encodeURIComponent(field)}__equal=${encodeURIComponent(value)}`;
|
||||
}
|
||||
const data = await baserowFetch(url);
|
||||
all = all.concat(data.results || []);
|
||||
if (!data.next) break;
|
||||
page++;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
async function batchCreateRows(tableId, rows) {
|
||||
if (!rows.length) return;
|
||||
// Baserow limit: 200 per batch
|
||||
for (let i = 0; i < rows.length; i += 200) {
|
||||
const chunk = rows.slice(i, i + 200);
|
||||
await baserowFetch(`/database/rows/table/${tableId}/batch/?user_field_names=true`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ items: chunk }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listRows, batchCreateRows };
|
||||
@@ -0,0 +1,33 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const CONFIG_PATH = process.env.CONFIG_PATH || '/data/config.json';
|
||||
|
||||
function getEnvConfig() {
|
||||
return {
|
||||
adminPassword: process.env.ADMIN_PASSWORD || 'admin',
|
||||
baserowUrl: (process.env.BASEROW_URL || 'http://baserow:80').replace(/\/+$/, ''),
|
||||
baserowToken: process.env.BASEROW_TOKEN || '',
|
||||
port: parseInt(process.env.PORT || '7849', 10),
|
||||
};
|
||||
}
|
||||
|
||||
function readConfig() {
|
||||
const defaults = { sets: [], answersTableId: null };
|
||||
try {
|
||||
if (fs.existsSync(CONFIG_PATH)) {
|
||||
return { ...defaults, ...JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[config] Fehler beim Lesen:', e.message);
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
|
||||
function writeConfig(data) {
|
||||
const dir = path.dirname(CONFIG_PATH);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(CONFIG_PATH, JSON.stringify(data, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
module.exports = { getEnvConfig, readConfig, writeConfig };
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
const path = require('path');
|
||||
const { getEnvConfig, readConfig, writeConfig } = require('./config');
|
||||
const { listRows, batchCreateRows } = require('./baserow');
|
||||
const { setupWebSocket, getSessions } = require('./live-session');
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
|
||||
app.use(express.json({ limit: '5mb' }));
|
||||
app.use(express.static(path.join(__dirname, '..', 'public')));
|
||||
|
||||
/* ---- Auth middleware ---- */
|
||||
function requireAdmin(req, res, next) {
|
||||
if (req.headers['x-admin-token'] !== getEnvConfig().adminPassword) {
|
||||
return res.status(401).json({ error: 'Nicht autorisiert' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/* ============================================================ */
|
||||
/* Public API */
|
||||
/* ============================================================ */
|
||||
|
||||
app.get('/api/sets', (req, res) => {
|
||||
const config = readConfig();
|
||||
res.json(config.sets.map((s) => ({ id: s.id, name: s.name })));
|
||||
});
|
||||
|
||||
app.get('/api/sets/:id/questions', async (req, res) => {
|
||||
try {
|
||||
const config = readConfig();
|
||||
const set = config.sets.find((s) => s.id === req.params.id);
|
||||
if (!set) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const rows = await listRows(set.tableId);
|
||||
res.json(rows);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/results', async (req, res) => {
|
||||
try {
|
||||
const config = readConfig();
|
||||
if (!config.answersTableId) return res.status(400).json({ error: 'Antworten-Tabelle nicht konfiguriert' });
|
||||
await batchCreateRows(config.answersTableId, req.body.answers);
|
||||
res.json({ success: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/results/:name', async (req, res) => {
|
||||
try {
|
||||
const config = readConfig();
|
||||
if (!config.answersTableId) return res.json([]);
|
||||
const rows = await listRows(config.answersTableId, { Nutzername: req.params.name });
|
||||
res.json(rows);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
/* ============================================================ */
|
||||
/* Admin API */
|
||||
/* ============================================================ */
|
||||
|
||||
app.post('/api/admin/login', (req, res) => {
|
||||
if (req.body.password === getEnvConfig().adminPassword) {
|
||||
res.json({ success: true, token: getEnvConfig().adminPassword });
|
||||
} else {
|
||||
res.status(401).json({ error: 'Falsches Passwort' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/admin/config', requireAdmin, (req, res) => res.json(readConfig()));
|
||||
|
||||
app.post('/api/admin/config', requireAdmin, (req, res) => {
|
||||
try {
|
||||
writeConfig(req.body);
|
||||
res.json({ success: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/admin/sessions', requireAdmin, (req, res) => res.json(getSessions()));
|
||||
|
||||
app.get('/api/admin/stats', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const config = readConfig();
|
||||
if (!config.answersTableId) return res.json({ answers: [] });
|
||||
const rows = await listRows(config.answersTableId);
|
||||
res.json({ answers: rows });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
/* ---- SPA routes ---- */
|
||||
app.get('/admin', (req, res) => res.sendFile(path.join(__dirname, '..', 'public', 'admin.html')));
|
||||
app.get('/present', (req, res) => res.sendFile(path.join(__dirname, '..', 'public', 'present.html')));
|
||||
app.get('*', (req, res) => res.sendFile(path.join(__dirname, '..', 'public', 'index.html')));
|
||||
|
||||
/* ---- Start ---- */
|
||||
setupWebSocket(server);
|
||||
const { port } = getEnvConfig();
|
||||
server.listen(port, '0.0.0.0', () => console.log(`[quizalarm] Laeuft auf Port ${port}`));
|
||||
@@ -0,0 +1,409 @@
|
||||
const { WebSocket, WebSocketServer } = require('ws');
|
||||
const { getEnvConfig, readConfig } = require('./config');
|
||||
const { listRows, batchCreateRows } = require('./baserow');
|
||||
const { calculateScore, sanitizeQuestion, parseCorrectAnswers } = require('./quiz-logic');
|
||||
|
||||
const sessions = new Map();
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function generateCode() {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
let code = '';
|
||||
for (let i = 0; i < 6; i++) code += chars[Math.floor(Math.random() * chars.length)];
|
||||
return code;
|
||||
}
|
||||
|
||||
function send(ws, data) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(data));
|
||||
}
|
||||
|
||||
function broadcastAll(session, data, exclude) {
|
||||
for (const [, p] of session.players) if (p.ws !== exclude) send(p.ws, data);
|
||||
for (const ws of session.admins) if (ws !== exclude) send(ws, data);
|
||||
for (const ws of session.presents) if (ws !== exclude) send(ws, data);
|
||||
}
|
||||
|
||||
function broadcastAdmins(session, data) {
|
||||
for (const ws of session.admins) send(ws, data);
|
||||
for (const ws of session.presents) send(ws, data);
|
||||
}
|
||||
|
||||
function broadcastPlayers(session, data) {
|
||||
for (const [, p] of session.players) send(p.ws, data);
|
||||
}
|
||||
|
||||
function playerList(session) {
|
||||
return Array.from(session.players.keys());
|
||||
}
|
||||
|
||||
function leaderboard(session) {
|
||||
return Array.from(session.players.entries())
|
||||
.map(([name, p]) => ({ name, score: p.score }))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map((e, i) => ({ ...e, rank: i + 1 }));
|
||||
}
|
||||
|
||||
function sessionInfo(s) {
|
||||
return {
|
||||
code: s.code,
|
||||
setName: s.setName,
|
||||
status: s.status,
|
||||
options: s.options,
|
||||
questionCount: s.questions.length,
|
||||
currentQuestionIndex: s.currentQuestionIndex,
|
||||
playerCount: s.players.size,
|
||||
players: playerList(s),
|
||||
leaderboard: leaderboard(s),
|
||||
};
|
||||
}
|
||||
|
||||
function shuffle(arr) {
|
||||
const a = [...arr];
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
function isAdmin(token) {
|
||||
return token === getEnvConfig().adminPassword;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* WebSocket setup */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function setupWebSocket(server) {
|
||||
const wss = new WebSocketServer({ server });
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
ws.isAlive = true;
|
||||
ws.on('pong', () => (ws.isAlive = true));
|
||||
ws.on('message', (raw) => {
|
||||
try {
|
||||
handleMessage(ws, JSON.parse(raw));
|
||||
} catch (e) {
|
||||
send(ws, { type: 'error', message: 'Ungueltige Nachricht' });
|
||||
}
|
||||
});
|
||||
ws.on('close', () => handleDisconnect(ws));
|
||||
});
|
||||
|
||||
// Heartbeat every 30 s
|
||||
setInterval(() => {
|
||||
wss.clients.forEach((ws) => {
|
||||
if (!ws.isAlive) return ws.terminate();
|
||||
ws.isAlive = false;
|
||||
ws.ping();
|
||||
});
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Message router */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
async function handleMessage(ws, msg) {
|
||||
switch (msg.type) {
|
||||
case 'admin-create':
|
||||
return adminCreate(ws, msg);
|
||||
case 'admin-join':
|
||||
return adminJoin(ws, msg);
|
||||
case 'admin-start':
|
||||
return adminStart(ws, msg);
|
||||
case 'admin-next':
|
||||
return adminNext(ws, msg);
|
||||
case 'admin-end':
|
||||
return adminEnd(ws, msg);
|
||||
case 'present-join':
|
||||
return presentJoin(ws, msg);
|
||||
case 'join':
|
||||
return playerJoin(ws, msg);
|
||||
case 'answer':
|
||||
return playerAnswer(ws, msg);
|
||||
default:
|
||||
send(ws, { type: 'error', message: 'Unbekannter Typ' });
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Admin handlers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
async function adminCreate(ws, msg) {
|
||||
if (!isAdmin(msg.token)) return send(ws, { type: 'error', message: 'Nicht autorisiert' });
|
||||
|
||||
const config = readConfig();
|
||||
const set = config.sets.find((s) => s.id === msg.setId);
|
||||
if (!set) return send(ws, { type: 'error', message: 'Fragenset nicht gefunden' });
|
||||
|
||||
let questions;
|
||||
try {
|
||||
questions = await listRows(set.tableId);
|
||||
} catch (e) {
|
||||
return send(ws, { type: 'error', message: 'Fehler beim Laden: ' + e.message });
|
||||
}
|
||||
if (!questions.length) return send(ws, { type: 'error', message: 'Keine Fragen gefunden' });
|
||||
|
||||
const opts = {
|
||||
shuffle: msg.options.shuffle !== false,
|
||||
scoring: msg.options.scoring || 'binary',
|
||||
timeLimit: parseInt(msg.options.timeLimit) || 30,
|
||||
};
|
||||
|
||||
if (opts.shuffle) questions = shuffle(questions);
|
||||
|
||||
const code = generateCode();
|
||||
const session = {
|
||||
code,
|
||||
sessionId: 'LIVE-' + Date.now().toString(36).toUpperCase(),
|
||||
setId: set.id,
|
||||
setName: set.name,
|
||||
tableId: set.tableId,
|
||||
options: opts,
|
||||
questions,
|
||||
currentQuestionIndex: -1,
|
||||
status: 'waiting',
|
||||
players: new Map(),
|
||||
admins: new Set([ws]),
|
||||
presents: new Set(),
|
||||
currentAnswers: new Map(),
|
||||
questionStartTime: null,
|
||||
timer: null,
|
||||
results: [],
|
||||
};
|
||||
|
||||
sessions.set(code, session);
|
||||
ws._qa = { code, role: 'admin' };
|
||||
|
||||
send(ws, {
|
||||
type: 'session-created',
|
||||
code,
|
||||
setName: set.name,
|
||||
questionCount: questions.length,
|
||||
options: opts,
|
||||
});
|
||||
}
|
||||
|
||||
function adminJoin(ws, msg) {
|
||||
if (!isAdmin(msg.token)) return send(ws, { type: 'error', message: 'Nicht autorisiert' });
|
||||
const s = sessions.get(msg.code);
|
||||
if (!s) return send(ws, { type: 'error', message: 'Session nicht gefunden' });
|
||||
s.admins.add(ws);
|
||||
ws._qa = { code: msg.code, role: 'admin' };
|
||||
send(ws, { type: 'admin-joined', session: sessionInfo(s) });
|
||||
}
|
||||
|
||||
function presentJoin(ws, msg) {
|
||||
if (!isAdmin(msg.token)) return send(ws, { type: 'error', message: 'Nicht autorisiert' });
|
||||
const s = sessions.get(msg.code);
|
||||
if (!s) return send(ws, { type: 'error', message: 'Session nicht gefunden' });
|
||||
s.presents.add(ws);
|
||||
ws._qa = { code: msg.code, role: 'present' };
|
||||
send(ws, { type: 'present-joined', session: sessionInfo(s) });
|
||||
}
|
||||
|
||||
function adminStart(ws, msg) {
|
||||
if (!isAdmin(msg.token)) return;
|
||||
const s = sessions.get(msg.code);
|
||||
if (!s || s.status !== 'waiting') return;
|
||||
s.status = 'active';
|
||||
broadcastAll(s, { type: 'game-start', totalQuestions: s.questions.length });
|
||||
sendNextQuestion(s);
|
||||
}
|
||||
|
||||
function adminNext(ws, msg) {
|
||||
if (!isAdmin(msg.token)) return;
|
||||
const s = sessions.get(msg.code);
|
||||
if (!s || s.status !== 'showing-result') return;
|
||||
sendNextQuestion(s);
|
||||
}
|
||||
|
||||
async function adminEnd(ws, msg) {
|
||||
if (!isAdmin(msg.token)) return;
|
||||
const s = sessions.get(msg.code);
|
||||
if (!s) return;
|
||||
await endSession(s);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Player handlers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function playerJoin(ws, msg) {
|
||||
const s = sessions.get(msg.code);
|
||||
if (!s) return send(ws, { type: 'error', message: 'Session nicht gefunden' });
|
||||
if (s.status !== 'waiting') return send(ws, { type: 'error', message: 'Session laeuft bereits' });
|
||||
|
||||
const name = (msg.name || '').trim();
|
||||
if (!name) return send(ws, { type: 'error', message: 'Name darf nicht leer sein' });
|
||||
|
||||
if (s.players.has(name)) {
|
||||
const existing = s.players.get(name);
|
||||
if (existing.ws && existing.ws.readyState === WebSocket.OPEN) {
|
||||
return send(ws, { type: 'error', message: 'Name bereits online. Bitte waehle einen anderen.' });
|
||||
}
|
||||
existing.ws = ws; // Reconnect
|
||||
} else {
|
||||
s.players.set(name, { ws, score: 0, answers: [] });
|
||||
}
|
||||
|
||||
ws._qa = { code: msg.code, role: 'player', name };
|
||||
|
||||
send(ws, { type: 'joined', session: { code: s.code, setName: s.setName, playerCount: s.players.size, players: playerList(s) } });
|
||||
broadcastAll(s, { type: 'player-joined', name, playerCount: s.players.size, players: playerList(s) }, ws);
|
||||
}
|
||||
|
||||
function playerAnswer(ws, msg) {
|
||||
const qa = ws._qa;
|
||||
if (!qa || qa.role !== 'player') return;
|
||||
const s = sessions.get(qa.code);
|
||||
if (!s || s.status !== 'active') return;
|
||||
if (s.currentAnswers.has(qa.name)) return; // Already answered
|
||||
|
||||
const elapsed = (Date.now() - s.questionStartTime) / 1000;
|
||||
const tl = s.options.scoring === 'time' ? s.options.timeLimit : 0;
|
||||
const remaining = tl > 0 ? Math.max(0, tl - elapsed) : 0;
|
||||
|
||||
if (tl > 0 && remaining <= 0) return; // Time up
|
||||
|
||||
s.currentAnswers.set(qa.name, { answers: msg.answers || [], timeRemaining: remaining });
|
||||
broadcastAdmins(s, { type: 'answer-count', count: s.currentAnswers.size, total: s.players.size });
|
||||
send(ws, { type: 'answer-received' });
|
||||
|
||||
if (s.currentAnswers.size >= s.players.size) showResult(s);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Game flow */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function sendNextQuestion(s) {
|
||||
s.currentQuestionIndex++;
|
||||
if (s.currentQuestionIndex >= s.questions.length) return endSession(s);
|
||||
|
||||
s.status = 'active';
|
||||
s.currentAnswers.clear();
|
||||
s.questionStartTime = Date.now();
|
||||
|
||||
const q = s.questions[s.currentQuestionIndex];
|
||||
const tl = s.options.scoring === 'time' ? s.options.timeLimit : 0;
|
||||
|
||||
broadcastAll(s, {
|
||||
type: 'question',
|
||||
index: s.currentQuestionIndex,
|
||||
total: s.questions.length,
|
||||
question: sanitizeQuestion(q),
|
||||
timeLimit: tl,
|
||||
});
|
||||
|
||||
clearTimeout(s.timer);
|
||||
if (tl > 0) {
|
||||
s.timer = setTimeout(() => {
|
||||
if (s.status === 'active') broadcastAll(s, { type: 'time-up' });
|
||||
}, tl * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function showResult(s) {
|
||||
clearTimeout(s.timer);
|
||||
s.status = 'showing-result';
|
||||
|
||||
const q = s.questions[s.currentQuestionIndex];
|
||||
const correct = parseCorrectAnswers(q);
|
||||
const tl = s.options.scoring === 'time' ? s.options.timeLimit : 0;
|
||||
|
||||
// Answer distribution
|
||||
const stats = {};
|
||||
['A', 'B', 'C', 'D'].forEach((k) => { if (q[`Antwort ${k}`]) stats[k] = 0; });
|
||||
|
||||
// Score for each player who answered
|
||||
for (const [name, ans] of s.currentAnswers) {
|
||||
const player = s.players.get(name);
|
||||
const result = calculateScore(q, ans.answers, ans.timeRemaining, tl, s.options.scoring);
|
||||
player.score += result.points;
|
||||
player.answers.push({ qId: q.id, idx: s.currentQuestionIndex, answers: ans.answers, correct: result.correct, points: result.points });
|
||||
ans.answers.forEach((a) => { if (stats[a] !== undefined) stats[a]++; });
|
||||
s.results.push({
|
||||
Nutzername: name, Frage_ID: q.id, Fragenset: s.setName,
|
||||
Antwort: ans.answers.join(','), Richtig: result.correct,
|
||||
Punkte: result.points, Session_ID: s.sessionId,
|
||||
Modus: 'Live', Zeitstempel: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Players who didn't answer
|
||||
for (const [name, player] of s.players) {
|
||||
if (!s.currentAnswers.has(name)) {
|
||||
player.answers.push({ qId: q.id, idx: s.currentQuestionIndex, answers: [], correct: false, points: 0 });
|
||||
s.results.push({
|
||||
Nutzername: name, Frage_ID: q.id, Fragenset: s.setName,
|
||||
Antwort: '', Richtig: false, Punkte: 0, Session_ID: s.sessionId,
|
||||
Modus: 'Live', Zeitstempel: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const lb = leaderboard(s);
|
||||
|
||||
// Send personal results to players
|
||||
for (const [name, player] of s.players) {
|
||||
const last = player.answers[player.answers.length - 1];
|
||||
send(player.ws, {
|
||||
type: 'question-result', correctAnswer: correct, stats, leaderboard: lb,
|
||||
yourResult: { correct: last.correct, points: last.points, totalScore: player.score },
|
||||
});
|
||||
}
|
||||
|
||||
broadcastAdmins(s, { type: 'question-result', correctAnswer: correct, stats, leaderboard: lb });
|
||||
}
|
||||
|
||||
async function endSession(s) {
|
||||
clearTimeout(s.timer);
|
||||
s.status = 'ended';
|
||||
const lb = leaderboard(s);
|
||||
broadcastAll(s, { type: 'game-end', leaderboard: lb, sessionId: s.sessionId });
|
||||
|
||||
try {
|
||||
const config = readConfig();
|
||||
if (config.answersTableId && s.results.length) {
|
||||
await batchCreateRows(config.answersTableId, s.results);
|
||||
console.log(`[quizalarm] Session ${s.code}: ${s.results.length} Ergebnisse gespeichert`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[quizalarm] Fehler beim Speichern:', e.message);
|
||||
}
|
||||
|
||||
setTimeout(() => sessions.delete(s.code), 300000);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Disconnect */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function handleDisconnect(ws) {
|
||||
const qa = ws._qa;
|
||||
if (!qa) return;
|
||||
const s = sessions.get(qa.code);
|
||||
if (!s) return;
|
||||
|
||||
if (qa.role === 'admin') s.admins.delete(ws);
|
||||
else if (qa.role === 'present') s.presents.delete(ws);
|
||||
else if (qa.role === 'player') {
|
||||
broadcastAll(s, { type: 'player-left', name: qa.name, playerCount: s.players.size, players: playerList(s) });
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Exports */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function getSessions() {
|
||||
return Array.from(sessions.values()).map(sessionInfo);
|
||||
}
|
||||
|
||||
module.exports = { setupWebSocket, getSessions };
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Normalize text for freitext comparison.
|
||||
* Removes special characters, lowercases, collapses whitespace.
|
||||
* "Villingen-Schwenningen" == "villingen schwenningen" == "villingenschwenningen"
|
||||
*/
|
||||
function normalizeText(text) {
|
||||
if (!text) return '';
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[äÄ]/g, 'ae')
|
||||
.replace(/[öÖ]/g, 'oe')
|
||||
.replace(/[üÜ]/g, 'ue')
|
||||
.replace(/ß/g, 'ss')
|
||||
.replace(/[^a-z0-9]/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function checkFreitext(userAnswer, correctAnswer) {
|
||||
return normalizeText(userAnswer) === normalizeText(correctAnswer);
|
||||
}
|
||||
|
||||
function parseCorrectAnswers(question) {
|
||||
const raw = (question['Richtige Antwort'] || '').trim();
|
||||
const typ = (question['Typ'] || 'MC').toLowerCase();
|
||||
if (typ === 'freitext') return [raw];
|
||||
return raw
|
||||
.split(',')
|
||||
.map((s) => s.trim().toUpperCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function countOptions(question) {
|
||||
let n = 0;
|
||||
if (question['Antwort A']) n++;
|
||||
if (question['Antwort B']) n++;
|
||||
if (question['Antwort C']) n++;
|
||||
if (question['Antwort D']) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate score for an answer.
|
||||
* @param {object} question – Baserow row
|
||||
* @param {string[]} selected – e.g. ['A','C'] or ['Berlin']
|
||||
* @param {number} timeRemaining – seconds left (0 if no timer)
|
||||
* @param {number} totalTime – total seconds (0 if binary)
|
||||
* @param {string} scoringMode – 'binary' | 'time'
|
||||
* @returns {{ points: number, correct: boolean }}
|
||||
*/
|
||||
function calculateScore(question, selected, timeRemaining, totalTime, scoringMode) {
|
||||
if (!selected || selected.length === 0) return { points: 0, correct: false };
|
||||
|
||||
// Base points
|
||||
let basis;
|
||||
if (scoringMode === 'time' && totalTime > 0) {
|
||||
const ratio = Math.max(0, Math.min(1, timeRemaining / totalTime));
|
||||
basis = 500 + Math.round(ratio * 500);
|
||||
} else {
|
||||
basis = 1000;
|
||||
}
|
||||
|
||||
const typ = (question['Typ'] || 'MC').toLowerCase();
|
||||
const correctAnswers = parseCorrectAnswers(question);
|
||||
|
||||
// Freitext
|
||||
if (typ === 'freitext') {
|
||||
const ok = checkFreitext(selected[0], correctAnswers[0]);
|
||||
return { points: ok ? basis : 0, correct: ok };
|
||||
}
|
||||
|
||||
// MC / Wahr-Falsch
|
||||
const correctSet = new Set(correctAnswers);
|
||||
const totalCorrect = correctSet.size;
|
||||
const totalIncorrect = countOptions(question) - totalCorrect;
|
||||
|
||||
let rightPicked = 0;
|
||||
let wrongPicked = 0;
|
||||
for (const s of selected) {
|
||||
if (correctSet.has(s.toUpperCase())) rightPicked++;
|
||||
else wrongPicked++;
|
||||
}
|
||||
|
||||
let points = (rightPicked / Math.max(totalCorrect, 1)) * basis;
|
||||
if (totalIncorrect > 0) {
|
||||
points -= (wrongPicked / totalIncorrect) * (basis / 2);
|
||||
}
|
||||
points = Math.max(0, Math.round(points));
|
||||
|
||||
const fullyCorrect = rightPicked === totalCorrect && wrongPicked === 0;
|
||||
return { points, correct: fullyCorrect };
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the correct answer from a question before sending to participants.
|
||||
*/
|
||||
function sanitizeQuestion(question) {
|
||||
const typ = (question['Typ'] || 'MC').toLowerCase();
|
||||
const sanitized = {
|
||||
id: question.id,
|
||||
frage: question['Frage'] || '',
|
||||
typ: question['Typ'] || 'MC',
|
||||
bild: null,
|
||||
antworten: [],
|
||||
kategorie: question['Kategorie'] || '',
|
||||
};
|
||||
|
||||
// Bild (Baserow file field → array of objects)
|
||||
const bild = question['Bild'];
|
||||
if (bild && Array.isArray(bild) && bild.length > 0) {
|
||||
sanitized.bild = bild[0].url;
|
||||
}
|
||||
|
||||
if (typ === 'freitext') {
|
||||
// no options
|
||||
} else {
|
||||
const keys = ['A', 'B', 'C', 'D'];
|
||||
for (const k of keys) {
|
||||
const val = question[`Antwort ${k}`];
|
||||
if (val) sanitized.antworten.push({ key: k, text: val });
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
module.exports = { calculateScore, sanitizeQuestion, parseCorrectAnswers, normalizeText };
|
||||
Reference in New Issue
Block a user