Admin panel sync improvements, Baserow field handling (Single Select support), and UI emoji swap
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 152 KiB |
+63
-23
@@ -35,7 +35,7 @@ function LoginPage({ onLogin }) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-stone-100">
|
||||
<div className="bg-white rounded-2xl shadow-lg p-8 w-full max-w-sm border-t-4 border-red-600">
|
||||
<img src="/img/logo.png" alt="Quizalarm" className="w-24 h-24 mb-2" />
|
||||
<div className="text-center text-4xl mb-2">🚨</div>
|
||||
<h1 className="text-2xl font-bold mb-6 text-center">Quizalarm Admin</h1>
|
||||
<input type="password" className="w-full p-3 border rounded-lg mb-3 text-center focus:border-red-500 outline-none" 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>}
|
||||
@@ -250,7 +250,14 @@ function LiveSetupPage({ config, token, onSessionCreated }) {
|
||||
ws.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'session-created') {
|
||||
onSessionCreated(msg.code, ws);
|
||||
onSessionCreated(msg.code, ws, {
|
||||
code: msg.code,
|
||||
setName: msg.setName,
|
||||
questionCount: msg.questionCount,
|
||||
options: msg.options,
|
||||
players: [],
|
||||
playerCount: 0,
|
||||
});
|
||||
} else if (msg.type === 'error') {
|
||||
setError(msg.message);
|
||||
setLoading(false);
|
||||
@@ -298,11 +305,11 @@ function LiveSetupPage({ config, token, onSessionCreated }) {
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* Live Control Page */
|
||||
/* Live Control Page (FIX: sends admin-join on mount) */
|
||||
/* ================================================================== */
|
||||
|
||||
function LiveControlPage({ token, code, ws }) {
|
||||
const [session, setSession] = useState(null);
|
||||
function LiveControlPage({ token, code, ws, initialSession }) {
|
||||
const [session, setSession] = useState(initialSession || null);
|
||||
const [phase, setPhase] = useState('waiting');
|
||||
const [answerCount, setAnswerCount] = useState(0);
|
||||
const [result, setResult] = useState(null);
|
||||
@@ -313,20 +320,26 @@ function LiveControlPage({ token, code, ws }) {
|
||||
|
||||
const handler = (e) => {
|
||||
const msg = JSON.parse(e.data);
|
||||
console.log('[admin-control] Received:', msg.type);
|
||||
switch (msg.type) {
|
||||
case 'session-created':
|
||||
setSession({ code: msg.code, setName: msg.setName, questionCount: msg.questionCount, options: msg.options, players: [] });
|
||||
setSession({ code: msg.code, setName: msg.setName, questionCount: msg.questionCount, options: msg.options, players: [], playerCount: 0 });
|
||||
setPhase('waiting');
|
||||
break;
|
||||
case 'admin-joined':
|
||||
setSession(msg.session);
|
||||
setLeaderboardData(msg.session.leaderboard || []);
|
||||
if (msg.session.status === 'waiting') setPhase('waiting');
|
||||
else if (msg.session.status === 'ended') setPhase('ended');
|
||||
else setPhase('active');
|
||||
setLeaderboardData(msg.session.leaderboard);
|
||||
else if (msg.session.status === 'showing-result') setPhase('result');
|
||||
else setPhase('question');
|
||||
break;
|
||||
case 'player-joined':
|
||||
case 'player-left':
|
||||
setSession((s) => s ? { ...s, players: msg.players, playerCount: msg.playerCount } : s);
|
||||
setSession((s) => {
|
||||
if (!s) return { code, players: msg.players, playerCount: msg.playerCount };
|
||||
return { ...s, players: msg.players, playerCount: msg.playerCount };
|
||||
});
|
||||
break;
|
||||
case 'game-start':
|
||||
setPhase('active');
|
||||
@@ -355,42 +368,67 @@ function LiveControlPage({ token, code, ws }) {
|
||||
};
|
||||
|
||||
ws.addEventListener('message', handler);
|
||||
return () => ws.removeEventListener('message', handler);
|
||||
}, [ws]);
|
||||
|
||||
const start = () => ws.send(JSON.stringify({ type: 'admin-start', token, code }));
|
||||
const showResultAction = () => ws.send(JSON.stringify({ type: 'admin-show-result', token, code }));
|
||||
const next = () => ws.send(JSON.stringify({ type: 'admin-next', token, code }));
|
||||
const end = () => ws.send(JSON.stringify({ type: 'admin-end', token, code }));
|
||||
// Request current session state from server to sync up
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'admin-join', token, code }));
|
||||
} else {
|
||||
const onOpen = () => {
|
||||
ws.send(JSON.stringify({ type: 'admin-join', token, code }));
|
||||
ws.removeEventListener('open', onOpen);
|
||||
};
|
||||
ws.addEventListener('open', onOpen);
|
||||
}
|
||||
|
||||
return () => ws.removeEventListener('message', handler);
|
||||
}, [ws, code, token]);
|
||||
|
||||
const start = () => {
|
||||
console.log('[admin-control] Sending admin-start');
|
||||
ws.send(JSON.stringify({ type: 'admin-start', token, code }));
|
||||
};
|
||||
const showResultAction = () => {
|
||||
console.log('[admin-control] Sending admin-show-result');
|
||||
ws.send(JSON.stringify({ type: 'admin-show-result', token, code }));
|
||||
};
|
||||
const next = () => {
|
||||
console.log('[admin-control] Sending admin-next');
|
||||
ws.send(JSON.stringify({ type: 'admin-next', token, code }));
|
||||
};
|
||||
const end = () => {
|
||||
console.log('[admin-control] Sending admin-end');
|
||||
ws.send(JSON.stringify({ type: 'admin-end', token, code }));
|
||||
};
|
||||
|
||||
const presentUrl = `${location.origin}/present?token=${encodeURIComponent(token)}#${code}`;
|
||||
const playerCount = session?.players?.length || session?.playerCount || 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-lg mx-auto">
|
||||
<div className="bg-white rounded-xl shadow p-6 text-center border-t-4 border-red-600">
|
||||
<h2 className="text-lg font-bold mb-2">Session-Code</h2>
|
||||
<div className="text-5xl font-mono font-extrabold tracking-widest text-red-600 mb-2">{code}</div>
|
||||
<p className="text-gray-400 text-sm">{session?.setName} — {session?.questionCount || '?'} Fragen</p>
|
||||
<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-red-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>
|
||||
<h3 className="font-bold mb-2">Teilnehmer ({playerCount})</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(session?.players || []).map((n) => <span key={n} className="px-3 py-1 bg-red-50 text-red-700 rounded-full text-sm font-medium">{n}</span>)}
|
||||
</div>
|
||||
{(!session?.players || session.players.length === 0) && <p className="text-gray-400 text-sm">Noch keine Teilnehmer...</p>}
|
||||
{playerCount === 0 && <p className="text-gray-400 text-sm">Noch keine Teilnehmer...</p>}
|
||||
</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 onClick={start} disabled={playerCount === 0} 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 ({playerCount} Teilnehmer)
|
||||
</button>
|
||||
)}
|
||||
|
||||
{phase === 'question' && (
|
||||
<div className="bg-white rounded-xl shadow p-6 text-center">
|
||||
<p className="text-lg mb-3">Antworten: <strong className="text-red-600">{answerCount}</strong> / {session?.players?.length || 0}</p>
|
||||
<p className="text-lg mb-3">Antworten: <strong className="text-red-600">{answerCount}</strong> / {playerCount}</p>
|
||||
<button onClick={showResultAction} className="w-full py-3 bg-amber-500 text-white rounded-xl font-bold hover:bg-amber-600">Ergebnis zeigen ➜</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -448,6 +486,7 @@ function AdminApp() {
|
||||
const [config, setConfig] = useState({ sets: [], answersTableId: null });
|
||||
const [liveCode, setLiveCode] = useState(null);
|
||||
const [liveWs, setLiveWs] = useState(null);
|
||||
const [liveInitialSession, setLiveInitialSession] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
@@ -460,9 +499,10 @@ function AdminApp() {
|
||||
try { await api.post('/api/admin/config', c); } catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
const handleSessionCreated = (code, ws) => {
|
||||
const handleSessionCreated = (code, ws, sessionData) => {
|
||||
setLiveCode(code);
|
||||
setLiveWs(ws);
|
||||
setLiveInitialSession(sessionData);
|
||||
setTab('live-control');
|
||||
};
|
||||
|
||||
@@ -491,7 +531,7 @@ function AdminApp() {
|
||||
<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 === 'live-control' && <LiveControlPage token={token} code={liveCode} ws={liveWs} initialSession={liveInitialSession} />}
|
||||
{tab === 'stats' && <StatsPage />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+27
-13
@@ -26,6 +26,23 @@ function shuffleArray(arr) {
|
||||
return a;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract value from Baserow Single Select field.
|
||||
* Can be { id: 6, value: "MC", color: "..." } or plain string or null.
|
||||
*/
|
||||
function fieldVal(field, fallback) {
|
||||
if (!field) return fallback || '';
|
||||
if (typeof field === 'string') return field;
|
||||
if (typeof field === 'object' && field.value !== undefined) return field.value;
|
||||
return String(field);
|
||||
}
|
||||
|
||||
function normalizeText(t) {
|
||||
return (t || '').toLowerCase()
|
||||
.replace(/[äÄ]/g, 'ae').replace(/[öÖ]/g, 'oe').replace(/[üÜ]/g, 'ue').replace(/ß/g, 'ss')
|
||||
.replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* Shared Components */
|
||||
/* ================================================================== */
|
||||
@@ -123,7 +140,7 @@ function AnswerGrid({ question, selected, onToggle, disabled }) {
|
||||
function HomePage({ navigate }) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center p-6">
|
||||
<img src="/img/logo.png" alt="Quizalarm" className="w-24 h-24 mb-2" />
|
||||
<div className="text-6xl mb-2">🚨</div>
|
||||
<h1 className="text-5xl font-extrabold mb-2 fire-gradient">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">
|
||||
@@ -402,6 +419,7 @@ function SoloQuizPage({ params, navigate }) {
|
||||
|
||||
useEffect(() => {
|
||||
api.get(`/api/sets/${params.setId}/questions`).then((qs) => {
|
||||
console.log(`Loaded ${qs.length} questions, sample Typ:`, qs[0]?.['Typ']);
|
||||
if (params.shuffle) {
|
||||
for (let i = qs.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
@@ -412,7 +430,6 @@ function SoloQuizPage({ params, navigate }) {
|
||||
}).catch((e) => setError(e.message));
|
||||
}, []);
|
||||
|
||||
// Shuffle answers when question changes
|
||||
useEffect(() => {
|
||||
if (questions.length === 0 || index >= questions.length) return;
|
||||
const q = questions[index];
|
||||
@@ -427,16 +444,14 @@ function SoloQuizPage({ params, navigate }) {
|
||||
if (!questions.length) return <div className="min-h-screen flex items-center justify-center animate-pulse text-xl text-amber-400">Lade Fragen...</div>;
|
||||
|
||||
const q = questions[index];
|
||||
const typ = (q['Typ'] || 'MC').toLowerCase();
|
||||
const typ = fieldVal(q['Typ'], 'MC').toLowerCase();
|
||||
const sanitized = {
|
||||
frage: q['Frage'] || q['frage'] || '(Kein Fragetext)',
|
||||
typ: q['Typ'] || q['typ'] || 'MC',
|
||||
frage: q['Frage'] || '(Kein Fragetext)',
|
||||
typ: fieldVal(q['Typ'], 'MC'),
|
||||
bild: q['Bild'] && Array.isArray(q['Bild']) && q['Bild'].length ? q['Bild'][0].url : null,
|
||||
antworten: shuffledAnswers,
|
||||
};
|
||||
|
||||
const normalizeText = (t) => (t || '').toLowerCase().replace(/[äÄ]/g, 'ae').replace(/[öÖ]/g, 'oe').replace(/[üÜ]/g, 'ue').replace(/ß/g, 'ss').replace(/[^a-z0-9]/g, '');
|
||||
|
||||
const checkAnswer = () => {
|
||||
const rawCorrect = (q['Richtige Antwort'] || '').trim();
|
||||
let correct, ok;
|
||||
@@ -520,11 +535,10 @@ function SoloResultPage({ params, navigate }) {
|
||||
const byCategory = {};
|
||||
results.forEach((r, i) => {
|
||||
const q = questions[i];
|
||||
const cat = (q && (q['Kategorie'] || q['kategorie'])) || 'Ohne Kategorie';
|
||||
const catName = typeof cat === 'object' && cat.value ? cat.value : cat;
|
||||
if (!byCategory[catName]) byCategory[catName] = { total: 0, correct: 0 };
|
||||
byCategory[catName].total++;
|
||||
if (r.Richtig) byCategory[catName].correct++;
|
||||
const cat = fieldVal(q?.['Kategorie'], 'Ohne Kategorie') || 'Ohne Kategorie';
|
||||
if (!byCategory[cat]) byCategory[cat] = { total: 0, correct: 0 };
|
||||
byCategory[cat].total++;
|
||||
if (r.Richtig) byCategory[cat].correct++;
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -573,7 +587,7 @@ function MyResultsPage({ navigate }) {
|
||||
if (results) {
|
||||
results.forEach((r) => {
|
||||
const sid = r['Session_ID'] || 'Unbekannt';
|
||||
if (!sessions[sid]) sessions[sid] = { modus: r['Modus'], fragenset: r['Fragenset'], date: r['Zeitstempel'], answers: [] };
|
||||
if (!sessions[sid]) sessions[sid] = { modus: fieldVal(r['Modus'], ''), fragenset: r['Fragenset'], date: r['Zeitstempel'], answers: [] };
|
||||
sessions[sid].answers.push(r);
|
||||
});
|
||||
}
|
||||
|
||||
+21
-7
@@ -14,13 +14,28 @@ function normalizeText(text) {
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the string value from a Baserow Single Select field.
|
||||
* Baserow returns { id: 6, value: "MC", color: "..." } for Single Select.
|
||||
*/
|
||||
function extractFieldValue(field, fallback) {
|
||||
if (!field) return fallback || '';
|
||||
if (typeof field === 'string') return field;
|
||||
if (typeof field === 'object' && field.value !== undefined) return field.value;
|
||||
return String(field);
|
||||
}
|
||||
|
||||
function getTyp(question) {
|
||||
return extractFieldValue(question['Typ'], 'MC').toLowerCase();
|
||||
}
|
||||
|
||||
function checkFreitext(userAnswer, correctAnswer) {
|
||||
return normalizeText(userAnswer) === normalizeText(correctAnswer);
|
||||
}
|
||||
|
||||
function parseCorrectAnswers(question) {
|
||||
const raw = (question['Richtige Antwort'] || '').trim();
|
||||
const typ = (question['Typ'] || 'MC').toLowerCase();
|
||||
const typ = getTyp(question);
|
||||
if (typ === 'freitext') return [raw];
|
||||
return raw
|
||||
.split(',')
|
||||
@@ -60,7 +75,7 @@ function calculateScore(question, selected, timeRemaining, totalTime, scoringMod
|
||||
basis = 1000;
|
||||
}
|
||||
|
||||
const typ = (question['Typ'] || 'MC').toLowerCase();
|
||||
const typ = getTyp(question);
|
||||
const correctAnswers = parseCorrectAnswers(question);
|
||||
|
||||
if (typ === 'freitext') {
|
||||
@@ -93,14 +108,14 @@ function calculateScore(question, selected, timeRemaining, totalTime, scoringMod
|
||||
* Strip the correct answer and shuffle options before sending to participants.
|
||||
*/
|
||||
function sanitizeQuestion(question) {
|
||||
const typ = (question['Typ'] || 'MC').toLowerCase();
|
||||
const typ = getTyp(question);
|
||||
const sanitized = {
|
||||
id: question.id,
|
||||
frage: question['Frage'] || '',
|
||||
typ: question['Typ'] || 'MC',
|
||||
typ: extractFieldValue(question['Typ'], 'MC'),
|
||||
bild: null,
|
||||
antworten: [],
|
||||
kategorie: question['Kategorie'] || '',
|
||||
kategorie: extractFieldValue(question['Kategorie'], ''),
|
||||
};
|
||||
|
||||
const bild = question['Bild'];
|
||||
@@ -114,11 +129,10 @@ function sanitizeQuestion(question) {
|
||||
const val = question[`Antwort ${k}`];
|
||||
if (val) sanitized.antworten.push({ key: k, text: val });
|
||||
}
|
||||
// Shuffle answer order
|
||||
sanitized.antworten = shuffleArray(sanitized.antworten);
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
module.exports = { calculateScore, sanitizeQuestion, parseCorrectAnswers, normalizeText, shuffleArray };
|
||||
module.exports = { calculateScore, sanitizeQuestion, parseCorrectAnswers, normalizeText, shuffleArray, extractFieldValue };
|
||||
Reference in New Issue
Block a user