Files
Quizalarm/public/js/present.js
T

220 lines
10 KiB
JavaScript

const { useState, useEffect, useRef } = React;
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);
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 [answerCount, setAnswerCount] = useState(0);
const [result, setResult] = useState(null);
const [leaderboard, setLeaderboard] = useState([]);
const [error, setError] = useState('');
const timerRef = useRef(null);
const wsRef = useRef(null);
useEffect(() => {
const code = location.hash.replace('#', '');
const params = new URLSearchParams(location.search);
const token = params.get('token');
if (!code || !token) {
setPhase('need-config');
return;
}
const ws = new WebSocket(wsUrl());
wsRef.current = ws;
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'present-join', token, code }));
};
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
switch (msg.type) {
case 'present-joined':
setSession(msg.session);
setLeaderboard(msg.session.leaderboard || []);
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);
break;
case 'question':
setQuestion(msg.question);
setQIndex(msg.index);
setQTotal(msg.total);
setTimeLimit(msg.timeLimit);
setTimeLeft(msg.timeLimit);
setAnswerCount(0);
setResult(null);
setPhase('question');
break;
case 'answer-count':
setAnswerCount(msg.count);
break;
case 'time-up':
clearInterval(timerRef.current);
break;
case 'question-result':
setResult(msg);
setLeaderboard(msg.leaderboard);
setPhase('result');
clearInterval(timerRef.current);
break;
case 'game-end':
setLeaderboard(msg.leaderboard);
setPhase('final');
clearInterval(timerRef.current);
break;
case 'error':
setError(msg.message);
setPhase('error');
break;
}
};
ws.onerror = () => setPhase('error');
ws.onclose = () => { if (phase !== 'final') setPhase('error'); };
return () => ws.close();
}, []);
useEffect(() => {
clearInterval(timerRef.current);
if (phase === 'question' && timeLimit > 0) {
const start = Date.now();
timerRef.current = setInterval(() => {
setTimeLeft(Math.max(0, timeLimit - (Date.now() - start) / 1000));
}, 100);
}
}, [phase, timeLimit]);
if (phase === 'need-config') {
return (
<div className="min-h-screen flex flex-col items-center justify-center p-8">
<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>
</div>
);
}
if (phase === 'error') return (
<div className="min-h-screen flex flex-col items-center justify-center text-red-400 text-3xl">
<p> Verbindungsfehler</p>
{error && <p className="text-lg mt-2">{error}</p>}
</div>
);
if (phase === 'connect') return <div className="min-h-screen flex items-center justify-center animate-pulse text-2xl text-amber-400">Verbinde...</div>;
if (phase === 'waiting') return (
<div className="min-h-screen flex flex-col items-center justify-center p-8">
<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>
<p className="text-2xl mb-4">{session?.players?.length || 0} Teilnehmer</p>
<div className="flex flex-wrap gap-3 justify-center max-w-2xl">
{(session?.players || []).map((n) => (
<span key={n} className="px-4 py-2 bg-gray-800 rounded-full text-lg">{n}</span>
))}
</div>
</div>
);
if (phase === 'question') {
const pct = timeLimit > 0 ? (timeLeft / timeLimit) * 100 : 100;
const barColor = pct > 50 ? 'bg-green-500' : pct > 25 ? 'bg-yellow-500' : 'bg-red-500';
return (
<div className="min-h-screen flex flex-col p-8">
<div className="flex justify-between items-center mb-4">
<span className="text-gray-400 text-xl">Frage {qIndex + 1} / {qTotal}</span>
<span className="text-xl">{answerCount} / {session?.players?.length || 0} 💬</span>
{timeLimit > 0 && <span className="text-3xl font-bold text-amber-400">{Math.ceil(timeLeft)}s</span>}
</div>
{timeLimit > 0 && (
<div className="w-full bg-gray-700 rounded-full h-3 mb-6 overflow-hidden">
<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>
{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>
))}
</div>
)}
{question?.typ?.toLowerCase() === 'freitext' && (
<div className="text-center text-2xl text-gray-400">✏️ Freitext-Antwort</div>
)}
</div>
);
}
if (phase === 'result') {
const maxStat = Math.max(1, ...Object.values(result?.stats || {}));
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>
</div>
))}
</div>
<div className="flex-1">
<h3 className="text-2xl font-bold text-center mb-4">🏆 Leaderboard</h3>
<div className="max-w-xl mx-auto">
{leaderboard.slice(0, 8).map((e, i) => (
<div key={e.name} className={`flex justify-between py-3 px-5 rounded-xl mb-2 text-xl ${i === 0 ? 'bg-amber-600' : i === 1 ? 'bg-gray-500' : i === 2 ? 'bg-amber-800' : 'bg-gray-800'}`}>
<span className="font-bold">{i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${e.rank}.`} {e.name}</span>
<span className="font-extrabold">{e.score}</span>
</div>
))}
</div>
</div>
</div>
);
}
if (phase === 'final') return (
<div className="min-h-screen flex flex-col items-center justify-center p-8">
<h2 className="text-5xl font-extrabold mb-8 fire-gradient">Quiz beendet!</h2>
<div className="w-full max-w-xl">
{leaderboard.map((e, i) => (
<div key={e.name} className={`flex justify-between py-4 px-6 rounded-xl mb-2 text-2xl ${i === 0 ? 'bg-amber-600' : i === 1 ? 'bg-gray-500' : i === 2 ? 'bg-amber-800' : 'bg-gray-800'}`}>
<span className="font-bold">{i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${e.rank}.`} {e.name}</span>
<span className="font-extrabold">{e.score}</span>
</div>
))}
</div>
</div>
);
return <div className="min-h-screen flex items-center justify-center animate-pulse text-2xl text-amber-400">Verbinde...</div>;
}
ReactDOM.createRoot(document.getElementById('root')).render(<PresentApp />);