Massive feature update: Solo session recovery, immediate result saving, image proxy, player reconnection (Live), and UI/UX refinements

This commit is contained in:
2026-04-16 17:53:12 +02:00
parent 247cca0803
commit 4b6e43ba65
8 changed files with 625 additions and 436 deletions
+47 -25
View File
@@ -4,8 +4,6 @@ function wsUrl() {
return `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}`;
}
const COLORS = { A: 'answer-a', B: 'answer-b', C: 'answer-c', D: 'answer-d' };
function PresentApp() {
const [phase, setPhase] = useState('connect');
const [session, setSession] = useState(null);
@@ -19,7 +17,6 @@ function PresentApp() {
const [leaderboard, setLeaderboard] = useState([]);
const [error, setError] = useState('');
const timerRef = useRef(null);
const wsRef = useRef(null);
useEffect(() => {
const code = location.hash.replace('#', '');
@@ -32,7 +29,6 @@ function PresentApp() {
}
const ws = new WebSocket(wsUrl());
wsRef.current = ws;
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'present-join', token, code }));
@@ -88,7 +84,6 @@ function PresentApp() {
};
ws.onerror = () => setPhase('error');
ws.onclose = () => { if (phase !== 'final') setPhase('error'); };
return () => ws.close();
}, []);
@@ -106,9 +101,9 @@ function PresentApp() {
if (phase === 'need-config') {
return (
<div className="min-h-screen flex flex-col items-center justify-center p-8">
<img src="/img/logo.png" alt="Quizalarm" className="w-24 h-24 mb-2" />
<div className="text-6xl mb-4">🚨</div>
<h1 className="text-3xl font-bold mb-4 fire-gradient">Quizalarm Präsentation</h1>
<p className="text-gray-400 mb-4 text-center">Öffne diesen Link aus dem Admin-Panel.<br />Die URL muss <code className="bg-gray-800 px-2 py-1 rounded">?token=PASSWORT#CODE</code> enthalten.</p>
<p className="text-gray-400 mb-4 text-center">Öffne diesen Link aus dem Admin-Panel.</p>
</div>
);
}
@@ -124,7 +119,7 @@ function PresentApp() {
if (phase === 'waiting') return (
<div className="min-h-screen flex flex-col items-center justify-center p-8">
<img src="/img/logo.png" alt="Quizalarm" className="w-24 h-24 mb-2" />
<div className="text-6xl mb-4">🚨</div>
<h1 className="text-4xl font-extrabold mb-4 fire-gradient">Quizalarm</h1>
<p className="text-gray-400 text-xl mb-6">{session?.setName}</p>
<div className="text-8xl font-mono font-extrabold tracking-widest text-red-500 mb-8">{session?.code}</div>
@@ -152,15 +147,18 @@ function PresentApp() {
<div className={`${barColor} h-full rounded-full transition-all duration-200`} style={{ width: `${pct}%` }} />
</div>
)}
<h2 className="text-4xl font-bold text-center mb-8 flex-shrink-0">{question?.frage}</h2>
<h2 className="text-4xl font-bold text-center mb-8">{question?.frage}</h2>
{question?.bild && <img src={question.bild} className="max-h-64 mx-auto rounded-xl mb-8" alt="" />}
{question?.typ?.toLowerCase() !== 'freitext' && (
<div className="grid grid-cols-2 gap-4 flex-1 max-h-96">
{(question?.antworten || []).map((a) => (
<div key={a.key} className={`${COLORS[a.key]} rounded-2xl flex items-center justify-center p-6 text-2xl font-bold`}>
{a.text}
</div>
))}
{(question?.antworten || []).map((a) => {
const colors = { A: 'bg-red-600', B: 'bg-blue-600', C: 'bg-amber-600', D: 'bg-green-600' };
return (
<div key={a.key} className={`${colors[a.key] || 'bg-gray-600'} rounded-2xl flex items-center justify-center p-6 text-2xl font-bold`}>
{a.text}
</div>
);
})}
</div>
)}
{question?.typ?.toLowerCase() === 'freitext' && (
@@ -171,20 +169,44 @@ function PresentApp() {
}
if (phase === 'result') {
const maxStat = Math.max(1, ...Object.values(result?.stats || {}));
const correctKeys = new Set(result?.correctAnswer || []);
return (
<div className="min-h-screen flex flex-col p-8">
<h2 className="text-3xl font-bold text-center mb-2">Ergebnis</h2>
<p className="text-center text-xl text-amber-400 mb-6">Richtig: {result?.correctAnswer?.join(', ')}</p>
<div className="flex gap-4 justify-center items-end mb-8 h-48">
{Object.entries(result?.stats || {}).map(([k, v]) => (
<div key={k} className="flex flex-col items-center flex-1 max-w-32">
<span className="text-xl font-bold mb-1">{v}</span>
<div className={`${COLORS[k]} w-full rounded-t-lg transition-all`} style={{ height: `${(v / maxStat) * 150}px`, minHeight: '8px' }} />
<span className="text-lg font-bold mt-1">{k}</span>
<h2 className="text-2xl text-gray-400 text-center mb-2">Frage {qIndex + 1} / {qTotal}</h2>
<h2 className="text-3xl font-bold text-center mb-6">{question?.frage}</h2>
{question?.bild && <img src={question.bild} className="max-h-48 mx-auto rounded-xl mb-6" alt="" />}
{/* Answer options with feedback colors + vote counts */}
{question?.typ?.toLowerCase() !== 'freitext' && (
<div className="grid grid-cols-2 gap-4 mb-8">
{(question?.antworten || []).map((a) => {
const isCorrect = correctKeys.has(a.key);
const count = result?.stats?.[a.key] || 0;
let cls;
if (isCorrect) {
cls = 'bg-green-600 ring-4 ring-green-400';
} else {
cls = 'bg-gray-700 opacity-50';
}
return (
<div key={a.key} className={`${cls} rounded-2xl p-6 text-2xl font-bold flex items-center justify-between`}>
<span>{isCorrect ? '✓ ' : ''}{a.text}</span>
<span className="text-3xl ml-4 opacity-80">{count}</span>
</div>
);
})}
</div>
)}
{question?.typ?.toLowerCase() === 'freitext' && (
<div className="text-center mb-8">
<div className="inline-block bg-green-600 ring-4 ring-green-400 rounded-2xl px-8 py-4 text-2xl font-bold">
{result?.correctAnswer?.[0]}
</div>
))}
</div>
</div>
)}
{/* Leaderboard */}
<div className="flex-1">
<h3 className="text-2xl font-bold text-center mb-4">🏆 Leaderboard</h3>
<div className="max-w-xl mx-auto">