Files
Quizalarm/public/js/app.js
T

1411 lines
70 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) => {
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, ...progress });
} else {
navigate('solo-quiz', { setId, setName: setName2, name: name.trim(), shuffle: doShuffle });
}
} catch (e) {
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">
<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) => (
<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 */
/* ================================================================== */
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 })}
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 });
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="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} 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>
)}
{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')} className="w-full py-3 bg-amber-600 hover:bg-amber-700 rounded-xl font-bold shadow-lg shadow-amber-900/30">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 [questionCount, setQuestionCount] = useState('');
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(setSets).catch((e) => setError(e.message)); }, []);
const selectSet = async (set) => {
setSelectedSet(set);
setQuestionCount('');
setTotalQuestions(null);
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 = parseInt(questionCount);
if (!count || count < 1) return setError('Bitte Anzahl der Fragen eingeben');
if (count > totalQuestions) return setError(`Maximal ${totalQuestions} Fragen verfügbar`);
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 });
}
} catch (e) {
navigate('wettkampf-quiz', { setId: selectedSet.id, setName: selectedSet.name, name: name.trim(), questionCount: count });
}
setChecking(false);
};
const continueSession = () => {
navigate('wettkampf-quiz', {
setId: selectedSet.id, setName: selectedSet.name,
name: name.trim(), questionCount: progressDialog.count,
continueSessionId: progressDialog.sessionId,
priorAnswers: progressDialog.answers,
});
setProgressDialog(null);
};
const startFresh = () => {
navigate('wettkampf-quiz', {
setId: selectedSet.id, setName: selectedSet.name,
name: name.trim(), questionCount: parseInt(questionCount),
});
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 Fragensets konfiguriert.</p>}
{sets.map((s) => (
<button key={s.id} onClick={() => selectSet(s)}
className="w-full py-4 bg-emerald-600 hover:bg-emerald-700 rounded-xl text-lg font-bold transition-all shadow-lg shadow-emerald-900/30">
{s.name}
</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>
</div>
<button onClick={() => { setSelectedSet(null); setTotalQuestions(null); setQuestionCount(''); }} className="text-sm text-gray-400 hover:text-white">Ändern</button>
</div>
{totalQuestions !== null && (
<div className="space-y-2">
<label className="block text-sm text-gray-400">Anzahl Fragen (max. {totalQuestions})</label>
<input type="number" min="1" max={totalQuestions}
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={`1 ${totalQuestions}`}
value={questionCount}
onChange={(e) => setQuestionCount(e.target.value)} />
</div>
)}
{error && <p className="text-red-400">{error}</p>}
<button onClick={startWettkampf} disabled={checking || !questionCount}
className="w-full py-4 bg-emerald-600 hover:bg-emerald-700 disabled:opacity-40 rounded-xl text-xl font-bold transition-all shadow-lg shadow-emerald-900/30">
{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 sessionId = useRef(params.continueSessionId || 'WK-' + Date.now().toString(36).toUpperCase());
// 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) => a.Frage_ID));
const resumeQuestions = qs.filter((q) => priorIds.has(q.id));
setQuestions(resumeQuestions);
// Restore answers state
const restored = {};
params.priorAnswers.forEach((a) => {
const ansStr = a.Antwort || '';
restored[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) 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,
});
};
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>
{/* Progress bar */}
<div className="w-full bg-gray-700 rounded-full h-2 mb-3">
<div className="bg-emerald-500 h-full rounded-full transition-all" style={{ width: `${(answeredCount / questions.length) * 100}%` }} />
</div>
{/* Question navigator */}
<div className="flex flex-wrap gap-1.5 mb-4 justify-center">
{questions.map((question, i) => {
const a = answers[question.id];
const isAnswered = a && a.selected && a.selected.length > 0 && a.selected[0] !== '';
const isCurrent = i === index;
let cls = 'w-9 h-9 rounded-lg text-sm font-bold transition-all ';
if (isCurrent) cls += 'bg-amber-500 text-white ring-2 ring-amber-300';
else if (isAnswered) cls += 'bg-emerald-600 text-white';
else cls += 'bg-gray-700 text-gray-400 hover:bg-gray-600';
return <button key={question.id} onClick={() => setIndex(i)} className={cls}>{i + 1}</button>;
})}
</div>
{/* Status */}
<div className="text-center text-sm mb-3">
<span className="text-emerald-400 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}
className="w-full py-3 bg-emerald-600 hover:bg-emerald-700 disabled:opacity-50 rounded-xl text-lg font-bold shadow-lg shadow-emerald-900/30">
{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')} className="w-full py-3 bg-emerald-600 hover:bg-emerald-700 rounded-xl font-bold shadow-lg shadow-emerald-900/30">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 />);