1516 lines
76 KiB
JavaScript
1516 lines
76 KiB
JavaScript
const { useState, useEffect, useRef, useCallback } = React;
|
|
|
|
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();
|
|
},
|
|
async put(url, data) {
|
|
const r = await fetch(url, { method: 'PUT', 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}`; }
|
|
|
|
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]]; }
|
|
return a;
|
|
}
|
|
|
|
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, '');
|
|
}
|
|
|
|
function Logo({ size }) {
|
|
const cls = size === 'lg' ? 'w-24 h-24' : size === 'md' ? 'w-16 h-16' : 'w-10 h-10';
|
|
return <img src="/img/logo.png" alt="Quizalarm" className={`${cls} mx-auto`} />;
|
|
}
|
|
|
|
/* ================================================================== */
|
|
/* Shared Components */
|
|
/* ================================================================== */
|
|
|
|
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="" />
|
|
</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 mb-4">
|
|
<div className="w-full bg-gray-700 rounded-full h-4 overflow-hidden">
|
|
<div className={`${color} h-full rounded-full transition-all duration-200`} style={{ width: `${pct}%` }} />
|
|
</div>
|
|
<div className="text-center text-sm mt-1 text-gray-300">{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 w-full">
|
|
<h3 className="text-lg font-bold mb-2 text-center">🏆 Leaderboard</h3>
|
|
{show.map((e, i) => (
|
|
<div key={e.name} className={`flex justify-between py-2 px-3 rounded-lg mb-1 ${e.name === highlight ? 'bg-red-700' : i === 0 ? 'bg-amber-700' : 'bg-gray-700'}`}>
|
|
<span className="font-medium">{i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${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, 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 />
|
|
);
|
|
}
|
|
|
|
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 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 || !!feedback}
|
|
onClick={() => {
|
|
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>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 */
|
|
/* ================================================================== */
|
|
|
|
function HomePage({ navigate }) {
|
|
return (
|
|
<div className="min-h-screen flex flex-col items-center justify-center p-6">
|
|
<Logo size="lg" />
|
|
<h1 className="text-5xl font-extrabold mb-2 mt-4 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">
|
|
<button onClick={() => navigate('wettkampf-select')} className="w-full py-4 bg-emerald-600 hover:bg-emerald-700 rounded-xl text-xl font-bold transition-all shadow-lg shadow-emerald-900/30">⚔️ Wettkampf</button>
|
|
<button onClick={() => navigate('solo-select')} className="w-full py-4 bg-amber-600 hover:bg-amber-700 rounded-xl text-xl font-bold transition-all shadow-lg shadow-amber-900/30">📚 Solo-Lernen</button>
|
|
<button onClick={() => navigate('join')} className="w-full py-4 bg-red-600 hover:bg-red-700 rounded-xl text-xl font-bold transition-all shadow-lg shadow-red-900/30">🎮 Live-Quiz beitreten</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-red-500 outline-none uppercase" placeholder="CODE" value={code} onChange={(e) => setCode(e.target.value)} maxLength={20} />
|
|
<input className="w-full p-4 rounded-xl bg-gray-800 text-white text-center text-xl border-2 border-gray-700 focus:border-red-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-red-600 hover:bg-red-700 rounded-xl text-xl font-bold shadow-lg shadow-red-900/30">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);
|
|
const phaseRef = useRef(phase);
|
|
phaseRef.current = phase;
|
|
|
|
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 (phaseRef.current !== 'final' && phaseRef.current !== 'error') setPhase('disconnected'); };
|
|
return () => { clearInterval(timerRef.current); ws.close(); };
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
clearInterval(timerRef.current);
|
|
if (phase === 'question' && timeLimit > 0 && !answered) {
|
|
const start = Date.now();
|
|
timerRef.current = setInterval(() => {
|
|
const left = Math.max(0, timeLimit - (Date.now() - start) / 1000);
|
|
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);
|
|
};
|
|
|
|
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>
|
|
<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>
|
|
<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>
|
|
);
|
|
|
|
if (phase === 'connecting') return <div className="min-h-screen flex items-center justify-center"><p className="text-xl animate-pulse text-amber-400">Verbinde...</p></div>;
|
|
|
|
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>
|
|
<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-red-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="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">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') {
|
|
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>
|
|
);
|
|
}
|
|
|
|
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 [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)); }, []);
|
|
|
|
const startSet = async (setId, setName2, color) => {
|
|
if (!name.trim()) return setError('Bitte Name eingeben');
|
|
setChecking(true);
|
|
setError('');
|
|
try {
|
|
const progress = await api.get(`/api/progress/${encodeURIComponent(name.trim())}/${setId}`);
|
|
if (progress.hasProgress) {
|
|
setProgressDialog({ setId, setName: setName2, color, ...progress });
|
|
} else {
|
|
navigate('solo-quiz', { setId, setName: setName2, name: name.trim(), shuffle: doShuffle, color });
|
|
}
|
|
} catch (e) {
|
|
navigate('solo-quiz', { setId, setName: setName2, name: name.trim(), shuffle: doShuffle, color });
|
|
}
|
|
setChecking(false);
|
|
};
|
|
|
|
const continueSession = () => {
|
|
navigate('solo-quiz', {
|
|
setId: progressDialog.setId, setName: progressDialog.setName,
|
|
name: name.trim(), shuffle: doShuffle, color: progressDialog.color,
|
|
continueSessionId: progressDialog.sessionId,
|
|
priorAnswers: progressDialog.answers,
|
|
});
|
|
setProgressDialog(null);
|
|
};
|
|
|
|
const startFresh = () => {
|
|
navigate('solo-quiz', {
|
|
setId: progressDialog.setId, setName: progressDialog.setName,
|
|
name: name.trim(), shuffle: doShuffle, color: progressDialog.color,
|
|
});
|
|
setProgressDialog(null);
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen flex flex-col items-center p-6 pt-12">
|
|
<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">
|
|
<input type="checkbox" checked={doShuffle} onChange={() => setDoShuffle(!doShuffle)} className="w-5 h-5 accent-amber-500" />
|
|
Fragen mischen
|
|
</label>
|
|
{error && <p className="text-red-400">{error}</p>}
|
|
|
|
{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) => {
|
|
const bg = s.color || '#d97706';
|
|
return (
|
|
<button key={s.id} onClick={() => startSet(s.id, s.name, s.color)} disabled={checking}
|
|
style={{ backgroundColor: bg }}
|
|
className="w-full py-4 rounded-xl text-lg font-bold transition-all shadow-lg text-white hover:opacity-90 disabled:opacity-50">
|
|
{checking ? 'Prüfe...' : s.name}
|
|
</button>
|
|
);
|
|
})}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ================================================================== */
|
|
/* Solo Quiz Page */
|
|
/* ================================================================== */
|
|
|
|
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 [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 [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) => {
|
|
setAllQuestions(qs);
|
|
let remaining = qs.filter((q) => !priorIds.current.has(q.id));
|
|
if (params.shuffle && remaining.length > 0) remaining = shuffleArray(remaining);
|
|
setQuestions(remaining);
|
|
}).catch((e) => setError(e.message));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
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}`] }); });
|
|
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) {
|
|
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, color: params.color })}
|
|
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];
|
|
const typ = fieldVal(q['Typ'], 'MC').toLowerCase();
|
|
const sanitized = {
|
|
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 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 correctKeysResolved, ok;
|
|
|
|
if (typ === 'freitext') {
|
|
correctKeysResolved = [];
|
|
setCorrectText(rawCorrect);
|
|
ok = normalizeText(selected[0]) === normalizeText(rawCorrect);
|
|
} else {
|
|
// Resolve correct answer: could be keys ("A","B") or text ("Wahr","Falsch")
|
|
const parts = rawCorrect.split(',').map((s) => s.trim()).filter(Boolean);
|
|
const validKeys = new Set(['A', 'B', 'C', 'D']);
|
|
const allAreKeys = parts.length > 0 && parts.every((p) => validKeys.has(p.toUpperCase()));
|
|
|
|
if (allAreKeys) {
|
|
correctKeysResolved = parts.map((p) => p.toUpperCase());
|
|
} else {
|
|
// Match text against answer options
|
|
correctKeysResolved = [];
|
|
for (const part of parts) {
|
|
const partLower = part.toLowerCase().trim();
|
|
// Search in all answers (including shuffled ones which keep their original keys)
|
|
const allAnswers = [];
|
|
['A', 'B', 'C', 'D'].forEach((k) => {
|
|
if (q[`Antwort ${k}`]) allAnswers.push({ key: k, text: q[`Antwort ${k}`] });
|
|
});
|
|
for (const ans of allAnswers) {
|
|
if (ans.text.toLowerCase().trim() === partLower) {
|
|
correctKeysResolved.push(ans.key);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
// Fallback
|
|
if (correctKeysResolved.length === 0) {
|
|
correctKeysResolved = parts.map((p) => p.toUpperCase());
|
|
}
|
|
}
|
|
|
|
const correctSet = new Set(correctKeysResolved);
|
|
const selectedSet = new Set(selected.map((s) => s.toUpperCase()));
|
|
ok = correctKeysResolved.length === selectedSet.size && correctKeysResolved.every((c) => selectedSet.has(c));
|
|
}
|
|
|
|
setCorrectKeys(correctKeysResolved);
|
|
setIsCorrect(ok);
|
|
setShowFeedback(true);
|
|
|
|
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(),
|
|
};
|
|
|
|
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) {
|
|
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, color: params.color });
|
|
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="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="h-full rounded-full transition-all" style={{ width: `${(totalProgress / totalAll) * 100}%`, backgroundColor: params.color || '#f59e0b' }} />
|
|
</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} feedback={feedback} />
|
|
{!showFeedback && (
|
|
<button onClick={checkAnswer} disabled={selected.length === 0}
|
|
style={{ backgroundColor: params.color || '#d97706' }}
|
|
className="mt-4 py-3 hover:opacity-90 disabled:opacity-40 rounded-xl text-lg font-bold w-full shadow-lg text-white">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>
|
|
{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>
|
|
)}
|
|
<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;
|
|
|
|
const qMap = {};
|
|
(questions || []).forEach((q) => { qMap[q.id] = q; });
|
|
|
|
const byCategory = {};
|
|
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++;
|
|
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')}
|
|
style={{ backgroundColor: params.color || '#d97706' }}
|
|
className="w-full py-3 hover:opacity-90 rounded-xl font-bold shadow-lg text-white">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>
|
|
);
|
|
}
|
|
|
|
/* ================================================================== */
|
|
/* Wettkampf Select Page */
|
|
/* ================================================================== */
|
|
|
|
function WettkampfSelectPage({ navigate }) {
|
|
const [sets, setSets] = useState([]);
|
|
const [name, setName] = useState('');
|
|
const [selectedSet, setSelectedSet] = useState(null);
|
|
const [totalQuestions, setTotalQuestions] = useState(null);
|
|
const [error, setError] = useState('');
|
|
const [checking, setChecking] = useState(false);
|
|
const [progressDialog, setProgressDialog] = useState(null);
|
|
|
|
useEffect(() => {
|
|
api.get('/api/sets').then((allSets) => {
|
|
// Only show sets with a configured wettkampfCount
|
|
setSets(allSets.filter((s) => s.wettkampfCount && s.wettkampfCount > 0));
|
|
}).catch((e) => setError(e.message));
|
|
}, []);
|
|
|
|
const selectSet = async (set) => {
|
|
setSelectedSet(set);
|
|
try {
|
|
const data = await api.get(`/api/sets/${set.id}/count`);
|
|
setTotalQuestions(data.count);
|
|
} catch (e) {
|
|
setError(e.message);
|
|
}
|
|
};
|
|
|
|
const startWettkampf = async () => {
|
|
if (!name.trim()) return setError('Bitte Name eingeben');
|
|
const count = selectedSet.wettkampfCount;
|
|
setChecking(true);
|
|
setError('');
|
|
try {
|
|
const progress = await api.get(`/api/wettkampf-progress/${encodeURIComponent(name.trim())}/${selectedSet.id}`);
|
|
if (progress.hasProgress) {
|
|
setProgressDialog({ ...progress, count });
|
|
} else {
|
|
navigate('wettkampf-quiz', { setId: selectedSet.id, setName: selectedSet.name, name: name.trim(), questionCount: count, color: selectedSet.color, timer: selectedSet.wettkampfTimer });
|
|
}
|
|
} catch (e) {
|
|
navigate('wettkampf-quiz', { setId: selectedSet.id, setName: selectedSet.name, name: name.trim(), questionCount: count, color: selectedSet.color, timer: selectedSet.wettkampfTimer });
|
|
}
|
|
setChecking(false);
|
|
};
|
|
|
|
const continueSession = () => {
|
|
navigate('wettkampf-quiz', {
|
|
setId: selectedSet.id, setName: selectedSet.name,
|
|
name: name.trim(), questionCount: selectedSet.wettkampfCount,
|
|
color: selectedSet.color, timer: selectedSet.wettkampfTimer,
|
|
continueSessionId: progressDialog.sessionId,
|
|
priorAnswers: progressDialog.answers,
|
|
});
|
|
setProgressDialog(null);
|
|
};
|
|
|
|
const startFresh = () => {
|
|
navigate('wettkampf-quiz', {
|
|
setId: selectedSet.id, setName: selectedSet.name,
|
|
name: name.trim(), questionCount: selectedSet.wettkampfCount,
|
|
color: selectedSet.color, timer: selectedSet.wettkampfTimer,
|
|
});
|
|
setProgressDialog(null);
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen flex flex-col items-center p-6 pt-12">
|
|
<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">⚔️ Wettkampf</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)} />
|
|
|
|
{progressDialog && (
|
|
<div className="bg-gray-800 rounded-xl p-5 border-2 border-emerald-500 space-y-3">
|
|
<h3 className="font-bold text-lg text-emerald-400">📌 Gespeicherter Fortschritt</h3>
|
|
<p>{selectedSet.name}: <strong>{progressDialog.totalAnswered}</strong> Fragen beantwortet</p>
|
|
<div className="flex gap-3">
|
|
<button onClick={continueSession} className="flex-1 py-3 bg-emerald-600 hover:bg-emerald-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 && (
|
|
<>
|
|
{!selectedSet ? (
|
|
<>
|
|
<h3 className="text-lg font-semibold mt-4">Fragenset wählen:</h3>
|
|
{sets.length === 0 && <p className="text-gray-500">Keine Wettkampf-Sets konfiguriert. Bitte im Admin-Bereich konfigurieren.</p>}
|
|
{sets.map((s) => {
|
|
const bg = s.color || '#059669';
|
|
return (
|
|
<button key={s.id} onClick={() => selectSet(s)}
|
|
style={{ backgroundColor: bg }}
|
|
className="w-full py-4 rounded-xl text-lg font-bold transition-all shadow-lg text-white hover:opacity-90">
|
|
{s.name} <span className="opacity-70 text-sm">({s.wettkampfCount} Fragen)</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="bg-gray-800 rounded-xl p-4 flex justify-between items-center">
|
|
<div>
|
|
<span className="text-sm text-gray-400">Fragenset:</span>
|
|
<p className="font-bold text-emerald-400">{selectedSet.name}</p>
|
|
<p className="text-sm text-gray-400">{selectedSet.wettkampfCount} Fragen{totalQuestions ? ` (von ${totalQuestions} gesamt)` : ''}</p>
|
|
</div>
|
|
<button onClick={() => { setSelectedSet(null); setTotalQuestions(null); }} className="text-sm text-gray-400 hover:text-white">Ändern</button>
|
|
</div>
|
|
|
|
{error && <p className="text-red-400">{error}</p>}
|
|
|
|
<button onClick={startWettkampf} disabled={checking}
|
|
style={{ backgroundColor: selectedSet.color || '#059669' }}
|
|
className="w-full py-4 hover:opacity-90 disabled:opacity-40 rounded-xl text-xl font-bold transition-all shadow-lg text-white">
|
|
{checking ? 'Prüfe...' : '⚔️ Wettkampf starten'}
|
|
</button>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ================================================================== */
|
|
/* Wettkampf Quiz Page */
|
|
/* ================================================================== */
|
|
|
|
function WettkampfQuizPage({ params, navigate }) {
|
|
const [questions, setQuestions] = useState([]);
|
|
const [allQuestionsRaw, setAllQuestionsRaw] = useState([]);
|
|
const [index, setIndex] = useState(0);
|
|
const [answers, setAnswers] = useState({}); // { questionId: { selected: [...], rowId: null } }
|
|
const [error, setError] = useState('');
|
|
const [enlargedImg, setEnlargedImg] = useState(null);
|
|
const [shuffledAnswersMap, setShuffledAnswersMap] = useState({});
|
|
const [saving, setSaving] = useState(false);
|
|
const [confirming, setConfirming] = useState(false);
|
|
const [timeLeft, setTimeLeft] = useState(null); // seconds remaining
|
|
const [timerExpired, setTimerExpired] = useState(false);
|
|
const sessionId = useRef(params.continueSessionId || 'WK-' + Date.now().toString(36).toUpperCase());
|
|
const timerRef = useRef(null);
|
|
const confirmRef = useRef(null);
|
|
const accentColor = params.color || '#059669';
|
|
|
|
// Type-aware random selection
|
|
const selectQuestions = useCallback((allQuestions, count) => {
|
|
const shuffled = shuffleArray(allQuestions);
|
|
|
|
// Detect all unique types dynamically
|
|
const typeGroups = {};
|
|
shuffled.forEach((q) => {
|
|
const typ = fieldVal(q['Typ'], 'MC');
|
|
if (!typeGroups[typ]) typeGroups[typ] = [];
|
|
typeGroups[typ].push(q);
|
|
});
|
|
|
|
const typeKeys = Object.keys(typeGroups);
|
|
const selected = [];
|
|
const usedIds = new Set();
|
|
|
|
// If count >= number of types, pick one of each type first
|
|
if (typeKeys.length > 1 && count >= typeKeys.length) {
|
|
for (const typ of typeKeys) {
|
|
const q = typeGroups[typ][0];
|
|
selected.push(q);
|
|
usedIds.add(q.id);
|
|
}
|
|
}
|
|
|
|
// Fill remaining from shuffled pool
|
|
for (const q of shuffled) {
|
|
if (selected.length >= count) break;
|
|
if (!usedIds.has(q.id)) {
|
|
selected.push(q);
|
|
usedIds.add(q.id);
|
|
}
|
|
}
|
|
|
|
return shuffleArray(selected); // Shuffle final order
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
api.get(`/api/sets/${params.setId}/questions`).then((qs) => {
|
|
setAllQuestionsRaw(qs);
|
|
|
|
if (params.priorAnswers && params.priorAnswers.length > 0) {
|
|
// Resuming: find the questions that were already answered
|
|
const priorIds = new Set(params.priorAnswers.map((a) => Number(a.Frage_ID)));
|
|
const resumeQuestions = qs.filter((q) => priorIds.has(q.id));
|
|
|
|
// Fill the rest to match configure questionCount
|
|
const needed = params.questionCount - resumeQuestions.length;
|
|
if (needed > 0) {
|
|
const unpicked = qs.filter((q) => !priorIds.has(q.id));
|
|
const additional = shuffleArray(unpicked).slice(0, needed);
|
|
resumeQuestions.push(...additional);
|
|
}
|
|
|
|
setQuestions(shuffleArray(resumeQuestions));
|
|
|
|
// Restore answers state
|
|
const restored = {};
|
|
params.priorAnswers.forEach((a) => {
|
|
const ansStr = a.Antwort || '';
|
|
restored[Number(a.Frage_ID)] = {
|
|
selected: ansStr ? ansStr.split(',').map((s) => s.trim()) : [],
|
|
rowId: a.rowId,
|
|
};
|
|
});
|
|
setAnswers(restored);
|
|
} else {
|
|
const picked = selectQuestions(qs, params.questionCount);
|
|
setQuestions(picked);
|
|
}
|
|
}).catch((e) => setError(e.message));
|
|
}, []);
|
|
|
|
// Build shuffled answers for each question
|
|
useEffect(() => {
|
|
if (questions.length === 0) return;
|
|
const map = {};
|
|
questions.forEach((q) => {
|
|
const typ = fieldVal(q['Typ'], 'MC').toLowerCase();
|
|
if (typ !== 'freitext') {
|
|
const antworten = [];
|
|
['A', 'B', 'C', 'D'].forEach((k) => { if (q[`Antwort ${k}`]) antworten.push({ key: k, text: q[`Antwort ${k}`] }); });
|
|
map[q.id] = shuffleArray(antworten);
|
|
} else {
|
|
map[q.id] = [];
|
|
}
|
|
});
|
|
setShuffledAnswersMap(map);
|
|
}, [questions]);
|
|
|
|
const q = questions[index];
|
|
const answeredCount = Object.keys(answers).filter((qid) => {
|
|
const a = answers[qid];
|
|
return a && a.selected && a.selected.length > 0 && (a.selected[0] !== '');
|
|
}).length;
|
|
const allAnswered = answeredCount >= questions.length;
|
|
|
|
const saveAnswer = async (questionId, selected) => {
|
|
const existing = answers[questionId];
|
|
const answerText = selected.join(',');
|
|
|
|
if (existing && existing.rowId) {
|
|
// Update existing answer
|
|
try {
|
|
await api.put('/api/results/update', {
|
|
sessionId: sessionId.current,
|
|
frageId: questionId,
|
|
nutzername: params.name,
|
|
antwort: answerText,
|
|
});
|
|
} catch (e) {
|
|
console.error('Update error:', e);
|
|
}
|
|
} else {
|
|
// Create new answer
|
|
try {
|
|
setSaving(true);
|
|
const row = await api.post('/api/results/single', {
|
|
Nutzername: params.name,
|
|
Frage_ID: questionId,
|
|
Fragenset: params.setName,
|
|
Antwort: answerText,
|
|
Richtig: false,
|
|
Punkte: 0,
|
|
Session_ID: sessionId.current,
|
|
Modus: 'Wettkampf',
|
|
Zeitstempel: new Date().toISOString(),
|
|
});
|
|
setAnswers((prev) => ({
|
|
...prev,
|
|
[questionId]: { selected, rowId: row.id },
|
|
}));
|
|
setSaving(false);
|
|
return;
|
|
} catch (e) {
|
|
console.error('Save error:', e);
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
setAnswers((prev) => ({
|
|
...prev,
|
|
[questionId]: { ...prev[questionId], selected },
|
|
}));
|
|
};
|
|
|
|
const handleSelect = (questionId, selected) => {
|
|
setAnswers((prev) => ({
|
|
...prev,
|
|
[questionId]: { ...(prev[questionId] || {}), selected },
|
|
}));
|
|
};
|
|
|
|
const submitCurrent = () => {
|
|
if (!q) return;
|
|
const currentAnswer = answers[q.id];
|
|
if (currentAnswer && currentAnswer.selected && currentAnswer.selected.length > 0) {
|
|
saveAnswer(q.id, currentAnswer.selected);
|
|
}
|
|
// Move to next unanswered or stay
|
|
goToNextUnanswered();
|
|
};
|
|
|
|
const goToNextUnanswered = () => {
|
|
// Find next unanswered starting from current index
|
|
for (let i = 1; i <= questions.length; i++) {
|
|
const nextIdx = (index + i) % questions.length;
|
|
const qId = questions[nextIdx].id;
|
|
const a = answers[qId];
|
|
if (!a || !a.selected || a.selected.length === 0 || a.selected[0] === '') {
|
|
setIndex(nextIdx);
|
|
return;
|
|
}
|
|
}
|
|
// All answered; stay on current
|
|
};
|
|
|
|
const skipQuestion = () => {
|
|
// Just go to next question (wrapping)
|
|
const nextIdx = (index + 1) % questions.length;
|
|
setIndex(nextIdx);
|
|
};
|
|
|
|
const confirmResults = async () => {
|
|
setConfirming(true);
|
|
// Calculate scores for all questions
|
|
const finalResults = [];
|
|
questions.forEach((question) => {
|
|
const a = answers[question.id];
|
|
if (!a || !a.selected || a.selected.length === 0 || a.selected[0] === '') {
|
|
// Unanswered — counts as wrong
|
|
finalResults.push({ rowId: null, questionId: question.id, richtig: false, punkte: 0 });
|
|
return;
|
|
}
|
|
|
|
const typ = fieldVal(question['Typ'], 'MC').toLowerCase();
|
|
const rawCorrect = (question['Richtige Antwort'] || '').trim();
|
|
let ok = false;
|
|
|
|
if (typ === 'freitext') {
|
|
ok = normalizeText(a.selected[0]) === normalizeText(rawCorrect);
|
|
} else {
|
|
// Resolve correct answers
|
|
const parts = rawCorrect.split(',').map((s) => s.trim()).filter(Boolean);
|
|
const validKeys = new Set(['A', 'B', 'C', 'D']);
|
|
const allAreKeys = parts.length > 0 && parts.every((p) => validKeys.has(p.toUpperCase()));
|
|
|
|
let correctKeysResolved;
|
|
if (allAreKeys) {
|
|
correctKeysResolved = parts.map((p) => p.toUpperCase());
|
|
} else {
|
|
correctKeysResolved = [];
|
|
for (const part of parts) {
|
|
const partLower = part.toLowerCase().trim();
|
|
const allAntworten = [];
|
|
['A', 'B', 'C', 'D'].forEach((k) => {
|
|
if (question[`Antwort ${k}`]) allAntworten.push({ key: k, text: question[`Antwort ${k}`] });
|
|
});
|
|
for (const ans of allAntworten) {
|
|
if (ans.text.toLowerCase().trim() === partLower) {
|
|
correctKeysResolved.push(ans.key);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (correctKeysResolved.length === 0) correctKeysResolved = parts.map((p) => p.toUpperCase());
|
|
}
|
|
|
|
const correctSet = new Set(correctKeysResolved);
|
|
const selectedSet = new Set(a.selected.map((s) => s.toUpperCase()));
|
|
ok = correctKeysResolved.length === selectedSet.size && correctKeysResolved.every((c) => selectedSet.has(c));
|
|
}
|
|
|
|
finalResults.push({
|
|
rowId: a.rowId,
|
|
questionId: question.id,
|
|
richtig: ok,
|
|
punkte: ok ? 1000 : 0,
|
|
});
|
|
});
|
|
|
|
// Save final scores to backend
|
|
try {
|
|
const rowsToUpdate = finalResults.filter((r) => r.rowId);
|
|
if (rowsToUpdate.length > 0) {
|
|
await api.put('/api/results/finalize', { results: rowsToUpdate });
|
|
}
|
|
} catch (e) {
|
|
console.error('Finalize error:', e);
|
|
}
|
|
|
|
navigate('wettkampf-result', {
|
|
results: finalResults,
|
|
questions: questions,
|
|
answers: answers,
|
|
setName: params.setName,
|
|
name: params.name,
|
|
allQuestionsRaw: allQuestionsRaw,
|
|
shuffledAnswersMap: shuffledAnswersMap,
|
|
color: params.color,
|
|
});
|
|
};
|
|
|
|
// Store confirmResults in ref for timer access
|
|
confirmRef.current = confirmResults;
|
|
|
|
// Countdown timer
|
|
useEffect(() => {
|
|
if (!params.timer || questions.length === 0) return;
|
|
const totalSeconds = params.timer * 60;
|
|
|
|
let startTimestamp = Date.now();
|
|
if (params.priorAnswers && params.priorAnswers.length > 0) {
|
|
let earliest = null;
|
|
for (const a of params.priorAnswers) {
|
|
if (a.Zeitstempel) {
|
|
const ts = new Date(a.Zeitstempel).getTime();
|
|
if (!earliest || ts < earliest) earliest = ts;
|
|
}
|
|
}
|
|
if (earliest) startTimestamp = earliest;
|
|
}
|
|
|
|
const runTimer = () => {
|
|
const elapsed = (Date.now() - startTimestamp) / 1000;
|
|
const remaining = Math.max(0, totalSeconds - elapsed);
|
|
setTimeLeft(remaining);
|
|
if (remaining <= 0) {
|
|
clearInterval(timerRef.current);
|
|
setTimerExpired(true);
|
|
// Auto-confirm
|
|
if (confirmRef.current) confirmRef.current();
|
|
}
|
|
};
|
|
|
|
runTimer(); // Initial check
|
|
timerRef.current = setInterval(runTimer, 200);
|
|
return () => clearInterval(timerRef.current);
|
|
}, [questions.length, params.timer, params.priorAnswers]);
|
|
|
|
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) return <div className="min-h-screen flex items-center justify-center animate-pulse text-xl text-emerald-400">Lade Fragen...</div>;
|
|
|
|
const currentAnswer = answers[q?.id];
|
|
const currentSelected = currentAnswer?.selected || [];
|
|
const typ = fieldVal(q['Typ'], 'MC').toLowerCase();
|
|
const isWF = typ === 'wahr/falsch';
|
|
const currentShuffledAnswers = shuffledAnswersMap[q?.id] || [];
|
|
|
|
const sanitized = {
|
|
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: currentShuffledAnswers,
|
|
};
|
|
|
|
const hasCurrentAnswer = currentSelected.length > 0 && currentSelected[0] !== '';
|
|
|
|
return (
|
|
<div className="min-h-screen flex flex-col p-4 max-w-2xl mx-auto">
|
|
{/* Header */}
|
|
<div className="flex justify-between items-center mb-2">
|
|
<BackButton onClick={() => { if (confirm('Wettkampf wirklich verlassen? Dein Fortschritt bleibt gespeichert.')) navigate('home'); }} label="Verlassen" />
|
|
<span className="text-sm text-gray-400">Frage {index + 1} / {questions.length}</span>
|
|
</div>
|
|
|
|
{/* Timer */}
|
|
{timeLeft !== null && (
|
|
<div className="w-full mb-3">
|
|
<div className="w-full bg-gray-700 rounded-full h-4 overflow-hidden">
|
|
<div className={`h-full rounded-full transition-all duration-200 ${timeLeft < 60 ? 'bg-red-500' : timeLeft < params.timer * 30 ? 'bg-yellow-500' : ''}`}
|
|
style={{ width: `${Math.max(0, (timeLeft / (params.timer * 60)) * 100)}%`, backgroundColor: timeLeft >= params.timer * 30 ? accentColor : undefined }} />
|
|
</div>
|
|
<div className={`text-center text-lg font-bold mt-1 ${timeLeft < 60 ? 'text-red-400 animate-pulse' : 'text-gray-300'}`}>
|
|
{Math.floor(timeLeft / 60)}:{String(Math.floor(timeLeft % 60)).padStart(2, '0')}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Progress bar */}
|
|
<div className="w-full bg-gray-700 rounded-full h-2 mb-3">
|
|
<div className="h-full rounded-full transition-all" style={{ width: `${(answeredCount / questions.length) * 100}%`, backgroundColor: accentColor }} />
|
|
</div>
|
|
|
|
<div className="mb-4">
|
|
{(() => {
|
|
const N = questions.length;
|
|
if (N === 0) return null;
|
|
const R = Math.ceil(N / 20);
|
|
const chunks = [];
|
|
const baseSize = Math.floor(N / R);
|
|
let remainder = N % R;
|
|
let startIndex = 0;
|
|
for (let i = 0; i < R; i++) {
|
|
const chunkSize = baseSize + (remainder > 0 ? 1 : 0);
|
|
remainder--;
|
|
const chunk = [];
|
|
for (let j = 0; j < chunkSize; j++) {
|
|
chunk.push({ q: questions[startIndex + j], origIndex: startIndex + j });
|
|
}
|
|
chunks.push(chunk);
|
|
startIndex += chunkSize;
|
|
}
|
|
return (
|
|
<div className="flex flex-col gap-1.5 sm:gap-2 items-center w-full">
|
|
{chunks.map((chunk, rowIdx) => (
|
|
<div key={rowIdx} className="flex gap-1 sm:gap-1.5 justify-center w-full flex-nowrap">
|
|
{chunk.map(({ q, origIndex }) => {
|
|
const a = answers[q.id];
|
|
const isAnswered = a && a.selected && a.selected.length > 0 && a.selected[0] !== '';
|
|
const isCurrent = origIndex === index;
|
|
const style = {};
|
|
// flex-nowrap with flex-1 aspect-square forces it on one row safely shrinking on mobile
|
|
let cls = 'flex-1 max-w-[2.25rem] aspect-square rounded-md sm:rounded-lg text-xs sm:text-sm font-bold transition-all flex items-center justify-center min-w-0 overflow-hidden shrink ';
|
|
if (isCurrent) { cls += 'text-white ring-2'; style.backgroundColor = '#f59e0b'; style.boxShadow = '0 0 0 2px #fcd34d'; }
|
|
else if (isAnswered) { cls += 'text-white'; style.backgroundColor = accentColor; }
|
|
else cls += 'bg-gray-700 text-gray-400 hover:bg-gray-600';
|
|
return <button key={q.id} onClick={() => setIndex(origIndex)} className={cls} style={style}>{origIndex + 1}</button>;
|
|
})}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
|
|
{/* Status */}
|
|
<div className="text-center text-sm mb-3">
|
|
<span style={{ color: accentColor }} className="font-semibold">{answeredCount}</span>
|
|
<span className="text-gray-500"> / {questions.length} beantwortet</span>
|
|
</div>
|
|
|
|
{/* Question */}
|
|
<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)} />}
|
|
|
|
{/* Answer area — no feedback, just selection */}
|
|
{typ === 'freitext' ? (
|
|
<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-emerald-500 outline-none"
|
|
placeholder="Deine Antwort..." value={currentSelected[0] || ''}
|
|
onChange={(e) => handleSelect(q.id, [e.target.value])} autoFocus />
|
|
) : (
|
|
<div className={`grid gap-3 grid-cols-1 sm:grid-cols-2`}>
|
|
{sanitized.antworten.map((a) => {
|
|
const isSelected = currentSelected.includes(a.key);
|
|
const cls = `${ANSWER_COLORS[a.key]} ${isSelected ? 'selected' : ''} 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}
|
|
onClick={() => {
|
|
if (isWF) handleSelect(q.id, [a.key]);
|
|
else handleSelect(q.id, isSelected ? currentSelected.filter((s) => s !== a.key) : [...currentSelected, a.key]);
|
|
}}>
|
|
{a.text}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Action buttons */}
|
|
<div className="mt-4 space-y-3">
|
|
{hasCurrentAnswer && (
|
|
<button onClick={submitCurrent} disabled={saving || timerExpired}
|
|
style={{ backgroundColor: accentColor }}
|
|
className="w-full py-3 hover:opacity-90 disabled:opacity-50 rounded-xl text-lg font-bold shadow-lg text-white">
|
|
{saving ? 'Speichere...' : 'Antwort speichern →'}
|
|
</button>
|
|
)}
|
|
<button onClick={skipQuestion}
|
|
className="w-full py-3 bg-gray-700 hover:bg-gray-600 rounded-xl text-lg font-bold">
|
|
Überspringen →
|
|
</button>
|
|
</div>
|
|
|
|
{/* Confirm button */}
|
|
<div className="mt-6 border-t border-gray-700 pt-4">
|
|
{allAnswered ? (
|
|
<button onClick={confirmResults} disabled={confirming}
|
|
className="w-full py-4 bg-red-600 hover:bg-red-700 disabled:opacity-50 rounded-xl text-xl font-bold shadow-lg shadow-red-900/30 animate-pulse">
|
|
{confirming ? 'Werte aus...' : '⚔️ Ergebnis bestätigen'}
|
|
</button>
|
|
) : (
|
|
<button disabled className="w-full py-4 bg-gray-800 opacity-40 rounded-xl text-xl font-bold cursor-not-allowed">
|
|
⚔️ Noch {questions.length - answeredCount} Fragen offen
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<ImageModal src={enlargedImg} onClose={() => setEnlargedImg(null)} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ================================================================== */
|
|
/* Wettkampf Result Page */
|
|
/* ================================================================== */
|
|
|
|
function WettkampfResultPage({ params, navigate }) {
|
|
const { results, questions, answers, setName, name, shuffledAnswersMap } = params;
|
|
const total = results.length;
|
|
const correct = results.filter((r) => r.richtig).length;
|
|
const pct = total > 0 ? Math.round((correct / total) * 100) : 0;
|
|
|
|
const qMap = {};
|
|
questions.forEach((q) => { qMap[q.id] = q; });
|
|
|
|
// Separate into wrong and right
|
|
const wrongResults = results.filter((r) => !r.richtig);
|
|
const rightResults = results.filter((r) => r.richtig);
|
|
|
|
// Resolve correct answers for a question
|
|
const resolveCorrectKeys = (question) => {
|
|
const rawCorrect = (question['Richtige Antwort'] || '').trim();
|
|
const typ = fieldVal(question['Typ'], 'MC').toLowerCase();
|
|
if (typ === 'freitext') return { keys: [], text: rawCorrect };
|
|
|
|
const parts = rawCorrect.split(',').map((s) => s.trim()).filter(Boolean);
|
|
const validKeys = new Set(['A', 'B', 'C', 'D']);
|
|
const allAreKeys = parts.length > 0 && parts.every((p) => validKeys.has(p.toUpperCase()));
|
|
|
|
if (allAreKeys) return { keys: parts.map((p) => p.toUpperCase()), text: null };
|
|
|
|
const resolved = [];
|
|
for (const part of parts) {
|
|
const partLower = part.toLowerCase().trim();
|
|
['A', 'B', 'C', 'D'].forEach((k) => {
|
|
if ((question[`Antwort ${k}`] || '').toLowerCase().trim() === partLower) resolved.push(k);
|
|
});
|
|
}
|
|
return { keys: resolved.length > 0 ? resolved : parts.map((p) => p.toUpperCase()), text: null };
|
|
};
|
|
|
|
const QuestionReview = ({ result }) => {
|
|
const question = qMap[result.questionId];
|
|
if (!question) return null;
|
|
const typ = fieldVal(question['Typ'], 'MC').toLowerCase();
|
|
const userAnswer = answers[result.questionId];
|
|
const selected = userAnswer?.selected || [];
|
|
const { keys: correctKeys, text: correctText } = resolveCorrectKeys(question);
|
|
const shuffledAntworten = shuffledAnswersMap[question.id] || [];
|
|
|
|
return (
|
|
<div className={`bg-gray-800 rounded-xl p-4 border-l-4 ${result.richtig ? 'border-green-500' : 'border-red-500'}`}>
|
|
<h4 className="font-bold mb-2">{question['Frage']}</h4>
|
|
{question['Bild'] && Array.isArray(question['Bild']) && question['Bild'].length > 0 && (
|
|
<img src={question['Bild'][0].url} className="max-h-32 rounded-lg mb-2" alt="" />
|
|
)}
|
|
{typ === 'freitext' ? (
|
|
<div className="space-y-2">
|
|
<div className={`p-3 rounded-lg text-center font-bold ${result.richtig ? 'bg-green-600 ring-2 ring-green-400' : 'bg-red-700 ring-2 ring-red-400'}`}>
|
|
{result.richtig ? '✓ ' : '✗ '}{selected[0] || '(keine Antwort)'}
|
|
</div>
|
|
{!result.richtig && correctText && (
|
|
<div className="p-3 rounded-lg text-center font-bold bg-green-600 ring-2 ring-green-400">✓ {correctText}</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="grid gap-2 grid-cols-1 sm:grid-cols-2">
|
|
{shuffledAntworten.map((a) => {
|
|
const isSelected = selected.includes(a.key);
|
|
const isCorrectAnswer = correctKeys.includes(a.key);
|
|
let cls = 'py-3 px-4 rounded-lg text-center font-bold ';
|
|
let icon = '';
|
|
if (isCorrectAnswer) { cls += 'bg-green-600 text-white ring-2 ring-green-400'; icon = '✓ '; }
|
|
else if (isSelected) { cls += 'bg-red-700 text-white ring-2 ring-red-400'; icon = '✗ '; }
|
|
else { cls += 'bg-gray-700 text-gray-500 opacity-40'; }
|
|
return <div key={a.key} className={cls}>{icon}{a.text}</div>;
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Category breakdown
|
|
const byCategory = {};
|
|
results.forEach((r) => {
|
|
const q = qMap[r.questionId];
|
|
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 (
|
|
<div className="min-h-screen flex flex-col items-center p-6 pt-12">
|
|
{/* Overall Score */}
|
|
<h2 className="text-3xl font-bold mb-2">⚔️ Wettkampf-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>
|
|
|
|
{/* Category breakdown */}
|
|
{Object.keys(byCategory).length > 1 && (
|
|
<div className="w-full max-w-md bg-gray-800 rounded-xl p-4 mb-6">
|
|
<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>
|
|
)}
|
|
|
|
{/* Wrong answers */}
|
|
{wrongResults.length > 0 && (
|
|
<div className="w-full max-w-md mb-6">
|
|
<h3 className="text-xl font-bold mb-3 text-red-400">❌ Falsch beantwortet ({wrongResults.length})</h3>
|
|
<div className="space-y-3">
|
|
{wrongResults.map((r) => <QuestionReview key={r.questionId} result={r} />)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Right answers */}
|
|
{rightResults.length > 0 && (
|
|
<div className="w-full max-w-md mb-6">
|
|
<h3 className="text-xl font-bold mb-3 text-green-400">✅ Richtig beantwortet ({rightResults.length})</h3>
|
|
<div className="space-y-3">
|
|
{rightResults.map((r) => <QuestionReview key={r.questionId} result={r} />)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="w-full max-w-md space-y-3">
|
|
<button onClick={() => navigate('wettkampf-select')}
|
|
style={{ backgroundColor: params.color || '#059669' }}
|
|
className="w-full py-3 hover:opacity-90 rounded-xl font-bold shadow-lg text-white">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));
|
|
};
|
|
|
|
const sessions = {};
|
|
if (results) {
|
|
results.forEach((r) => {
|
|
const sid = r['Session_ID'] || 'Unbekannt';
|
|
if (!sessions[sid]) sessions[sid] = { modus: fieldVal(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">
|
|
<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>
|
|
{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 cor = s.answers.filter((a) => {
|
|
const v = a['Richtig'];
|
|
return v === true || v === 'true' || v === 1;
|
|
}).length;
|
|
const points = s.answers.reduce((sum, a) => sum + (Number(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 text-white ${s.modus === 'Live' ? 'bg-red-600' : s.modus === 'Wettkampf' ? 'bg-emerald-600' : 'bg-amber-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>{cor}/{total} richtig — {points.toLocaleString('de')} Punkte</p>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</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 'wettkampf-select': return <WettkampfSelectPage navigate={navigate} />;
|
|
case 'wettkampf-quiz': return <WettkampfQuizPage params={params} navigate={navigate} />;
|
|
case 'wettkampf-result': return <WettkampfResultPage params={params} navigate={navigate} />;
|
|
case 'my-results': return <MyResultsPage navigate={navigate} />;
|
|
default: return <HomePage navigate={navigate} />;
|
|
}
|
|
}
|
|
|
|
ReactDOM.createRoot(document.getElementById('root')).render(<App />); |