Security & UX Update: Custom session codes, admin session take-over, secure presentation tokens, and SVG/CSS branding assets
This commit is contained in:
@@ -11,6 +11,11 @@
|
|||||||
<script src="https://unpkg.com/react-dom@18/umd/react-dom.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://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="bg-stone-100 text-gray-900 min-h-screen">
|
<body class="bg-stone-100 text-gray-900 min-h-screen">
|
||||||
|
|||||||
+26
-3
@@ -5,7 +5,8 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Quizalarm</title>
|
<title>Quizalarm</title>
|
||||||
<link rel="icon" href="/img/logo.png">
|
<link rel="icon"
|
||||||
|
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🚨</text></svg>">
|
||||||
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
|
<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/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://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||||
@@ -13,10 +14,13 @@
|
|||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
background: #111111 url('/img/bg.png') center/cover fixed;
|
background: #111111;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(ellipse at 20% 50%, rgba(220, 38, 38, 0.08) 0%, transparent 50%),
|
||||||
|
radial-gradient(ellipse at 80% 50%, rgba(245, 158, 11, 0.06) 0%, transparent 50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Answer button base colors */
|
||||||
.answer-a {
|
.answer-a {
|
||||||
background: #dc2626;
|
background: #dc2626;
|
||||||
}
|
}
|
||||||
@@ -49,6 +53,7 @@
|
|||||||
background: #15803d;
|
background: #15803d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Selected state */
|
||||||
.answer-a.selected,
|
.answer-a.selected,
|
||||||
.answer-b.selected,
|
.answer-b.selected,
|
||||||
.answer-c.selected,
|
.answer-c.selected,
|
||||||
@@ -57,6 +62,7 @@
|
|||||||
transform: scale(1.03);
|
transform: scale(1.03);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Disabled state */
|
||||||
.answer-a.disabled,
|
.answer-a.disabled,
|
||||||
.answer-b.disabled,
|
.answer-b.disabled,
|
||||||
.answer-c.disabled,
|
.answer-c.disabled,
|
||||||
@@ -65,6 +71,23 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ensure Tailwind doesn't override answer button styles */
|
||||||
|
button.answer-a,
|
||||||
|
button.answer-b,
|
||||||
|
button.answer-c,
|
||||||
|
button.answer-d {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.answer-a.selected,
|
||||||
|
button.answer-b.selected,
|
||||||
|
button.answer-c.selected,
|
||||||
|
button.answer-d.selected {
|
||||||
|
outline: 4px solid white;
|
||||||
|
transform: scale(1.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fire gradient for headings */
|
||||||
.fire-gradient {
|
.fire-gradient {
|
||||||
background: linear-gradient(135deg, #dc2626, #f59e0b);
|
background: linear-gradient(135deg, #dc2626, #f59e0b);
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
|
|||||||
+116
-97
@@ -228,7 +228,7 @@ function StatsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ================================================================== */
|
/* ================================================================== */
|
||||||
/* Live Setup Page */
|
/* Live Setup Page — with custom code + reconnect */
|
||||||
/* ================================================================== */
|
/* ================================================================== */
|
||||||
|
|
||||||
function LiveSetupPage({ config, token, onSessionCreated }) {
|
function LiveSetupPage({ config, token, onSessionCreated }) {
|
||||||
@@ -236,21 +236,30 @@ function LiveSetupPage({ config, token, onSessionCreated }) {
|
|||||||
const [shuffle, setShuffle] = useState(true);
|
const [shuffle, setShuffle] = useState(true);
|
||||||
const [scoring, setScoring] = useState('binary');
|
const [scoring, setScoring] = useState('binary');
|
||||||
const [timeLimit, setTimeLimit] = useState(30);
|
const [timeLimit, setTimeLimit] = useState(30);
|
||||||
|
const [customCode, setCustomCode] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Reconnect state
|
||||||
|
const [reconnectCode, setReconnectCode] = useState('');
|
||||||
|
const [reconnectError, setReconnectError] = useState('');
|
||||||
|
const [reconnecting, setReconnecting] = useState(false);
|
||||||
|
|
||||||
const create = () => {
|
const create = () => {
|
||||||
if (!setId) return setError('Bitte ein Fragenset waehlen');
|
if (!setId) return setError('Bitte ein Fragenset waehlen');
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
const ws = new WebSocket(wsUrl());
|
const ws = new WebSocket(wsUrl());
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
ws.send(JSON.stringify({ type: 'admin-create', token, setId, options: { shuffle, scoring, timeLimit } }));
|
ws.send(JSON.stringify({
|
||||||
|
type: 'admin-create', token, setId,
|
||||||
|
options: { shuffle, scoring, timeLimit },
|
||||||
|
customCode: customCode.trim() || null,
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
ws.onmessage = (e) => {
|
ws.onmessage = (e) => {
|
||||||
const msg = JSON.parse(e.data);
|
const msg = JSON.parse(e.data);
|
||||||
if (msg.type === 'session-created') {
|
if (msg.type === 'session-created') {
|
||||||
// IMPORTANT: Release WS control before handing off
|
|
||||||
ws.onmessage = null;
|
ws.onmessage = null;
|
||||||
onSessionCreated(msg.code, ws, {
|
onSessionCreated(msg.code, ws, {
|
||||||
code: msg.code,
|
code: msg.code,
|
||||||
@@ -259,6 +268,7 @@ function LiveSetupPage({ config, token, onSessionCreated }) {
|
|||||||
options: msg.options,
|
options: msg.options,
|
||||||
players: [],
|
players: [],
|
||||||
playerCount: 0,
|
playerCount: 0,
|
||||||
|
presentToken: msg.presentToken,
|
||||||
});
|
});
|
||||||
} else if (msg.type === 'error') {
|
} else if (msg.type === 'error') {
|
||||||
setError(msg.message);
|
setError(msg.message);
|
||||||
@@ -269,38 +279,96 @@ function LiveSetupPage({ config, token, onSessionCreated }) {
|
|||||||
ws.onerror = () => { setError('WebSocket Verbindungsfehler'); setLoading(false); };
|
ws.onerror = () => { setError('WebSocket Verbindungsfehler'); setLoading(false); };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const reconnect = () => {
|
||||||
|
const code = reconnectCode.trim().replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
|
||||||
|
if (!code) return setReconnectError('Bitte Code eingeben');
|
||||||
|
setReconnecting(true);
|
||||||
|
setReconnectError('');
|
||||||
|
|
||||||
|
const ws = new WebSocket(wsUrl());
|
||||||
|
ws.onopen = () => {
|
||||||
|
ws.send(JSON.stringify({ type: 'admin-join', token, code }));
|
||||||
|
};
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
const msg = JSON.parse(e.data);
|
||||||
|
if (msg.type === 'admin-joined') {
|
||||||
|
ws.onmessage = null;
|
||||||
|
onSessionCreated(msg.session.code, ws, msg.session);
|
||||||
|
} else if (msg.type === 'error') {
|
||||||
|
setReconnectError(msg.message);
|
||||||
|
setReconnecting(false);
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.onerror = () => { setReconnectError('Verbindungsfehler'); setReconnecting(false); };
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-xl shadow p-6 max-w-md mx-auto border-t-4 border-red-600">
|
<div className="space-y-6 max-w-md mx-auto">
|
||||||
<h2 className="text-xl font-bold mb-4">🎮 Live-Session erstellen</h2>
|
{/* Create new session */}
|
||||||
<div className="space-y-4">
|
<div className="bg-white rounded-xl shadow p-6 border-t-4 border-red-600">
|
||||||
<div>
|
<h2 className="text-xl font-bold mb-4">🎮 Neue Live-Session</h2>
|
||||||
<label className="block text-sm font-medium mb-1">Fragenset</label>
|
<div className="space-y-4">
|
||||||
<select className="w-full p-3 border rounded-lg focus:border-red-500 outline-none" 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-red-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 focus:border-red-500 outline-none" 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>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Sekunden pro Frage</label>
|
<label className="block text-sm font-medium mb-1">Fragenset</label>
|
||||||
<input type="number" className="w-full p-3 border rounded-lg focus:border-red-500 outline-none" value={timeLimit} onChange={(e) => setTimeLimit(parseInt(e.target.value) || 30)} min={5} max={120} />
|
<select className="w-full p-3 border rounded-lg focus:border-red-500 outline-none" 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>
|
</div>
|
||||||
)}
|
<div>
|
||||||
{error && <p className="text-red-500 text-sm bg-red-50 p-2 rounded">{error}</p>}
|
<label className="block text-sm font-medium mb-1">Session-Code <span className="text-gray-400">(optional)</span></label>
|
||||||
<button onClick={create} disabled={loading} className="w-full py-3 bg-red-600 text-white rounded-lg font-bold hover:bg-red-700 disabled:opacity-50">
|
<input
|
||||||
{loading ? 'Erstelle...' : 'Session erstellen'}
|
className="w-full p-3 border rounded-lg focus:border-red-500 outline-none uppercase tracking-wider"
|
||||||
</button>
|
placeholder="z.B. FEUERWEHR oder leer für Zufall"
|
||||||
|
value={customCode}
|
||||||
|
onChange={(e) => setCustomCode(e.target.value)}
|
||||||
|
maxLength={20}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">Nur Buchstaben und Zahlen. Mind. 3 Zeichen. Leer = zufällig generiert.</p>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={shuffle} onChange={() => setShuffle(!shuffle)} className="w-5 h-5 accent-red-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 focus:border-red-500 outline-none" 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 focus:border-red-500 outline-none" value={timeLimit} onChange={(e) => setTimeLimit(parseInt(e.target.value) || 30)} min={5} max={120} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && <p className="text-red-500 text-sm bg-red-50 p-2 rounded">{error}</p>}
|
||||||
|
<button onClick={create} disabled={loading} className="w-full py-3 bg-red-600 text-white rounded-lg font-bold hover:bg-red-700 disabled:opacity-50">
|
||||||
|
{loading ? 'Erstelle...' : 'Session erstellen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reconnect to existing session */}
|
||||||
|
<div className="bg-white rounded-xl shadow p-6 border-t-4 border-amber-500">
|
||||||
|
<h2 className="text-xl font-bold mb-4">🔄 Bestehende Session übernehmen</h2>
|
||||||
|
<p className="text-sm text-gray-500 mb-3">Falls die Verbindung unterbrochen wurde, kannst du dich hier wieder mit einer laufenden Session verbinden.</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<input
|
||||||
|
className="w-full p-3 border rounded-lg focus:border-amber-500 outline-none uppercase tracking-wider text-center text-lg"
|
||||||
|
placeholder="Session-Code eingeben"
|
||||||
|
value={reconnectCode}
|
||||||
|
onChange={(e) => setReconnectCode(e.target.value)}
|
||||||
|
maxLength={20}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && reconnect()}
|
||||||
|
/>
|
||||||
|
{reconnectError && <p className="text-red-500 text-sm bg-red-50 p-2 rounded">{reconnectError}</p>}
|
||||||
|
<button onClick={reconnect} disabled={reconnecting} className="w-full py-3 bg-amber-500 text-white rounded-lg font-bold hover:bg-amber-600 disabled:opacity-50">
|
||||||
|
{reconnecting ? 'Verbinde...' : 'Verbinden'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -321,26 +389,17 @@ function LiveControlPage({ token, code, ws, initialSession }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ws) return;
|
if (!ws) return;
|
||||||
|
|
||||||
// Take over the WS completely with onmessage (no addEventListener confusion)
|
|
||||||
ws.onmessage = (e) => {
|
ws.onmessage = (e) => {
|
||||||
const msg = JSON.parse(e.data);
|
const msg = JSON.parse(e.data);
|
||||||
console.log('[admin-control] Received:', msg.type, msg);
|
|
||||||
|
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'session-created':
|
case 'session-created':
|
||||||
setSession({
|
setSession({
|
||||||
code: msg.code,
|
code: msg.code, setName: msg.setName, questionCount: msg.questionCount,
|
||||||
setName: msg.setName,
|
options: msg.options, players: [], playerCount: 0, presentToken: msg.presentToken,
|
||||||
questionCount: msg.questionCount,
|
|
||||||
options: msg.options,
|
|
||||||
players: [],
|
|
||||||
playerCount: 0,
|
|
||||||
});
|
});
|
||||||
setPhase('waiting');
|
setPhase('waiting');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'admin-joined':
|
case 'admin-joined':
|
||||||
console.log('[admin-control] Session synced:', msg.session.players, 'count:', msg.session.playerCount);
|
|
||||||
setSession(msg.session);
|
setSession(msg.session);
|
||||||
setLeaderboardData(msg.session.leaderboard || []);
|
setLeaderboardData(msg.session.leaderboard || []);
|
||||||
if (msg.session.status === 'waiting') setPhase('waiting');
|
if (msg.session.status === 'waiting') setPhase('waiting');
|
||||||
@@ -348,86 +407,46 @@ function LiveControlPage({ token, code, ws, initialSession }) {
|
|||||||
else if (msg.session.status === 'showing-result') setPhase('result');
|
else if (msg.session.status === 'showing-result') setPhase('result');
|
||||||
else setPhase('question');
|
else setPhase('question');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'player-joined':
|
case 'player-joined':
|
||||||
case 'player-left':
|
case 'player-left':
|
||||||
console.log('[admin-control] Player update:', msg.players, 'count:', msg.playerCount);
|
|
||||||
setSession((s) => {
|
setSession((s) => {
|
||||||
const base = s || { code };
|
const base = s || { code };
|
||||||
return { ...base, players: msg.players, playerCount: msg.playerCount };
|
return { ...base, players: msg.players, playerCount: msg.playerCount };
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
case 'game-start': setPhase('active'); break;
|
||||||
case 'game-start':
|
case 'question': setPhase('question'); setAnswerCount(0); setResult(null); break;
|
||||||
setPhase('active');
|
case 'answer-count': setAnswerCount(msg.count); break;
|
||||||
break;
|
|
||||||
|
|
||||||
case 'question':
|
|
||||||
setPhase('question');
|
|
||||||
setAnswerCount(0);
|
|
||||||
setResult(null);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'answer-count':
|
|
||||||
setAnswerCount(msg.count);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'question-result':
|
case 'question-result':
|
||||||
setResult(msg);
|
setResult(msg); setLeaderboardData(msg.leaderboard); setPhase('result'); break;
|
||||||
setLeaderboardData(msg.leaderboard);
|
|
||||||
setPhase('result');
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'game-end':
|
case 'game-end':
|
||||||
setLeaderboardData(msg.leaderboard);
|
setLeaderboardData(msg.leaderboard); setPhase('ended'); break;
|
||||||
setPhase('ended');
|
case 'error': console.error('Admin error:', msg.message); break;
|
||||||
break;
|
|
||||||
|
|
||||||
case 'error':
|
|
||||||
console.error('[admin-control] Error:', msg.message);
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sync current session state from server
|
|
||||||
console.log('[admin-control] Sending admin-join for code:', code);
|
|
||||||
ws.send(JSON.stringify({ type: 'admin-join', token, code }));
|
ws.send(JSON.stringify({ type: 'admin-join', token, code }));
|
||||||
|
|
||||||
// Safety net: periodically re-sync session state every 5 seconds while waiting
|
|
||||||
refreshRef.current = setInterval(() => {
|
refreshRef.current = setInterval(() => {
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
ws.send(JSON.stringify({ type: 'admin-join', token, code }));
|
ws.send(JSON.stringify({ type: 'admin-join', token, code }));
|
||||||
}
|
}
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|
||||||
return () => {
|
return () => { clearInterval(refreshRef.current); ws.onmessage = null; };
|
||||||
clearInterval(refreshRef.current);
|
|
||||||
ws.onmessage = null;
|
|
||||||
};
|
|
||||||
}, [ws, code, token]);
|
}, [ws, code, token]);
|
||||||
|
|
||||||
// Stop polling once game starts
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (phase !== 'waiting') {
|
if (phase !== 'waiting') clearInterval(refreshRef.current);
|
||||||
clearInterval(refreshRef.current);
|
|
||||||
}
|
|
||||||
}, [phase]);
|
}, [phase]);
|
||||||
|
|
||||||
const start = () => {
|
const start = () => ws.send(JSON.stringify({ type: 'admin-start', token, code }));
|
||||||
console.log('[admin-control] Starting game');
|
const showResultAction = () => ws.send(JSON.stringify({ type: 'admin-show-result', token, code }));
|
||||||
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 showResultAction = () => {
|
|
||||||
ws.send(JSON.stringify({ type: 'admin-show-result', token, code }));
|
|
||||||
};
|
|
||||||
const next = () => {
|
|
||||||
ws.send(JSON.stringify({ type: 'admin-next', token, code }));
|
|
||||||
};
|
|
||||||
const end = () => {
|
|
||||||
ws.send(JSON.stringify({ type: 'admin-end', token, code }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const presentUrl = `${location.origin}/present?token=${encodeURIComponent(token)}#${code}`;
|
const presentToken = session?.presentToken || '';
|
||||||
|
const presentUrl = `${location.origin}/present?pt=${encodeURIComponent(presentToken)}#${code}`;
|
||||||
const playerCount = session?.players?.length || session?.playerCount || 0;
|
const playerCount = session?.players?.length || session?.playerCount || 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ function PresentApp() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const code = location.hash.replace('#', '');
|
const code = location.hash.replace('#', '');
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
const token = params.get('token');
|
// Accept both ?pt= (present token) and ?token= (legacy/admin password)
|
||||||
|
const token = params.get('pt') || params.get('token');
|
||||||
|
|
||||||
if (!code || !token) {
|
if (!code || !token) {
|
||||||
setPhase('need-config');
|
setPhase('need-config');
|
||||||
@@ -176,18 +177,12 @@ function PresentApp() {
|
|||||||
<h2 className="text-3xl font-bold text-center mb-6">{question?.frage}</h2>
|
<h2 className="text-3xl font-bold text-center mb-6">{question?.frage}</h2>
|
||||||
{question?.bild && <img src={question.bild} className="max-h-48 mx-auto rounded-xl mb-6" alt="" />}
|
{question?.bild && <img src={question.bild} className="max-h-48 mx-auto rounded-xl mb-6" alt="" />}
|
||||||
|
|
||||||
{/* Answer options with feedback colors + vote counts */}
|
|
||||||
{question?.typ?.toLowerCase() !== 'freitext' && (
|
{question?.typ?.toLowerCase() !== 'freitext' && (
|
||||||
<div className="grid grid-cols-2 gap-4 mb-8">
|
<div className="grid grid-cols-2 gap-4 mb-8">
|
||||||
{(question?.antworten || []).map((a) => {
|
{(question?.antworten || []).map((a) => {
|
||||||
const isCorrect = correctKeys.has(a.key);
|
const isCorrect = correctKeys.has(a.key);
|
||||||
const count = result?.stats?.[a.key] || 0;
|
const count = result?.stats?.[a.key] || 0;
|
||||||
let cls;
|
const cls = isCorrect ? 'bg-green-600 ring-4 ring-green-400' : 'bg-gray-700 opacity-50';
|
||||||
if (isCorrect) {
|
|
||||||
cls = 'bg-green-600 ring-4 ring-green-400';
|
|
||||||
} else {
|
|
||||||
cls = 'bg-gray-700 opacity-50';
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div key={a.key} className={`${cls} rounded-2xl p-6 text-2xl font-bold flex items-center justify-between`}>
|
<div key={a.key} className={`${cls} rounded-2xl p-6 text-2xl font-bold flex items-center justify-between`}>
|
||||||
<span>{isCorrect ? '✓ ' : ''}{a.text}</span>
|
<span>{isCorrect ? '✓ ' : ''}{a.text}</span>
|
||||||
@@ -206,7 +201,6 @@ function PresentApp() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Leaderboard */}
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="text-2xl font-bold text-center mb-4">🏆 Leaderboard</h3>
|
<h3 className="text-2xl font-bold text-center mb-4">🏆 Leaderboard</h3>
|
||||||
<div className="max-w-xl mx-auto">
|
<div className="max-w-xl mx-auto">
|
||||||
|
|||||||
+6
-2
@@ -14,10 +14,13 @@
|
|||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
background: #111111 url('/img/bg.png') center/cover fixed;
|
background: #111111;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(ellipse at 20% 50%, rgba(220, 38, 38, 0.08) 0%, transparent 50%),
|
||||||
|
radial-gradient(ellipse at 80% 50%, rgba(245, 158, 11, 0.06) 0%, transparent 50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Answer colors for presentation view */
|
||||||
.answer-a {
|
.answer-a {
|
||||||
background: #dc2626;
|
background: #dc2626;
|
||||||
}
|
}
|
||||||
@@ -34,6 +37,7 @@
|
|||||||
background: #16a34a;
|
background: #16a34a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Fire gradient for headings */
|
||||||
.fire-gradient {
|
.fire-gradient {
|
||||||
background: linear-gradient(135deg, #dc2626, #f59e0b);
|
background: linear-gradient(135deg, #dc2626, #f59e0b);
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
|
|||||||
+58
-23
@@ -1,4 +1,5 @@
|
|||||||
const { WebSocket, WebSocketServer } = require('ws');
|
const { WebSocket, WebSocketServer } = require('ws');
|
||||||
|
const crypto = require('crypto');
|
||||||
const { getEnvConfig, readConfig } = require('./config');
|
const { getEnvConfig, readConfig } = require('./config');
|
||||||
const { listRows, batchCreateRows } = require('./baserow');
|
const { listRows, batchCreateRows } = require('./baserow');
|
||||||
const { calculateScore, sanitizeQuestion, parseCorrectAnswers } = require('./quiz-logic');
|
const { calculateScore, sanitizeQuestion, parseCorrectAnswers } = require('./quiz-logic');
|
||||||
@@ -12,6 +13,15 @@ function generateCode() {
|
|||||||
return code;
|
return code;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeCode(input) {
|
||||||
|
// Remove everything except letters and numbers, uppercase
|
||||||
|
return (input || '').replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function generatePresentToken() {
|
||||||
|
return crypto.randomBytes(24).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
function send(ws, data) {
|
function send(ws, data) {
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(data));
|
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(data));
|
||||||
}
|
}
|
||||||
@@ -43,6 +53,7 @@ function sessionInfo(s) {
|
|||||||
code: s.code, setName: s.setName, status: s.status, options: s.options,
|
code: s.code, setName: s.setName, status: s.status, options: s.options,
|
||||||
questionCount: s.questions.length, currentQuestionIndex: s.currentQuestionIndex,
|
questionCount: s.questions.length, currentQuestionIndex: s.currentQuestionIndex,
|
||||||
playerCount: s.players.size, players: playerList(s), leaderboard: leaderboard(s),
|
playerCount: s.players.size, players: playerList(s), leaderboard: leaderboard(s),
|
||||||
|
presentToken: s.presentToken,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +70,11 @@ function isAdmin(token) {
|
|||||||
return token === getEnvConfig().adminPassword;
|
return token === getEnvConfig().adminPassword;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isValidPresentToken(token, code) {
|
||||||
|
const s = sessions.get(code);
|
||||||
|
return s && s.presentToken === token;
|
||||||
|
}
|
||||||
|
|
||||||
function setupWebSocket(server) {
|
function setupWebSocket(server) {
|
||||||
const wss = new WebSocketServer({ server });
|
const wss = new WebSocketServer({ server });
|
||||||
wss.on('connection', (ws) => {
|
wss.on('connection', (ws) => {
|
||||||
@@ -114,7 +130,21 @@ async function adminCreate(ws, msg) {
|
|||||||
};
|
};
|
||||||
if (opts.shuffle) questions = shuffle(questions);
|
if (opts.shuffle) questions = shuffle(questions);
|
||||||
|
|
||||||
const code = generateCode();
|
// Custom or auto-generated code
|
||||||
|
let code;
|
||||||
|
if (msg.customCode) {
|
||||||
|
code = sanitizeCode(msg.customCode);
|
||||||
|
if (code.length < 3) return send(ws, { type: 'error', message: 'Code muss mindestens 3 Zeichen lang sein' });
|
||||||
|
if (code.length > 20) return send(ws, { type: 'error', message: 'Code darf maximal 20 Zeichen lang sein' });
|
||||||
|
if (sessions.has(code)) return send(ws, { type: 'error', message: `Code "${code}" ist bereits vergeben` });
|
||||||
|
} else {
|
||||||
|
code = generateCode();
|
||||||
|
// Ensure uniqueness
|
||||||
|
while (sessions.has(code)) code = generateCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
const presentToken = generatePresentToken();
|
||||||
|
|
||||||
const session = {
|
const session = {
|
||||||
code, sessionId: 'LIVE-' + Date.now().toString(36).toUpperCase(),
|
code, sessionId: 'LIVE-' + Date.now().toString(36).toUpperCase(),
|
||||||
setId: set.id, setName: set.name, tableId: set.tableId, options: opts,
|
setId: set.id, setName: set.name, tableId: set.tableId, options: opts,
|
||||||
@@ -122,34 +152,44 @@ async function adminCreate(ws, msg) {
|
|||||||
players: new Map(), admins: new Set([ws]), presents: new Set(),
|
players: new Map(), admins: new Set([ws]), presents: new Set(),
|
||||||
currentAnswers: new Map(), questionStartTime: null, timer: null,
|
currentAnswers: new Map(), questionStartTime: null, timer: null,
|
||||||
currentSanitized: null, results: [],
|
currentSanitized: null, results: [],
|
||||||
|
presentToken,
|
||||||
};
|
};
|
||||||
|
|
||||||
sessions.set(code, session);
|
sessions.set(code, session);
|
||||||
ws._qa = { code, role: 'admin' };
|
ws._qa = { code, role: 'admin' };
|
||||||
send(ws, { type: 'session-created', code, setName: set.name, questionCount: questions.length, options: opts });
|
console.log(`[live] Session created: ${code} (${set.name}, ${questions.length} questions)`);
|
||||||
|
send(ws, {
|
||||||
|
type: 'session-created', code, setName: set.name,
|
||||||
|
questionCount: questions.length, options: opts,
|
||||||
|
presentToken,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function adminJoin(ws, msg) {
|
function adminJoin(ws, msg) {
|
||||||
if (!isAdmin(msg.token)) return send(ws, { type: 'error', message: 'Nicht autorisiert' });
|
if (!isAdmin(msg.token)) return send(ws, { type: 'error', message: 'Nicht autorisiert' });
|
||||||
const s = sessions.get(msg.code);
|
const code = sanitizeCode(msg.code);
|
||||||
if (!s) return send(ws, { type: 'error', message: 'Session nicht gefunden' });
|
const s = sessions.get(code);
|
||||||
|
if (!s) return send(ws, { type: 'error', message: `Session "${code}" nicht gefunden` });
|
||||||
s.admins.add(ws);
|
s.admins.add(ws);
|
||||||
ws._qa = { code: msg.code, role: 'admin' };
|
ws._qa = { code, role: 'admin' };
|
||||||
send(ws, { type: 'admin-joined', session: sessionInfo(s) });
|
send(ws, { type: 'admin-joined', session: sessionInfo(s) });
|
||||||
}
|
}
|
||||||
|
|
||||||
function presentJoin(ws, msg) {
|
function presentJoin(ws, msg) {
|
||||||
if (!isAdmin(msg.token)) return send(ws, { type: 'error', message: 'Nicht autorisiert' });
|
const code = sanitizeCode(msg.code);
|
||||||
const s = sessions.get(msg.code);
|
if (!isAdmin(msg.token) && !isValidPresentToken(msg.token, code)) {
|
||||||
|
return send(ws, { type: 'error', message: 'Nicht autorisiert' });
|
||||||
|
}
|
||||||
|
const s = sessions.get(code);
|
||||||
if (!s) return send(ws, { type: 'error', message: 'Session nicht gefunden' });
|
if (!s) return send(ws, { type: 'error', message: 'Session nicht gefunden' });
|
||||||
s.presents.add(ws);
|
s.presents.add(ws);
|
||||||
ws._qa = { code: msg.code, role: 'present' };
|
ws._qa = { code, role: 'present' };
|
||||||
send(ws, { type: 'present-joined', session: sessionInfo(s) });
|
send(ws, { type: 'present-joined', session: sessionInfo(s) });
|
||||||
}
|
}
|
||||||
|
|
||||||
function adminStart(ws, msg) {
|
function adminStart(ws, msg) {
|
||||||
if (!isAdmin(msg.token)) return;
|
if (!isAdmin(msg.token)) return;
|
||||||
const s = sessions.get(msg.code);
|
const s = sessions.get(sanitizeCode(msg.code));
|
||||||
if (!s || s.status !== 'waiting') return;
|
if (!s || s.status !== 'waiting') return;
|
||||||
s.status = 'active';
|
s.status = 'active';
|
||||||
broadcastAll(s, { type: 'game-start', totalQuestions: s.questions.length });
|
broadcastAll(s, { type: 'game-start', totalQuestions: s.questions.length });
|
||||||
@@ -158,21 +198,21 @@ function adminStart(ws, msg) {
|
|||||||
|
|
||||||
function adminShowResult(ws, msg) {
|
function adminShowResult(ws, msg) {
|
||||||
if (!isAdmin(msg.token)) return;
|
if (!isAdmin(msg.token)) return;
|
||||||
const s = sessions.get(msg.code);
|
const s = sessions.get(sanitizeCode(msg.code));
|
||||||
if (!s || s.status !== 'active') return;
|
if (!s || s.status !== 'active') return;
|
||||||
showResult(s);
|
showResult(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
function adminNext(ws, msg) {
|
function adminNext(ws, msg) {
|
||||||
if (!isAdmin(msg.token)) return;
|
if (!isAdmin(msg.token)) return;
|
||||||
const s = sessions.get(msg.code);
|
const s = sessions.get(sanitizeCode(msg.code));
|
||||||
if (!s || s.status !== 'showing-result') return;
|
if (!s || s.status !== 'showing-result') return;
|
||||||
sendNextQuestion(s);
|
sendNextQuestion(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function adminEnd(ws, msg) {
|
async function adminEnd(ws, msg) {
|
||||||
if (!isAdmin(msg.token)) return;
|
if (!isAdmin(msg.token)) return;
|
||||||
const s = sessions.get(msg.code);
|
const s = sessions.get(sanitizeCode(msg.code));
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
await endSession(s);
|
await endSession(s);
|
||||||
}
|
}
|
||||||
@@ -180,29 +220,27 @@ async function adminEnd(ws, msg) {
|
|||||||
/* ---- Player ---- */
|
/* ---- Player ---- */
|
||||||
|
|
||||||
function playerJoin(ws, msg) {
|
function playerJoin(ws, msg) {
|
||||||
const s = sessions.get(msg.code);
|
const code = sanitizeCode(msg.code);
|
||||||
if (!s) return send(ws, { type: 'error', message: 'Session nicht gefunden.' });
|
const s = sessions.get(code);
|
||||||
|
if (!s) return send(ws, { type: 'error', message: 'Session nicht gefunden. Pruefe den Code.' });
|
||||||
|
|
||||||
const name = (msg.name || '').trim();
|
const name = (msg.name || '').trim();
|
||||||
if (!name) return send(ws, { type: 'error', message: 'Name darf nicht leer sein' });
|
if (!name) return send(ws, { type: 'error', message: 'Name darf nicht leer sein' });
|
||||||
|
|
||||||
// --- Reconnect existing player ---
|
|
||||||
if (s.players.has(name)) {
|
if (s.players.has(name)) {
|
||||||
const existing = s.players.get(name);
|
const existing = s.players.get(name);
|
||||||
if (existing.ws && existing.ws.readyState === WebSocket.OPEN) {
|
if (existing.ws && existing.ws.readyState === WebSocket.OPEN) {
|
||||||
return send(ws, { type: 'error', message: 'Name bereits online. Bitte waehle einen anderen.' });
|
return send(ws, { type: 'error', message: 'Name bereits online. Bitte waehle einen anderen.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reconnect
|
|
||||||
existing.ws = ws;
|
existing.ws = ws;
|
||||||
ws._qa = { code: msg.code, role: 'player', name };
|
ws._qa = { code, role: 'player', name };
|
||||||
|
|
||||||
send(ws, {
|
send(ws, {
|
||||||
type: 'joined',
|
type: 'joined',
|
||||||
session: { code: s.code, setName: s.setName, playerCount: s.players.size, players: playerList(s) },
|
session: { code: s.code, setName: s.setName, playerCount: s.players.size, players: playerList(s) },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Catch up to current game state
|
|
||||||
if (s.status === 'active' && s.currentSanitized) {
|
if (s.status === 'active' && s.currentSanitized) {
|
||||||
const tl = s.options.scoring === 'time' ? s.options.timeLimit : 0;
|
const tl = s.options.scoring === 'time' ? s.options.timeLimit : 0;
|
||||||
send(ws, { type: 'game-start', totalQuestions: s.questions.length });
|
send(ws, { type: 'game-start', totalQuestions: s.questions.length });
|
||||||
@@ -215,7 +253,6 @@ function playerJoin(ws, msg) {
|
|||||||
}
|
}
|
||||||
} else if (s.status === 'showing-result') {
|
} else if (s.status === 'showing-result') {
|
||||||
send(ws, { type: 'game-start', totalQuestions: s.questions.length });
|
send(ws, { type: 'game-start', totalQuestions: s.questions.length });
|
||||||
// They'll see the next question when master clicks next
|
|
||||||
} else if (s.status === 'ended') {
|
} else if (s.status === 'ended') {
|
||||||
send(ws, { type: 'game-end', leaderboard: leaderboard(s), sessionId: s.sessionId });
|
send(ws, { type: 'game-end', leaderboard: leaderboard(s), sessionId: s.sessionId });
|
||||||
}
|
}
|
||||||
@@ -224,13 +261,12 @@ function playerJoin(ws, msg) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- New player: only allowed in waiting room ---
|
|
||||||
if (s.status !== 'waiting') {
|
if (s.status !== 'waiting') {
|
||||||
return send(ws, { type: 'error', message: 'Session laeuft bereits. Nur bestehende Teilnehmer koennen wieder beitreten.' });
|
return send(ws, { type: 'error', message: 'Session laeuft bereits. Nur bestehende Teilnehmer koennen wieder beitreten.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
s.players.set(name, { ws, score: 0, answers: [] });
|
s.players.set(name, { ws, score: 0, answers: [] });
|
||||||
ws._qa = { code: msg.code, role: 'player', name };
|
ws._qa = { code, role: 'player', name };
|
||||||
|
|
||||||
send(ws, {
|
send(ws, {
|
||||||
type: 'joined',
|
type: 'joined',
|
||||||
@@ -271,7 +307,7 @@ function sendNextQuestion(s) {
|
|||||||
const q = s.questions[s.currentQuestionIndex];
|
const q = s.questions[s.currentQuestionIndex];
|
||||||
const tl = s.options.scoring === 'time' ? s.options.timeLimit : 0;
|
const tl = s.options.scoring === 'time' ? s.options.timeLimit : 0;
|
||||||
const sanitized = sanitizeQuestion(q);
|
const sanitized = sanitizeQuestion(q);
|
||||||
s.currentSanitized = sanitized; // Store for reconnecting players
|
s.currentSanitized = sanitized;
|
||||||
|
|
||||||
broadcastAll(s, {
|
broadcastAll(s, {
|
||||||
type: 'question', index: s.currentQuestionIndex, total: s.questions.length,
|
type: 'question', index: s.currentQuestionIndex, total: s.questions.length,
|
||||||
@@ -361,7 +397,6 @@ function handleDisconnect(ws) {
|
|||||||
if (!s) return;
|
if (!s) return;
|
||||||
if (qa.role === 'admin') s.admins.delete(ws);
|
if (qa.role === 'admin') s.admins.delete(ws);
|
||||||
else if (qa.role === 'present') s.presents.delete(ws);
|
else if (qa.role === 'present') s.presents.delete(ws);
|
||||||
// Players stay in the session (they can reconnect)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSessions() {
|
function getSessions() {
|
||||||
|
|||||||
Reference in New Issue
Block a user