Add per-set color picker and Wettkampf countdown timer with auto-submit
This commit is contained in:
+49
-15
@@ -60,21 +60,26 @@ function ConfigPage({ config, setConfig, saveConfig }) {
|
|||||||
const [newSetName, setNewSetName] = useState('');
|
const [newSetName, setNewSetName] = useState('');
|
||||||
const [newSetTableId, setNewSetTableId] = useState('');
|
const [newSetTableId, setNewSetTableId] = useState('');
|
||||||
const [newSetWettkampfCount, setNewSetWettkampfCount] = useState('');
|
const [newSetWettkampfCount, setNewSetWettkampfCount] = useState('');
|
||||||
|
const [newSetColor, setNewSetColor] = useState('#d97706');
|
||||||
|
const [newSetTimer, setNewSetTimer] = useState('');
|
||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
const [debugResult, setDebugResult] = useState(null);
|
const [debugResult, setDebugResult] = useState(null);
|
||||||
|
|
||||||
const addSet = () => {
|
const addSet = () => {
|
||||||
if (!newSetName || !newSetTableId) return;
|
if (!newSetName || !newSetTableId) return;
|
||||||
const wkCount = parseInt(newSetWettkampfCount) || null;
|
const wkCount = parseInt(newSetWettkampfCount) || null;
|
||||||
|
const wkTimer = parseInt(newSetTimer) || null;
|
||||||
const updated = {
|
const updated = {
|
||||||
...config,
|
...config,
|
||||||
sets: [...(config.sets || []), { id: Date.now().toString(36), name: newSetName, tableId: parseInt(newSetTableId), wettkampfCount: wkCount }],
|
sets: [...(config.sets || []), { id: Date.now().toString(36), name: newSetName, tableId: parseInt(newSetTableId), wettkampfCount: wkCount, color: newSetColor, wettkampfTimer: wkTimer }],
|
||||||
};
|
};
|
||||||
setConfig(updated);
|
setConfig(updated);
|
||||||
saveConfig(updated);
|
saveConfig(updated);
|
||||||
setNewSetName('');
|
setNewSetName('');
|
||||||
setNewSetTableId('');
|
setNewSetTableId('');
|
||||||
setNewSetWettkampfCount('');
|
setNewSetWettkampfCount('');
|
||||||
|
setNewSetColor('#d97706');
|
||||||
|
setNewSetTimer('');
|
||||||
setMsg('Fragenset hinzugefügt!');
|
setMsg('Fragenset hinzugefügt!');
|
||||||
setTimeout(() => setMsg(''), 3000);
|
setTimeout(() => setMsg(''), 3000);
|
||||||
};
|
};
|
||||||
@@ -87,10 +92,20 @@ function ConfigPage({ config, setConfig, saveConfig }) {
|
|||||||
|
|
||||||
const updateSetWettkampfCount = (setId, val) => {
|
const updateSetWettkampfCount = (setId, val) => {
|
||||||
const wkCount = parseInt(val) || null;
|
const wkCount = parseInt(val) || null;
|
||||||
const updated = {
|
const updated = { ...config, sets: config.sets.map((s) => s.id === setId ? { ...s, wettkampfCount: wkCount } : s) };
|
||||||
...config,
|
setConfig(updated);
|
||||||
sets: config.sets.map((s) => s.id === setId ? { ...s, wettkampfCount: wkCount } : s),
|
saveConfig(updated);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateSetColor = (setId, val) => {
|
||||||
|
const updated = { ...config, sets: config.sets.map((s) => s.id === setId ? { ...s, color: val } : s) };
|
||||||
|
setConfig(updated);
|
||||||
|
saveConfig(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSetTimer = (setId, val) => {
|
||||||
|
const wkTimer = parseInt(val) || null;
|
||||||
|
const updated = { ...config, sets: config.sets.map((s) => s.id === setId ? { ...s, wettkampfTimer: wkTimer } : s) };
|
||||||
setConfig(updated);
|
setConfig(updated);
|
||||||
saveConfig(updated);
|
saveConfig(updated);
|
||||||
};
|
};
|
||||||
@@ -119,21 +134,40 @@ function ConfigPage({ config, setConfig, saveConfig }) {
|
|||||||
{(config.sets || []).map((s) => (
|
{(config.sets || []).map((s) => (
|
||||||
<div key={s.id} className="py-3 border-b last:border-0">
|
<div key={s.id} className="py-3 border-b last:border-0">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div><span className="font-semibold">{s.name}</span> <span className="text-gray-400 text-sm">(Table ID: {s.tableId})</span></div>
|
<div className="flex items-center gap-2">
|
||||||
|
<div style={{ backgroundColor: s.color || '#d97706', width: 16, height: 16, borderRadius: 4, flexShrink: 0 }} />
|
||||||
|
<span className="font-semibold">{s.name}</span>
|
||||||
|
<span className="text-gray-400 text-sm">(Table ID: {s.tableId})</span>
|
||||||
|
</div>
|
||||||
<button onClick={() => removeSet(s.id)} className="text-red-500 hover:text-red-700 text-sm">Entfernen</button>
|
<button onClick={() => removeSet(s.id)} className="text-red-500 hover:text-red-700 text-sm">Entfernen</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-2">
|
<div className="flex flex-wrap items-center gap-3 mt-2">
|
||||||
<span className="text-sm text-gray-500">⚔️ Wettkampf-Fragen:</span>
|
<div className="flex items-center gap-1">
|
||||||
<input className="w-20 p-1 border rounded text-center text-sm focus:border-emerald-500 outline-none" type="number" min="1" placeholder="—" value={s.wettkampfCount || ''} onChange={(e) => updateSetWettkampfCount(s.id, e.target.value)} />
|
<span className="text-sm text-gray-500">🎨</span>
|
||||||
{s.wettkampfCount ? <span className="text-xs text-emerald-600">✓ aktiv</span> : <span className="text-xs text-gray-400">nicht konfiguriert</span>}
|
<input type="color" value={s.color || '#d97706'} onChange={(e) => updateSetColor(s.id, e.target.value)} className="w-8 h-8 border rounded cursor-pointer" style={{ padding: 0 }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-sm text-gray-500">⚔️ Fragen:</span>
|
||||||
|
<input className="w-16 p-1 border rounded text-center text-sm focus:border-emerald-500 outline-none" type="number" min="1" placeholder="—" value={s.wettkampfCount || ''} onChange={(e) => updateSetWettkampfCount(s.id, e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-sm text-gray-500">⏱ Timer (Min):</span>
|
||||||
|
<input className="w-16 p-1 border rounded text-center text-sm focus:border-emerald-500 outline-none" type="number" min="1" placeholder="—" value={s.wettkampfTimer || ''} onChange={(e) => updateSetTimer(s.id, e.target.value)} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div className="mt-4 flex gap-2 flex-wrap">
|
<div className="mt-4 space-y-2">
|
||||||
<input className="flex-1 min-w-0 p-2 border rounded-lg focus:border-red-500 outline-none" placeholder="Name" value={newSetName} onChange={(e) => setNewSetName(e.target.value)} />
|
<div className="flex gap-2 flex-wrap">
|
||||||
<input className="w-28 p-2 border rounded-lg focus:border-red-500 outline-none" placeholder="Table ID" type="number" value={newSetTableId} onChange={(e) => setNewSetTableId(e.target.value)} />
|
<input className="flex-1 min-w-0 p-2 border rounded-lg focus:border-red-500 outline-none" placeholder="Name" value={newSetName} onChange={(e) => setNewSetName(e.target.value)} />
|
||||||
<input className="w-20 p-2 border rounded-lg focus:border-emerald-500 outline-none" placeholder="⚔️ Anz." type="number" min="1" value={newSetWettkampfCount} onChange={(e) => setNewSetWettkampfCount(e.target.value)} />
|
<input className="w-24 p-2 border rounded-lg focus:border-red-500 outline-none" placeholder="Table ID" type="number" value={newSetTableId} onChange={(e) => setNewSetTableId(e.target.value)} />
|
||||||
<button onClick={addSet} className="px-4 py-2 bg-red-600 text-white rounded-lg font-bold hover:bg-red-700">+</button>
|
<input type="color" value={newSetColor} onChange={(e) => setNewSetColor(e.target.value)} className="w-10 h-10 border rounded cursor-pointer" style={{ padding: 0 }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<input className="w-24 p-2 border rounded-lg focus:border-emerald-500 outline-none" placeholder="⚔️ Anz." type="number" min="1" value={newSetWettkampfCount} onChange={(e) => setNewSetWettkampfCount(e.target.value)} />
|
||||||
|
<input className="w-28 p-2 border rounded-lg focus:border-emerald-500 outline-none" placeholder="⏱ Timer (Min)" type="number" min="1" value={newSetTimer} onChange={(e) => setNewSetTimer(e.target.value)} />
|
||||||
|
<button onClick={addSet} className="px-4 py-2 bg-red-600 text-white rounded-lg font-bold hover:bg-red-700">+ Hinzufügen</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{msg && <p className="text-green-600 text-sm mt-2">{msg}</p>}
|
{msg && <p className="text-green-600 text-sm mt-2">{msg}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+100
-35
@@ -365,19 +365,19 @@ function SoloSelectPage({ navigate }) {
|
|||||||
|
|
||||||
useEffect(() => { api.get('/api/sets').then(setSets).catch((e) => setError(e.message)); }, []);
|
useEffect(() => { api.get('/api/sets').then(setSets).catch((e) => setError(e.message)); }, []);
|
||||||
|
|
||||||
const startSet = async (setId, setName2) => {
|
const startSet = async (setId, setName2, color) => {
|
||||||
if (!name.trim()) return setError('Bitte Name eingeben');
|
if (!name.trim()) return setError('Bitte Name eingeben');
|
||||||
setChecking(true);
|
setChecking(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const progress = await api.get(`/api/progress/${encodeURIComponent(name.trim())}/${setId}`);
|
const progress = await api.get(`/api/progress/${encodeURIComponent(name.trim())}/${setId}`);
|
||||||
if (progress.hasProgress) {
|
if (progress.hasProgress) {
|
||||||
setProgressDialog({ setId, setName: setName2, ...progress });
|
setProgressDialog({ setId, setName: setName2, color, ...progress });
|
||||||
} else {
|
} else {
|
||||||
navigate('solo-quiz', { setId, setName: setName2, name: name.trim(), shuffle: doShuffle });
|
navigate('solo-quiz', { setId, setName: setName2, name: name.trim(), shuffle: doShuffle, color });
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
navigate('solo-quiz', { setId, setName: setName2, name: name.trim(), shuffle: doShuffle });
|
navigate('solo-quiz', { setId, setName: setName2, name: name.trim(), shuffle: doShuffle, color });
|
||||||
}
|
}
|
||||||
setChecking(false);
|
setChecking(false);
|
||||||
};
|
};
|
||||||
@@ -385,7 +385,7 @@ function SoloSelectPage({ navigate }) {
|
|||||||
const continueSession = () => {
|
const continueSession = () => {
|
||||||
navigate('solo-quiz', {
|
navigate('solo-quiz', {
|
||||||
setId: progressDialog.setId, setName: progressDialog.setName,
|
setId: progressDialog.setId, setName: progressDialog.setName,
|
||||||
name: name.trim(), shuffle: doShuffle,
|
name: name.trim(), shuffle: doShuffle, color: progressDialog.color,
|
||||||
continueSessionId: progressDialog.sessionId,
|
continueSessionId: progressDialog.sessionId,
|
||||||
priorAnswers: progressDialog.answers,
|
priorAnswers: progressDialog.answers,
|
||||||
});
|
});
|
||||||
@@ -395,7 +395,7 @@ function SoloSelectPage({ navigate }) {
|
|||||||
const startFresh = () => {
|
const startFresh = () => {
|
||||||
navigate('solo-quiz', {
|
navigate('solo-quiz', {
|
||||||
setId: progressDialog.setId, setName: progressDialog.setName,
|
setId: progressDialog.setId, setName: progressDialog.setName,
|
||||||
name: name.trim(), shuffle: doShuffle,
|
name: name.trim(), shuffle: doShuffle, color: progressDialog.color,
|
||||||
});
|
});
|
||||||
setProgressDialog(null);
|
setProgressDialog(null);
|
||||||
};
|
};
|
||||||
@@ -428,12 +428,16 @@ function SoloSelectPage({ navigate }) {
|
|||||||
<>
|
<>
|
||||||
<h3 className="text-lg font-semibold mt-4">Fragenset wählen:</h3>
|
<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.length === 0 && <p className="text-gray-500">Keine Fragensets konfiguriert.</p>}
|
||||||
{sets.map((s) => (
|
{sets.map((s) => {
|
||||||
<button key={s.id} onClick={() => startSet(s.id, s.name)} disabled={checking}
|
const bg = s.color || '#d97706';
|
||||||
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">
|
return (
|
||||||
{checking ? 'Prüfe...' : s.name}
|
<button key={s.id} onClick={() => startSet(s.id, s.name, s.color)} disabled={checking}
|
||||||
</button>
|
style={{ backgroundColor: bg }}
|
||||||
))}
|
className="w-full py-4 rounded-xl text-lg font-bold transition-all shadow-lg text-white hover:opacity-90 disabled:opacity-50">
|
||||||
|
{checking ? 'Prüfe...' : s.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -487,7 +491,7 @@ function SoloQuizPage({ params, navigate }) {
|
|||||||
<div className="min-h-screen flex flex-col items-center justify-center p-6">
|
<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>
|
<h2 className="text-2xl font-bold mb-4">✅ Alle Fragen bereits beantwortet!</h2>
|
||||||
<div className="space-y-3 w-full max-w-sm">
|
<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 })}
|
<button onClick={() => navigate('solo-result', { results: allResults, setName: params.setName, name: params.name, questions: allQuestions, color: params.color })}
|
||||||
className="w-full py-3 bg-amber-600 rounded-xl font-bold">Ergebnis anzeigen</button>
|
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 })}
|
<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>
|
className="w-full py-3 bg-gray-600 rounded-xl font-bold">Neue Runde starten</button>
|
||||||
@@ -584,7 +588,7 @@ function SoloQuizPage({ params, navigate }) {
|
|||||||
...priorResults.map((a) => ({ Frage_ID: a.Frage_ID, Richtig: a.Richtig, Punkte: a.Punkte })),
|
...priorResults.map((a) => ({ Frage_ID: a.Frage_ID, Richtig: a.Richtig, Punkte: a.Punkte })),
|
||||||
...results,
|
...results,
|
||||||
];
|
];
|
||||||
navigate('solo-result', { results: allResults, setName: params.setName, name: params.name, questions: allQuestions });
|
navigate('solo-result', { results: allResults, setName: params.setName, name: params.name, questions: allQuestions, color: params.color });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIndex(index + 1);
|
setIndex(index + 1);
|
||||||
@@ -601,13 +605,15 @@ function SoloQuizPage({ params, navigate }) {
|
|||||||
<span className="text-sm text-gray-400">Frage {totalProgress} / {totalAll}</span>
|
<span className="text-sm text-gray-400">Frage {totalProgress} / {totalAll}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-gray-700 rounded-full h-2 mb-4">
|
<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 className="h-full rounded-full transition-all" style={{ width: `${(totalProgress / totalAll) * 100}%`, backgroundColor: params.color || '#f59e0b' }} />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-bold mb-4 text-center">{sanitized.frage}</h2>
|
<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)} />}
|
{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} />
|
<AnswerGrid question={sanitized} selected={selected} onToggle={setSelected} disabled={showFeedback} feedback={feedback} />
|
||||||
{!showFeedback && (
|
{!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>
|
<button onClick={checkAnswer} disabled={selected.length === 0}
|
||||||
|
style={{ backgroundColor: params.color || '#d97706' }}
|
||||||
|
className="mt-4 py-3 hover:opacity-90 disabled:opacity-40 rounded-xl text-lg font-bold w-full shadow-lg text-white">Prüfen</button>
|
||||||
)}
|
)}
|
||||||
{showFeedback && (
|
{showFeedback && (
|
||||||
<div className="mt-4 text-center space-y-3">
|
<div className="mt-4 text-center space-y-3">
|
||||||
@@ -665,7 +671,9 @@ function SoloResultPage({ params, navigate }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="w-full max-w-md space-y-3">
|
<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('solo-select')}
|
||||||
|
style={{ backgroundColor: params.color || '#d97706' }}
|
||||||
|
className="w-full py-3 hover:opacity-90 rounded-xl font-bold shadow-lg text-white">Nochmal spielen</button>
|
||||||
<button onClick={() => navigate('home')} className="w-full py-3 bg-gray-700 hover:bg-gray-600 rounded-xl font-bold">Zur Startseite</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
@@ -712,10 +720,10 @@ function WettkampfSelectPage({ navigate }) {
|
|||||||
if (progress.hasProgress) {
|
if (progress.hasProgress) {
|
||||||
setProgressDialog({ ...progress, count });
|
setProgressDialog({ ...progress, count });
|
||||||
} else {
|
} else {
|
||||||
navigate('wettkampf-quiz', { setId: selectedSet.id, setName: selectedSet.name, name: name.trim(), questionCount: count });
|
navigate('wettkampf-quiz', { setId: selectedSet.id, setName: selectedSet.name, name: name.trim(), questionCount: count, color: selectedSet.color, timer: selectedSet.wettkampfTimer });
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
navigate('wettkampf-quiz', { setId: selectedSet.id, setName: selectedSet.name, name: name.trim(), questionCount: count });
|
navigate('wettkampf-quiz', { setId: selectedSet.id, setName: selectedSet.name, name: name.trim(), questionCount: count, color: selectedSet.color, timer: selectedSet.wettkampfTimer });
|
||||||
}
|
}
|
||||||
setChecking(false);
|
setChecking(false);
|
||||||
};
|
};
|
||||||
@@ -724,6 +732,7 @@ function WettkampfSelectPage({ navigate }) {
|
|||||||
navigate('wettkampf-quiz', {
|
navigate('wettkampf-quiz', {
|
||||||
setId: selectedSet.id, setName: selectedSet.name,
|
setId: selectedSet.id, setName: selectedSet.name,
|
||||||
name: name.trim(), questionCount: selectedSet.wettkampfCount,
|
name: name.trim(), questionCount: selectedSet.wettkampfCount,
|
||||||
|
color: selectedSet.color, timer: selectedSet.wettkampfTimer,
|
||||||
continueSessionId: progressDialog.sessionId,
|
continueSessionId: progressDialog.sessionId,
|
||||||
priorAnswers: progressDialog.answers,
|
priorAnswers: progressDialog.answers,
|
||||||
});
|
});
|
||||||
@@ -734,6 +743,7 @@ function WettkampfSelectPage({ navigate }) {
|
|||||||
navigate('wettkampf-quiz', {
|
navigate('wettkampf-quiz', {
|
||||||
setId: selectedSet.id, setName: selectedSet.name,
|
setId: selectedSet.id, setName: selectedSet.name,
|
||||||
name: name.trim(), questionCount: selectedSet.wettkampfCount,
|
name: name.trim(), questionCount: selectedSet.wettkampfCount,
|
||||||
|
color: selectedSet.color, timer: selectedSet.wettkampfTimer,
|
||||||
});
|
});
|
||||||
setProgressDialog(null);
|
setProgressDialog(null);
|
||||||
};
|
};
|
||||||
@@ -762,12 +772,16 @@ function WettkampfSelectPage({ navigate }) {
|
|||||||
<>
|
<>
|
||||||
<h3 className="text-lg font-semibold mt-4">Fragenset wählen:</h3>
|
<h3 className="text-lg font-semibold mt-4">Fragenset wählen:</h3>
|
||||||
{sets.length === 0 && <p className="text-gray-500">Keine Wettkampf-Sets konfiguriert. Bitte im Admin-Bereich konfigurieren.</p>}
|
{sets.length === 0 && <p className="text-gray-500">Keine Wettkampf-Sets konfiguriert. Bitte im Admin-Bereich konfigurieren.</p>}
|
||||||
{sets.map((s) => (
|
{sets.map((s) => {
|
||||||
<button key={s.id} onClick={() => selectSet(s)}
|
const bg = s.color || '#059669';
|
||||||
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">
|
return (
|
||||||
{s.name} <span className="text-emerald-200 text-sm">({s.wettkampfCount} Fragen)</span>
|
<button key={s.id} onClick={() => selectSet(s)}
|
||||||
</button>
|
style={{ backgroundColor: bg }}
|
||||||
))}
|
className="w-full py-4 rounded-xl text-lg font-bold transition-all shadow-lg text-white hover:opacity-90">
|
||||||
|
{s.name} <span className="opacity-70 text-sm">({s.wettkampfCount} Fragen)</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -783,7 +797,8 @@ function WettkampfSelectPage({ navigate }) {
|
|||||||
{error && <p className="text-red-400">{error}</p>}
|
{error && <p className="text-red-400">{error}</p>}
|
||||||
|
|
||||||
<button onClick={startWettkampf} disabled={checking}
|
<button onClick={startWettkampf} disabled={checking}
|
||||||
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">
|
style={{ backgroundColor: selectedSet.color || '#059669' }}
|
||||||
|
className="w-full py-4 hover:opacity-90 disabled:opacity-40 rounded-xl text-xl font-bold transition-all shadow-lg text-white">
|
||||||
{checking ? 'Prüfe...' : '⚔️ Wettkampf starten'}
|
{checking ? 'Prüfe...' : '⚔️ Wettkampf starten'}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
@@ -809,7 +824,12 @@ function WettkampfQuizPage({ params, navigate }) {
|
|||||||
const [shuffledAnswersMap, setShuffledAnswersMap] = useState({});
|
const [shuffledAnswersMap, setShuffledAnswersMap] = useState({});
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [confirming, setConfirming] = useState(false);
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
const [timeLeft, setTimeLeft] = useState(null); // seconds remaining
|
||||||
|
const [timerExpired, setTimerExpired] = useState(false);
|
||||||
const sessionId = useRef(params.continueSessionId || 'WK-' + Date.now().toString(36).toUpperCase());
|
const sessionId = useRef(params.continueSessionId || 'WK-' + Date.now().toString(36).toUpperCase());
|
||||||
|
const timerRef = useRef(null);
|
||||||
|
const confirmRef = useRef(null);
|
||||||
|
const accentColor = params.color || '#059669';
|
||||||
|
|
||||||
// Type-aware random selection
|
// Type-aware random selection
|
||||||
const selectQuestions = useCallback((allQuestions, count) => {
|
const selectQuestions = useCallback((allQuestions, count) => {
|
||||||
@@ -991,7 +1011,11 @@ function WettkampfQuizPage({ params, navigate }) {
|
|||||||
const finalResults = [];
|
const finalResults = [];
|
||||||
questions.forEach((question) => {
|
questions.forEach((question) => {
|
||||||
const a = answers[question.id];
|
const a = answers[question.id];
|
||||||
if (!a) return;
|
if (!a || !a.selected || a.selected.length === 0 || a.selected[0] === '') {
|
||||||
|
// Unanswered — counts as wrong
|
||||||
|
finalResults.push({ rowId: null, questionId: question.id, richtig: false, punkte: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const typ = fieldVal(question['Typ'], 'MC').toLowerCase();
|
const typ = fieldVal(question['Typ'], 'MC').toLowerCase();
|
||||||
const rawCorrect = (question['Richtige Antwort'] || '').trim();
|
const rawCorrect = (question['Richtige Antwort'] || '').trim();
|
||||||
@@ -1057,9 +1081,33 @@ function WettkampfQuizPage({ params, navigate }) {
|
|||||||
name: params.name,
|
name: params.name,
|
||||||
allQuestionsRaw: allQuestionsRaw,
|
allQuestionsRaw: allQuestionsRaw,
|
||||||
shuffledAnswersMap: shuffledAnswersMap,
|
shuffledAnswersMap: shuffledAnswersMap,
|
||||||
|
color: params.color,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Store confirmResults in ref for timer access
|
||||||
|
confirmRef.current = confirmResults;
|
||||||
|
|
||||||
|
// Countdown timer
|
||||||
|
useEffect(() => {
|
||||||
|
if (!params.timer || questions.length === 0) return;
|
||||||
|
const totalSeconds = params.timer * 60;
|
||||||
|
setTimeLeft(totalSeconds);
|
||||||
|
const start = Date.now();
|
||||||
|
timerRef.current = setInterval(() => {
|
||||||
|
const elapsed = (Date.now() - start) / 1000;
|
||||||
|
const remaining = Math.max(0, totalSeconds - elapsed);
|
||||||
|
setTimeLeft(remaining);
|
||||||
|
if (remaining <= 0) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
setTimerExpired(true);
|
||||||
|
// Auto-confirm
|
||||||
|
if (confirmRef.current) confirmRef.current();
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
return () => clearInterval(timerRef.current);
|
||||||
|
}, [questions.length, params.timer]);
|
||||||
|
|
||||||
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 (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>;
|
if (!questions.length) return <div className="min-h-screen flex items-center justify-center animate-pulse text-xl text-emerald-400">Lade Fragen...</div>;
|
||||||
|
|
||||||
@@ -1086,9 +1134,22 @@ function WettkampfQuizPage({ params, navigate }) {
|
|||||||
<span className="text-sm text-gray-400">Frage {index + 1} / {questions.length}</span>
|
<span className="text-sm text-gray-400">Frage {index + 1} / {questions.length}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Timer */}
|
||||||
|
{timeLeft !== null && (
|
||||||
|
<div className="w-full mb-3">
|
||||||
|
<div className="w-full bg-gray-700 rounded-full h-4 overflow-hidden">
|
||||||
|
<div className={`h-full rounded-full transition-all duration-200 ${timeLeft < 60 ? 'bg-red-500' : timeLeft < params.timer * 30 ? 'bg-yellow-500' : ''}`}
|
||||||
|
style={{ width: `${Math.max(0, (timeLeft / (params.timer * 60)) * 100)}%`, backgroundColor: timeLeft >= params.timer * 30 ? accentColor : undefined }} />
|
||||||
|
</div>
|
||||||
|
<div className={`text-center text-lg font-bold mt-1 ${timeLeft < 60 ? 'text-red-400 animate-pulse' : 'text-gray-300'}`}>
|
||||||
|
{Math.floor(timeLeft / 60)}:{String(Math.floor(timeLeft % 60)).padStart(2, '0')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Progress bar */}
|
{/* Progress bar */}
|
||||||
<div className="w-full bg-gray-700 rounded-full h-2 mb-3">
|
<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 className="h-full rounded-full transition-all" style={{ width: `${(answeredCount / questions.length) * 100}%`, backgroundColor: accentColor }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Question navigator */}
|
{/* Question navigator */}
|
||||||
@@ -1097,17 +1158,18 @@ function WettkampfQuizPage({ params, navigate }) {
|
|||||||
const a = answers[question.id];
|
const a = answers[question.id];
|
||||||
const isAnswered = a && a.selected && a.selected.length > 0 && a.selected[0] !== '';
|
const isAnswered = a && a.selected && a.selected.length > 0 && a.selected[0] !== '';
|
||||||
const isCurrent = i === index;
|
const isCurrent = i === index;
|
||||||
|
const style = {};
|
||||||
let cls = 'w-9 h-9 rounded-lg text-sm font-bold transition-all ';
|
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';
|
if (isCurrent) { cls += 'text-white ring-2'; style.backgroundColor = '#f59e0b'; style.boxShadow = '0 0 0 2px #fcd34d'; }
|
||||||
else if (isAnswered) cls += 'bg-emerald-600 text-white';
|
else if (isAnswered) { cls += 'text-white'; style.backgroundColor = accentColor; }
|
||||||
else cls += 'bg-gray-700 text-gray-400 hover:bg-gray-600';
|
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>;
|
return <button key={question.id} onClick={() => setIndex(i)} className={cls} style={style}>{i + 1}</button>;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status */}
|
{/* Status */}
|
||||||
<div className="text-center text-sm mb-3">
|
<div className="text-center text-sm mb-3">
|
||||||
<span className="text-emerald-400 font-semibold">{answeredCount}</span>
|
<span style={{ color: accentColor }} className="font-semibold">{answeredCount}</span>
|
||||||
<span className="text-gray-500"> / {questions.length} beantwortet</span>
|
<span className="text-gray-500"> / {questions.length} beantwortet</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1141,8 +1203,9 @@ function WettkampfQuizPage({ params, navigate }) {
|
|||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
<div className="mt-4 space-y-3">
|
<div className="mt-4 space-y-3">
|
||||||
{hasCurrentAnswer && (
|
{hasCurrentAnswer && (
|
||||||
<button onClick={submitCurrent} disabled={saving}
|
<button onClick={submitCurrent} disabled={saving || timerExpired}
|
||||||
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">
|
style={{ backgroundColor: accentColor }}
|
||||||
|
className="w-full py-3 hover:opacity-90 disabled:opacity-50 rounded-xl text-lg font-bold shadow-lg text-white">
|
||||||
{saving ? 'Speichere...' : 'Antwort speichern →'}
|
{saving ? 'Speichere...' : 'Antwort speichern →'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -1304,7 +1367,9 @@ function WettkampfResultPage({ params, navigate }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="w-full max-w-md space-y-3">
|
<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('wettkampf-select')}
|
||||||
|
style={{ backgroundColor: params.color || '#059669' }}
|
||||||
|
className="w-full py-3 hover:opacity-90 rounded-xl font-bold shadow-lg text-white">Nochmal spielen</button>
|
||||||
<button onClick={() => navigate('home')} className="w-full py-3 bg-gray-700 hover:bg-gray-600 rounded-xl font-bold">Zur Startseite</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-1
@@ -90,7 +90,7 @@ function rewriteImageUrls(rows) {
|
|||||||
|
|
||||||
app.get('/api/sets', (req, res) => {
|
app.get('/api/sets', (req, res) => {
|
||||||
const config = readConfig();
|
const config = readConfig();
|
||||||
res.json((config.sets || []).map((s) => ({ id: s.id, name: s.name, wettkampfCount: s.wettkampfCount || null })));
|
res.json((config.sets || []).map((s) => ({ id: s.id, name: s.name, wettkampfCount: s.wettkampfCount || null, color: s.color || null, wettkampfTimer: s.wettkampfTimer || null })));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/sets/:id/questions', async (req, res) => {
|
app.get('/api/sets/:id/questions', async (req, res) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user