Implement Wettkampf mode with random type-aware question selection and answer persistence
This commit is contained in:
+660
-2
@@ -11,6 +11,11 @@ const api = {
|
||||
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}`; }
|
||||
@@ -157,8 +162,9 @@ function HomePage({ navigate }) {
|
||||
<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('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('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>
|
||||
@@ -666,6 +672,655 @@ function SoloResultPage({ params, navigate }) {
|
||||
);
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* 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 */
|
||||
/* ================================================================== */
|
||||
@@ -714,7 +1369,7 @@ function MyResultsPage({ navigate }) {
|
||||
<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' : 'bg-amber-600'}`}>{s.modus}</span>
|
||||
<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>
|
||||
@@ -745,6 +1400,9 @@ function App() {
|
||||
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} />;
|
||||
}
|
||||
|
||||
+9
-1
@@ -61,4 +61,12 @@ async function batchCreateRows(tableId, rows) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listRows, createRow, batchCreateRows };
|
||||
async function updateRow(tableId, rowId, data) {
|
||||
console.log(`[baserow] Update row ${rowId} in table ${tableId}`);
|
||||
return baserowFetch(`/database/rows/table/${tableId}/${rowId}/?user_field_names=true`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { listRows, createRow, batchCreateRows, updateRow };
|
||||
+128
-4
@@ -2,7 +2,7 @@ const express = require('express');
|
||||
const http = require('http');
|
||||
const path = require('path');
|
||||
const { getEnvConfig, readConfig, writeConfig } = require('./config');
|
||||
const { listRows, createRow, batchCreateRows } = require('./baserow');
|
||||
const { listRows, createRow, batchCreateRows, updateRow } = require('./baserow');
|
||||
const { setupWebSocket, getSessions } = require('./live-session');
|
||||
const { extractFieldValue } = require('./quiz-logic');
|
||||
|
||||
@@ -106,19 +106,77 @@ app.get('/api/sets/:id/questions', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/* ---- Solo: Save single answer immediately ---- */
|
||||
app.get('/api/sets/:id/count', async (req, res) => {
|
||||
try {
|
||||
const config = readConfig();
|
||||
const set = config.sets.find((s) => s.id === req.params.id);
|
||||
if (!set) return res.status(404).json({ error: 'Fragenset nicht gefunden' });
|
||||
const rows = await listRows(set.tableId);
|
||||
res.json({ count: rows.length });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
/* ---- Solo/Wettkampf: Save single answer immediately ---- */
|
||||
app.post('/api/results/single', async (req, res) => {
|
||||
try {
|
||||
const config = readConfig();
|
||||
if (!config.answersTableId) return res.status(400).json({ error: 'Antworten-Tabelle nicht konfiguriert' });
|
||||
await createRow(config.answersTableId, req.body);
|
||||
res.json({ success: true });
|
||||
const row = await createRow(config.answersTableId, req.body);
|
||||
res.json({ success: true, id: row.id });
|
||||
} catch (e) {
|
||||
console.error('[api] Single save error:', e.message);
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
/* ---- Wettkampf: Update existing answer ---- */
|
||||
app.put('/api/results/update', async (req, res) => {
|
||||
try {
|
||||
const config = readConfig();
|
||||
if (!config.answersTableId) return res.status(400).json({ error: 'Antworten-Tabelle nicht konfiguriert' });
|
||||
const { sessionId, frageId, nutzername, antwort } = req.body;
|
||||
if (!sessionId || !frageId || !nutzername) return res.status(400).json({ error: 'sessionId, frageId und nutzername erforderlich' });
|
||||
|
||||
// Find the existing row
|
||||
const rows = await listRows(config.answersTableId, {
|
||||
Session_ID: sessionId,
|
||||
Nutzername: nutzername,
|
||||
});
|
||||
const existing = rows.find((r) => r['Frage_ID'] === frageId);
|
||||
if (!existing) return res.status(404).json({ error: 'Antwort nicht gefunden' });
|
||||
|
||||
await updateRow(config.answersTableId, existing.id, { Antwort: antwort });
|
||||
res.json({ success: true });
|
||||
} catch (e) {
|
||||
console.error('[api] Update answer error:', e.message);
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
/* ---- Wettkampf: Finalize scores ---- */
|
||||
app.put('/api/results/finalize', async (req, res) => {
|
||||
try {
|
||||
const config = readConfig();
|
||||
if (!config.answersTableId) return res.status(400).json({ error: 'Antworten-Tabelle nicht konfiguriert' });
|
||||
const { results } = req.body;
|
||||
if (!results || !results.length) return res.status(400).json({ error: 'Keine Ergebnisse' });
|
||||
|
||||
// results is array of { rowId, richtig, punkte }
|
||||
for (const r of results) {
|
||||
await updateRow(config.answersTableId, r.rowId, {
|
||||
Richtig: r.richtig,
|
||||
Punkte: r.punkte,
|
||||
});
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (e) {
|
||||
console.error('[api] Finalize error:', e.message);
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
/* ---- Solo: Check for incomplete session ---- */
|
||||
app.get('/api/progress/:name/:setId', async (req, res) => {
|
||||
try {
|
||||
@@ -187,6 +245,72 @@ app.get('/api/progress/:name/:setId', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/* ---- Wettkampf: Check for incomplete session ---- */
|
||||
app.get('/api/wettkampf-progress/:name/:setId', async (req, res) => {
|
||||
try {
|
||||
const config = readConfig();
|
||||
const set = config.sets.find((s) => s.id === req.params.setId);
|
||||
if (!set) return res.status(404).json({ error: 'Set nicht gefunden' });
|
||||
if (!config.answersTableId) return res.json({ hasProgress: false });
|
||||
|
||||
const allAnswers = await listRows(config.answersTableId, {
|
||||
Nutzername: req.params.name,
|
||||
Fragenset: set.name,
|
||||
});
|
||||
|
||||
// Filter to Wettkampf only
|
||||
const wkAnswers = allAnswers.filter((a) => {
|
||||
return extractFieldValue(a['Modus'], '') === 'Wettkampf';
|
||||
});
|
||||
|
||||
// Group by Session_ID
|
||||
const bySession = {};
|
||||
wkAnswers.forEach((a) => {
|
||||
const sid = a['Session_ID'] || '';
|
||||
if (!bySession[sid]) bySession[sid] = [];
|
||||
bySession[sid].push(a);
|
||||
});
|
||||
|
||||
// Find the most recent session where NOT all answers are finalized (Punkte is still 0 for all)
|
||||
let best = null;
|
||||
for (const [sid, answers] of Object.entries(bySession)) {
|
||||
// A session is "incomplete" if all Punkte are 0 and all Richtig are false (not yet confirmed)
|
||||
const allUnscored = answers.every((a) => {
|
||||
const punkte = Number(a['Punkte']) || 0;
|
||||
const richtig = a['Richtig'];
|
||||
return punkte === 0 && (richtig === false || richtig === 'false' || !richtig);
|
||||
});
|
||||
if (allUnscored && answers.length > 0) {
|
||||
const latest = answers.reduce((max, a) => {
|
||||
const ts = a['Zeitstempel'] || '';
|
||||
return ts > max ? ts : max;
|
||||
}, '');
|
||||
if (!best || latest > best.latest) {
|
||||
best = { sid, answers, latest };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (best) {
|
||||
res.json({
|
||||
hasProgress: true,
|
||||
sessionId: best.sid,
|
||||
answers: best.answers.map((a) => ({
|
||||
rowId: a.id,
|
||||
Frage_ID: a['Frage_ID'],
|
||||
Antwort: a['Antwort'] || '',
|
||||
})),
|
||||
totalAnswered: best.answers.length,
|
||||
});
|
||||
} else {
|
||||
res.json({ hasProgress: false });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[api] Wettkampf progress error:', e.message);
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
/* ---- Batch save (still used by live sessions) ---- */
|
||||
app.post('/api/results', async (req, res) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user