Massive feature update: Solo session recovery, immediate result saving, image proxy, player reconnection (Live), and UI/UX refinements
This commit is contained in:
+243
-174
@@ -13,23 +13,14 @@ const api = {
|
||||
},
|
||||
};
|
||||
|
||||
function wsUrl() {
|
||||
return `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}`;
|
||||
}
|
||||
function wsUrl() { return `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}`; }
|
||||
|
||||
function shuffleArray(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]];
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
@@ -51,7 +42,7 @@ function ImageModal({ src, onClose }) {
|
||||
if (!src) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-50 p-4" onClick={onClose}>
|
||||
<img src={src} className="max-w-full max-h-full object-contain rounded-lg" alt="Vergroesserung" />
|
||||
<img src={src} className="max-w-full max-h-full object-contain rounded-lg" alt="" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -88,44 +79,53 @@ function Leaderboard({ entries, highlight, compact }) {
|
||||
|
||||
const ANSWER_COLORS = { A: 'answer-a', B: 'answer-b', C: 'answer-c', D: 'answer-d' };
|
||||
|
||||
function AnswerGrid({ question, selected, onToggle, disabled }) {
|
||||
function AnswerGrid({ question, selected, onToggle, disabled, feedback }) {
|
||||
const typ = (question.typ || 'MC').toLowerCase();
|
||||
const isWF = typ === 'wahr/falsch';
|
||||
|
||||
if (typ === 'freitext') {
|
||||
if (feedback) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className={`p-4 rounded-xl text-center text-xl font-bold text-white ${feedback.isCorrect ? 'bg-green-600 ring-4 ring-green-400' : 'bg-red-700 ring-4 ring-red-400'}`}>
|
||||
{feedback.isCorrect ? '✓ ' : '✗ '}{selected[0] || '(keine Antwort)'}
|
||||
</div>
|
||||
{!feedback.isCorrect && feedback.correctText && (
|
||||
<div className="p-4 rounded-xl text-center text-xl font-bold bg-green-600 text-white ring-4 ring-green-400">✓ {feedback.correctText}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
className="w-full p-4 rounded-xl bg-gray-700 text-white text-xl text-center border-2 border-gray-600 focus:border-red-500 outline-none"
|
||||
placeholder="Deine Antwort..."
|
||||
value={selected[0] || ''}
|
||||
onChange={(e) => onToggle([e.target.value])}
|
||||
disabled={disabled}
|
||||
autoFocus
|
||||
/>
|
||||
<input type="text" className="w-full p-4 rounded-xl bg-gray-700 text-white text-xl text-center border-2 border-gray-600 focus:border-red-500 outline-none"
|
||||
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`;
|
||||
const isSelected = selected.includes(a.key);
|
||||
let cls, icon = '';
|
||||
|
||||
if (feedback) {
|
||||
const isCorrectAnswer = feedback.correctKeys.includes(a.key);
|
||||
if (isCorrectAnswer) { cls = 'bg-green-600 text-white ring-4 ring-green-400'; icon = '✓ '; }
|
||||
else if (isSelected) { cls = 'bg-red-700 text-white ring-4 ring-red-400'; icon = '✗ '; }
|
||||
else { cls = 'bg-gray-700 text-gray-500 opacity-40'; }
|
||||
cls += ' font-bold py-4 px-6 rounded-xl text-lg text-center cursor-default';
|
||||
} else {
|
||||
cls = `${ANSWER_COLORS[a.key]} ${isSelected ? 'selected' : ''} ${disabled ? 'disabled' : ''} text-white font-bold py-4 px-6 rounded-xl text-lg cursor-pointer transition-all text-center`;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={a.key}
|
||||
className={cls}
|
||||
disabled={disabled}
|
||||
<button key={a.key} className={cls} disabled={disabled || !!feedback}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
if (isWF) {
|
||||
onToggle([a.key]);
|
||||
} else {
|
||||
onToggle(active ? selected.filter((s) => s !== a.key) : [...selected, a.key]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{a.text}
|
||||
if (disabled || feedback) return;
|
||||
if (isWF) onToggle([a.key]);
|
||||
else onToggle(isSelected ? selected.filter((s) => s !== a.key) : [...selected, a.key]);
|
||||
}}>
|
||||
{icon}{a.text}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -133,6 +133,14 @@ function AnswerGrid({ question, selected, onToggle, disabled }) {
|
||||
);
|
||||
}
|
||||
|
||||
function BackButton({ onClick, label }) {
|
||||
return (
|
||||
<button onClick={onClick} className="text-gray-400 hover:text-white text-sm flex items-center gap-1">
|
||||
<span>←</span> <span>{label || 'Beenden'}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* Home Page */
|
||||
/* ================================================================== */
|
||||
@@ -161,12 +169,10 @@ 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>
|
||||
@@ -207,66 +213,28 @@ function LivePlayPage({ params, navigate }) {
|
||||
useEffect(() => {
|
||||
const ws = new WebSocket(wsUrl());
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({ type: 'join', code: params.code, name: params.name }));
|
||||
};
|
||||
|
||||
ws.onopen = () => ws.send(JSON.stringify({ type: 'join', code: params.code, name: params.name }));
|
||||
ws.onmessage = (e) => {
|
||||
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 '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;
|
||||
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;
|
||||
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;
|
||||
setLeaderboardData(msg.leaderboard); setPhase('final'); clearInterval(timerRef.current); break;
|
||||
case 'error': setError(msg.message); setPhase('error'); break;
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (phaseRef.current !== 'final' && phaseRef.current !== 'error') setPhase('disconnected');
|
||||
};
|
||||
|
||||
ws.onclose = () => { if (phaseRef.current !== 'final' && phaseRef.current !== 'error') setPhase('disconnected'); };
|
||||
return () => { clearInterval(timerRef.current); ws.close(); };
|
||||
}, []);
|
||||
|
||||
@@ -275,8 +243,7 @@ function LivePlayPage({ params, navigate }) {
|
||||
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);
|
||||
const left = Math.max(0, timeLimit - (Date.now() - start) / 1000);
|
||||
setTimeLeft(left);
|
||||
if (left <= 0) clearInterval(timerRef.current);
|
||||
}, 100);
|
||||
@@ -289,6 +256,8 @@ function LivePlayPage({ params, navigate }) {
|
||||
setAnswered(true);
|
||||
};
|
||||
|
||||
const leave = () => { if (wsRef.current) wsRef.current.close(); navigate('home'); };
|
||||
|
||||
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>
|
||||
@@ -298,8 +267,9 @@ function LivePlayPage({ params, navigate }) {
|
||||
|
||||
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>
|
||||
<p className="text-yellow-400 text-xl mb-4">Verbindung verloren</p>
|
||||
<p className="text-gray-400 mb-4">Du kannst mit demselben Namen wieder beitreten.</p>
|
||||
<button onClick={() => navigate('join')} className="py-3 px-6 bg-red-600 rounded-xl font-bold">Erneut beitreten</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -307,6 +277,7 @@ function LivePlayPage({ params, navigate }) {
|
||||
|
||||
if (phase === 'waiting') return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center p-6">
|
||||
<div className="self-start mb-4"><BackButton onClick={leave} label="Verlassen" /></div>
|
||||
<h2 className="text-3xl font-bold mb-2">⏳ Warteraum</h2>
|
||||
<p className="text-gray-400 mb-4">{session?.setName}</p>
|
||||
<p className="text-lg mb-4">{session?.playerCount || 0} Teilnehmer</p>
|
||||
@@ -321,33 +292,42 @@ function LivePlayPage({ params, navigate }) {
|
||||
|
||||
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>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<BackButton onClick={leave} label="Verlassen" />
|
||||
<span className="text-sm text-gray-400">Frage {qIndex + 1} / {qTotal}</span>
|
||||
</div>
|
||||
{timeLimit > 0 && <Timer timeLeft={timeLeft} total={timeLimit} />}
|
||||
<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-red-600 hover:bg-red-700 disabled:opacity-40 rounded-xl text-lg font-bold w-full shadow-lg shadow-red-900/30">Antwort senden</button>
|
||||
)}
|
||||
{!answered && <button onClick={submitAnswer} disabled={selected.length === 0} className="mt-4 py-3 bg-red-600 hover:bg-red-700 disabled:opacity-40 rounded-xl text-lg font-bold w-full">Antwort senden</button>}
|
||||
{answered && <p className="mt-4 text-center text-amber-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'}
|
||||
if (phase === 'result') {
|
||||
const liveFeedback = result ? {
|
||||
correctKeys: result.correctAnswer || [],
|
||||
correctText: question?.typ?.toLowerCase() === 'freitext' ? (result.correctAnswer || [])[0] : null,
|
||||
isCorrect: result.yourResult?.correct,
|
||||
} : null;
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col p-4 max-w-2xl mx-auto">
|
||||
<div className="text-center text-sm text-gray-400 mb-2">Frage {qIndex + 1} / {qTotal}</div>
|
||||
<h2 className="text-xl font-bold mb-4 text-center">{question?.frage}</h2>
|
||||
{question?.bild && <img src={question.bild} className="max-h-36 mx-auto rounded-lg mb-4" alt="" />}
|
||||
{question && <AnswerGrid question={question} selected={selected} disabled feedback={liveFeedback} />}
|
||||
<div className={`text-3xl text-center mt-4 font-bold ${result?.yourResult?.correct ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{result?.yourResult?.correct ? '✅ Richtig!' : '❌ Falsch'}
|
||||
</div>
|
||||
<p className="text-xl text-center font-bold mt-1">+{result?.yourResult?.points || 0} Punkte</p>
|
||||
<p className="text-gray-400 text-center mb-2">Gesamt: {result?.yourResult?.totalScore || 0}</p>
|
||||
<Leaderboard entries={leaderboardData} highlight={params.name} compact />
|
||||
<p className="mt-4 text-gray-500 animate-pulse text-center">Warte auf nächste Frage...</p>
|
||||
</div>
|
||||
<p className="text-2xl font-bold mb-1">+{result?.yourResult?.points || 0} Punkte</p>
|
||||
<p className="text-gray-400 mb-4">Gesamt: {result?.yourResult?.totalScore || 0}</p>
|
||||
{result?.correctAnswer && (
|
||||
<p className="bg-gray-800 py-2 px-4 rounded-lg mb-4">Richtige Antwort: <strong className="text-amber-400">{result.correctAnswer.join(', ')}</strong></p>
|
||||
)}
|
||||
<Leaderboard entries={leaderboardData} highlight={params.name} compact />
|
||||
<p className="mt-4 text-gray-500 animate-pulse">Warte auf nächste Frage...</p>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === 'final') return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center p-4">
|
||||
@@ -361,7 +341,7 @@ function LivePlayPage({ params, navigate }) {
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* Solo Select Page */
|
||||
/* Solo Select Page — with progress check */
|
||||
/* ================================================================== */
|
||||
|
||||
function SoloSelectPage({ navigate }) {
|
||||
@@ -369,19 +349,51 @@ function SoloSelectPage({ navigate }) {
|
||||
const [name, setName] = useState('');
|
||||
const [doShuffle, setDoShuffle] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [progressDialog, setProgressDialog] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/api/sets').then(setSets).catch((e) => setError(e.message));
|
||||
}, []);
|
||||
useEffect(() => { api.get('/api/sets').then(setSets).catch((e) => setError(e.message)); }, []);
|
||||
|
||||
const start = (setId, setName2) => {
|
||||
const startSet = async (setId, setName2) => {
|
||||
if (!name.trim()) return setError('Bitte Name eingeben');
|
||||
navigate('solo-quiz', { setId, setName: setName2, name: name.trim(), shuffle: doShuffle });
|
||||
setChecking(true);
|
||||
setError('');
|
||||
try {
|
||||
const progress = await api.get(`/api/progress/${encodeURIComponent(name.trim())}/${setId}`);
|
||||
if (progress.hasProgress) {
|
||||
setProgressDialog({ setId, setName: setName2, ...progress });
|
||||
} else {
|
||||
navigate('solo-quiz', { setId, setName: setName2, name: name.trim(), shuffle: doShuffle });
|
||||
}
|
||||
} catch (e) {
|
||||
// If progress check fails, just start fresh
|
||||
navigate('solo-quiz', { setId, setName: setName2, name: name.trim(), shuffle: doShuffle });
|
||||
}
|
||||
setChecking(false);
|
||||
};
|
||||
|
||||
const continueSession = () => {
|
||||
navigate('solo-quiz', {
|
||||
setId: progressDialog.setId, setName: progressDialog.setName,
|
||||
name: name.trim(), shuffle: doShuffle,
|
||||
continueSessionId: progressDialog.sessionId,
|
||||
priorAnswers: progressDialog.answers,
|
||||
});
|
||||
setProgressDialog(null);
|
||||
};
|
||||
|
||||
const startFresh = () => {
|
||||
navigate('solo-quiz', {
|
||||
setId: progressDialog.setId, setName: progressDialog.setName,
|
||||
name: name.trim(), shuffle: doShuffle,
|
||||
});
|
||||
setProgressDialog(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center p-6 pt-12">
|
||||
<h2 className="text-3xl font-bold mb-6">📚 Solo-Lernen</h2>
|
||||
<div className="w-full max-w-md"><BackButton onClick={() => navigate('home')} label="Zurück" /></div>
|
||||
<h2 className="text-3xl font-bold mb-6 mt-2">📚 Solo-Lernen</h2>
|
||||
<div className="w-full max-w-md space-y-4">
|
||||
<input className="w-full p-4 rounded-xl bg-gray-800 text-white text-center text-xl border-2 border-gray-700 focus:border-amber-500 outline-none" placeholder="Dein Name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<label className="flex items-center gap-3 text-gray-300 cursor-pointer">
|
||||
@@ -389,44 +401,68 @@ function SoloSelectPage({ navigate }) {
|
||||
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. Bitte im Admin-Bereich einrichten.</p>}
|
||||
{sets.map((s) => (
|
||||
<button key={s.id} onClick={() => start(s.id, s.name)} className="w-full py-4 bg-amber-600 hover:bg-amber-700 rounded-xl text-lg font-bold transition-all shadow-lg shadow-amber-900/30">{s.name}</button>
|
||||
))}
|
||||
<button onClick={() => navigate('home')} className="w-full py-3 text-gray-400 hover:text-white">← Zurück</button>
|
||||
|
||||
{/* Progress dialog */}
|
||||
{progressDialog && (
|
||||
<div className="bg-gray-800 rounded-xl p-5 border-2 border-amber-500 space-y-3">
|
||||
<h3 className="font-bold text-lg text-amber-400">📌 Gespeicherter Fortschritt</h3>
|
||||
<p>{progressDialog.setName}: <strong>{progressDialog.totalAnswered}</strong> / {progressDialog.totalQuestions} Fragen beantwortet</p>
|
||||
<p className="text-sm text-gray-400">{progressDialog.totalCorrect} davon richtig</p>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={continueSession} className="flex-1 py-3 bg-amber-600 hover:bg-amber-700 rounded-xl font-bold">Weitermachen</button>
|
||||
<button onClick={startFresh} className="flex-1 py-3 bg-gray-600 hover:bg-gray-500 rounded-xl font-bold">Neue Runde</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!progressDialog && (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold mt-4">Fragenset wählen:</h3>
|
||||
{sets.length === 0 && <p className="text-gray-500">Keine Fragensets konfiguriert.</p>}
|
||||
{sets.map((s) => (
|
||||
<button key={s.id} onClick={() => startSet(s.id, s.name)} disabled={checking}
|
||||
className="w-full py-4 bg-amber-600 hover:bg-amber-700 disabled:opacity-50 rounded-xl text-lg font-bold transition-all shadow-lg shadow-amber-900/30">
|
||||
{checking ? 'Prüfe...' : s.name}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* Solo Quiz Page */
|
||||
/* Solo Quiz Page — immediate save per answer + continue support */
|
||||
/* ================================================================== */
|
||||
|
||||
function SoloQuizPage({ params, navigate }) {
|
||||
const [allQuestions, setAllQuestions] = useState([]);
|
||||
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 [correctKeys, setCorrectKeys] = useState([]);
|
||||
const [correctText, setCorrectText] = useState('');
|
||||
const [results, setResults] = useState([]);
|
||||
const [priorResults] = useState(params.priorAnswers || []);
|
||||
const [error, setError] = useState('');
|
||||
const [enlargedImg, setEnlargedImg] = useState(null);
|
||||
const [shuffledAnswers, setShuffledAnswers] = useState([]);
|
||||
const sessionId = useRef('SOLO-' + Date.now().toString(36).toUpperCase());
|
||||
const [saving, setSaving] = useState(false);
|
||||
const sessionId = useRef(params.continueSessionId || 'SOLO-' + Date.now().toString(36).toUpperCase());
|
||||
const priorIds = useRef(new Set((params.priorAnswers || []).map((a) => a.Frage_ID)));
|
||||
|
||||
useEffect(() => {
|
||||
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));
|
||||
[qs[i], qs[j]] = [qs[j], qs[i]];
|
||||
}
|
||||
setAllQuestions(qs);
|
||||
// Filter out already answered questions
|
||||
let remaining = qs.filter((q) => !priorIds.current.has(q.id));
|
||||
if (params.shuffle && remaining.length > 0) {
|
||||
remaining = shuffleArray(remaining);
|
||||
}
|
||||
setQuestions(qs);
|
||||
setQuestions(remaining);
|
||||
}).catch((e) => setError(e.message));
|
||||
}, []);
|
||||
|
||||
@@ -434,13 +470,27 @@ function SoloQuizPage({ params, navigate }) {
|
||||
if (questions.length === 0 || index >= questions.length) return;
|
||||
const q = questions[index];
|
||||
const answers = [];
|
||||
['A', 'B', 'C', 'D'].forEach((k) => {
|
||||
if (q[`Antwort ${k}`]) answers.push({ key: k, text: q[`Antwort ${k}`] });
|
||||
});
|
||||
['A', 'B', 'C', 'D'].forEach((k) => { if (q[`Antwort ${k}`]) answers.push({ key: k, text: q[`Antwort ${k}`] }); });
|
||||
setShuffledAnswers(shuffleArray(answers));
|
||||
}, [index, questions]);
|
||||
|
||||
if (error) return <div className="min-h-screen flex items-center justify-center text-red-400 text-xl p-6 text-center">{error}</div>;
|
||||
if (!questions.length && allQuestions.length > 0) {
|
||||
// All questions already answered → go straight to results
|
||||
const allResults = priorResults.map((a) => ({ Frage_ID: a.Frage_ID, Richtig: a.Richtig, Punkte: a.Punkte }));
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center p-6">
|
||||
<h2 className="text-2xl font-bold mb-4">✅ Alle Fragen bereits beantwortet!</h2>
|
||||
<div className="space-y-3 w-full max-w-sm">
|
||||
<button onClick={() => navigate('solo-result', { results: allResults, setName: params.setName, name: params.name, questions: allQuestions })}
|
||||
className="w-full py-3 bg-amber-600 rounded-xl font-bold">Ergebnis anzeigen</button>
|
||||
<button onClick={() => navigate('solo-quiz', { ...params, continueSessionId: null, priorAnswers: null })}
|
||||
className="w-full py-3 bg-gray-600 rounded-xl font-bold">Neue Runde starten</button>
|
||||
<button onClick={() => navigate('home')} className="w-full py-3 text-gray-400 hover:text-white">← Zur Startseite</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!questions.length) return <div className="min-h-screen flex items-center justify-center animate-pulse text-xl text-amber-400">Lade Fragen...</div>;
|
||||
|
||||
const q = questions[index];
|
||||
@@ -452,11 +502,18 @@ function SoloQuizPage({ params, navigate }) {
|
||||
antworten: shuffledAnswers,
|
||||
};
|
||||
|
||||
const feedback = showFeedback ? {
|
||||
correctKeys, correctText: typ === 'freitext' ? correctText : null, isCorrect,
|
||||
} : null;
|
||||
|
||||
const totalProgress = priorIds.current.size + index + 1;
|
||||
const totalAll = allQuestions.length;
|
||||
|
||||
const checkAnswer = () => {
|
||||
const rawCorrect = (q['Richtige Antwort'] || '').trim();
|
||||
let correct, ok;
|
||||
if (typ === 'freitext') {
|
||||
correct = [rawCorrect];
|
||||
correct = []; setCorrectText(rawCorrect);
|
||||
ok = normalizeText(selected[0]) === normalizeText(rawCorrect);
|
||||
} else {
|
||||
correct = rawCorrect.split(',').map((s) => s.trim().toUpperCase()).filter(Boolean);
|
||||
@@ -465,44 +522,58 @@ function SoloQuizPage({ params, navigate }) {
|
||||
ok = correct.length === selectedSet.size && correct.every((c) => selectedSet.has(c));
|
||||
}
|
||||
|
||||
setCorrectAnswer(correct);
|
||||
setCorrectKeys(correct);
|
||||
setIsCorrect(ok);
|
||||
setShowFeedback(true);
|
||||
|
||||
const newResults = [...results, {
|
||||
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(),
|
||||
}];
|
||||
setResults(newResults);
|
||||
const answerText = selected.map((key) => {
|
||||
const ans = shuffledAnswers.find((a) => a.key === key);
|
||||
return ans ? ans.text : key;
|
||||
}).join(', ');
|
||||
|
||||
const answerData = {
|
||||
Nutzername: params.name, Frage_ID: q.id, Fragenset: params.setName,
|
||||
Antwort: answerText, Richtig: ok, Punkte: ok ? 1000 : 0,
|
||||
Session_ID: sessionId.current, Modus: 'Solo', Zeitstempel: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Save immediately to Baserow
|
||||
setSaving(true);
|
||||
api.post('/api/results/single', answerData)
|
||||
.catch((e) => console.error('Save error:', e))
|
||||
.finally(() => setSaving(false));
|
||||
|
||||
setResults((prev) => [...prev, { Frage_ID: q.id, Richtig: ok, Punkte: ok ? 1000 : 0 }]);
|
||||
};
|
||||
|
||||
const nextQuestion = () => {
|
||||
if (index + 1 >= questions.length) {
|
||||
api.post('/api/results', { answers: results }).catch((e) => console.error('Save error:', e));
|
||||
navigate('solo-result', { results, setName: params.setName, name: params.name, questions });
|
||||
const allResults = [
|
||||
...priorResults.map((a) => ({ Frage_ID: a.Frage_ID, Richtig: a.Richtig, Punkte: a.Punkte })),
|
||||
...results,
|
||||
];
|
||||
navigate('solo-result', { results: allResults, setName: params.setName, name: params.name, questions: allQuestions });
|
||||
return;
|
||||
}
|
||||
setIndex(index + 1);
|
||||
setSelected([]);
|
||||
setShowFeedback(false);
|
||||
setCorrectKeys([]);
|
||||
setCorrectText('');
|
||||
};
|
||||
|
||||
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="flex justify-between items-center mb-2">
|
||||
<BackButton onClick={() => navigate('home')} />
|
||||
<span className="text-sm text-gray-400">Frage {totalProgress} / {totalAll}</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-700 rounded-full h-2 mb-4">
|
||||
<div className="bg-amber-500 h-full rounded-full transition-all" style={{ width: `${((index + 1) / questions.length) * 100}%` }} />
|
||||
<div className="bg-amber-500 h-full rounded-full transition-all" style={{ width: `${(totalProgress / totalAll) * 100}%` }} />
|
||||
</div>
|
||||
<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} />
|
||||
<AnswerGrid question={sanitized} selected={selected} onToggle={setSelected} disabled={showFeedback} feedback={feedback} />
|
||||
{!showFeedback && (
|
||||
<button onClick={checkAnswer} disabled={selected.length === 0} className="mt-4 py-3 bg-amber-600 hover:bg-amber-700 disabled:opacity-40 rounded-xl text-lg font-bold w-full shadow-lg shadow-amber-900/30">Prüfen</button>
|
||||
)}
|
||||
@@ -511,8 +582,8 @@ function SoloQuizPage({ params, navigate }) {
|
||||
<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 className="text-amber-400">{correctAnswer.join(', ')}</strong></p>}
|
||||
<button onClick={nextQuestion} className="w-full py-3 bg-red-600 hover:bg-red-700 rounded-xl text-lg font-bold shadow-lg shadow-red-900/30">
|
||||
{saving && <p className="text-gray-500 text-sm animate-pulse">Speichere...</p>}
|
||||
<button onClick={nextQuestion} disabled={saving} className="w-full py-3 bg-red-600 hover:bg-red-700 disabled:opacity-50 rounded-xl text-lg font-bold shadow-lg shadow-red-900/30">
|
||||
{index + 1 >= questions.length ? 'Ergebnis anzeigen' : 'Nächste Frage →'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -523,7 +594,7 @@ function SoloQuizPage({ params, navigate }) {
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* Solo Result Page */
|
||||
/* Solo Result Page — matches by Frage_ID */
|
||||
/* ================================================================== */
|
||||
|
||||
function SoloResultPage({ params, navigate }) {
|
||||
@@ -532,9 +603,13 @@ function SoloResultPage({ params, navigate }) {
|
||||
const correct = results.filter((r) => r.Richtig).length;
|
||||
const pct = total > 0 ? Math.round((correct / total) * 100) : 0;
|
||||
|
||||
// Map questions by ID for category lookup
|
||||
const qMap = {};
|
||||
(questions || []).forEach((q) => { qMap[q.id] = q; });
|
||||
|
||||
const byCategory = {};
|
||||
results.forEach((r, i) => {
|
||||
const q = questions[i];
|
||||
results.forEach((r) => {
|
||||
const q = qMap[r.Frage_ID];
|
||||
const cat = fieldVal(q?.['Kategorie'], 'Ohne Kategorie') || 'Ohne Kategorie';
|
||||
if (!byCategory[cat]) byCategory[cat] = { total: 0, correct: 0 };
|
||||
byCategory[cat].total++;
|
||||
@@ -579,8 +654,7 @@ function MyResultsPage({ navigate }) {
|
||||
if (!name.trim()) return;
|
||||
setError('');
|
||||
api.get(`/api/results/${encodeURIComponent(name.trim())}`)
|
||||
.then(setResults)
|
||||
.catch((e) => setError(e.message));
|
||||
.then(setResults).catch((e) => setError(e.message));
|
||||
};
|
||||
|
||||
const sessions = {};
|
||||
@@ -594,7 +668,8 @@ function MyResultsPage({ navigate }) {
|
||||
|
||||
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"><BackButton onClick={() => navigate('home')} label="Zurück" /></div>
|
||||
<h2 className="text-3xl font-bold mb-6 mt-2">📊 Meine Ergebnisse</h2>
|
||||
<div className="w-full max-w-md space-y-3">
|
||||
<input className="w-full p-4 rounded-xl bg-gray-800 text-white text-center border-2 border-gray-700 focus:border-red-500 outline-none" placeholder="Dein Name" value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && search()} />
|
||||
<button onClick={search} className="w-full py-3 bg-red-600 hover:bg-red-700 rounded-xl font-bold shadow-lg shadow-red-900/30">Suchen</button>
|
||||
@@ -605,7 +680,7 @@ function MyResultsPage({ navigate }) {
|
||||
<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 cor = 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">
|
||||
@@ -616,13 +691,12 @@ function MyResultsPage({ navigate }) {
|
||||
</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>
|
||||
<p>{cor}/{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>
|
||||
);
|
||||
}
|
||||
@@ -634,12 +708,7 @@ function MyResultsPage({ navigate }) {
|
||||
function App() {
|
||||
const [page, setPage] = useState('home');
|
||||
const [params, setParams] = useState({});
|
||||
|
||||
const navigate = useCallback((p, par = {}) => {
|
||||
setPage(p);
|
||||
setParams(par);
|
||||
window.scrollTo(0, 0);
|
||||
}, []);
|
||||
const navigate = useCallback((p, par = {}) => { setPage(p); setParams(par); window.scrollTo(0, 0); }, []);
|
||||
|
||||
switch (page) {
|
||||
case 'home': return <HomePage navigate={navigate} />;
|
||||
|
||||
Reference in New Issue
Block a user