Files
ni-quiz/templates/host.html
T
2026-06-04 02:13:37 +00:00

463 lines
22 KiB
HTML

<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NI Quiz · Host</title>
<!-- QR Code library -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&family=DM+Sans:wght@400;500&display=swap');
*{margin:0;padding:0;box-sizing:border-box;}
:root{
--bg:#ffffff;--surface:#f0f4f8;--surface2:#e2e8f0;
--accent:#0077cc;--green:#00ff88;--red:#ff3355;--amber:#ffcc00;--purple:#cc44ff;
--text:#0a0f1e;--muted:#4a5568;--border:rgba(0,0,0,0.12);
--pixel:'Press Start 2P',monospace;
}
html{font-size:17px;}
html,body{width:100%;height:100vh;background:var(--bg);color:var(--text);font-family:'DM Sans',sans-serif;overflow:hidden;}
.grid-bg{position:fixed;inset:0;background-image:linear-gradient(rgba(0,119,204,0.06) 1px,transparent 1px),linear-gradient(90deg,rgba(0,119,204,0.06) 1px,transparent 1px);background-size:40px 40px;pointer-events:none;}
.screen{display:none;height:100vh;padding:1.5rem 2rem;flex-direction:column;}
.screen.active{display:flex;}
/* ── LOBBY ── */
#lobby-screen{align-items:center;justify-content:center;text-align:center;}
.logo{font-family:var(--pixel);font-size:1.6rem;color:var(--accent);text-shadow:0 0 30px var(--accent);margin-bottom:.3rem;}
.tagline{font-size:.8rem;color:var(--muted);margin-bottom:2rem;}
.lobby-main{display:flex;gap:2.5rem;align-items:flex-start;justify-content:center;width:100%;max-width:900px;}
/* QR */
.qr-box{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:1.2rem;text-align:center;min-width:200px;}
.qr-label{font-family:var(--pixel);font-size:.4rem;color:var(--muted);letter-spacing:.1em;margin-bottom:.8rem;}
#qrcode{display:flex;justify-content:center;margin-bottom:.8rem;}
#qrcode canvas,#qrcode img{border-radius:8px;}
.qr-url{font-size:.75rem;color:var(--accent);word-break:break-all;}
/* Player list */
.players-box{flex:1;background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:1.2rem;}
.players-box-title{font-family:var(--pixel);font-size:.45rem;color:var(--muted);letter-spacing:.1em;margin-bottom:.8rem;}
.player-count-big{font-family:var(--pixel);font-size:2.5rem;color:var(--accent);line-height:1;margin-bottom:.3rem;}
.player-count-sub{font-size:.75rem;color:var(--muted);margin-bottom:.8rem;}
.p-chips{display:flex;flex-wrap:wrap;gap:.4rem;max-height:120px;overflow-y:auto;}
.p-chip{padding:.25rem .6rem;border-radius:100px;background:var(--surface2);border:1px solid var(--border);font-size:.72rem;}
.start-row{display:flex;gap:.8rem;margin-top:2rem;}
.hbtn{padding:.8rem 1.8rem;border-radius:6px;font-family:var(--pixel);font-size:.5rem;letter-spacing:.08em;cursor:pointer;border:2px solid;transition:all .15s;}
.hbtn:active{transform:scale(.96);}
.hbtn-primary{border-color:var(--accent);color:#000;background:var(--accent);}
.hbtn-primary:disabled{opacity:.35;cursor:not-allowed;}
.hbtn-secondary{border-color:var(--border);color:var(--muted);background:transparent;}
.hbtn-secondary:hover{border-color:var(--muted);color:var(--text);}
.hbtn-danger{border-color:var(--red);color:var(--red);background:transparent;}
.hbtn-danger:hover{background:rgba(255,51,85,.1);}
.hbtn-warning{border-color:var(--amber);color:var(--amber);background:transparent;}
.hbtn-warning:hover{background:rgba(255,204,0,.1);}
/* ── QUESTION ── */
#question-screen{justify-content:space-between;}
.q-top{display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;}
.q-meta{font-family:var(--pixel);font-size:.5rem;color:var(--muted);}
.timer-big{font-family:var(--pixel);font-size:2.5rem;color:var(--amber);}
.timer-big.urgent{color:var(--red);animation:blink .5s step-end infinite;}
.q-body{display:flex;gap:1.5rem;flex:1;}
.q-left{flex:1;}
.q-text{margin-bottom:1.2rem;}
.q-es{font-family:var(--pixel);font-size:clamp(.55rem,1.1vw,.72rem);line-height:2;color:var(--text);display:block;}
.q-en{font-family:var(--pixel);font-size:clamp(.45rem,.9vw,.6rem);line-height:1.8;color:var(--muted);display:block;margin-top:.25rem;}
.opts-grid{display:flex;flex-direction:column;gap:.6rem;}
.opt-box{background:var(--surface);border:1.5px solid var(--border);border-radius:8px;padding:.8rem 1rem;font-size:.82rem;display:flex;align-items:center;gap:.6rem;}
.opt-box.correct{border-color:var(--green);background:rgba(0,255,136,.07);}
.opt-letter{font-family:var(--pixel);font-size:.55rem;min-width:24px;color:var(--muted);}
.q-right{width:240px;display:flex;flex-direction:column;gap:.8rem;}
.ans-box,.lb-box{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:1rem;}
.box-title{font-family:var(--pixel);font-size:.38rem;color:var(--muted);letter-spacing:.1em;margin-bottom:.6rem;}
.ans-count{font-family:var(--pixel);font-size:2rem;color:var(--accent);}
.ans-sub{font-size:.72rem;color:var(--muted);}
.ans-bar-wrap{margin-top:.6rem;height:4px;background:var(--surface2);border-radius:2px;}
.ans-bar{height:100%;background:var(--accent);border-radius:2px;transition:width .3s;}
.lb-box{flex:1;overflow:hidden;}
.lb-item{display:flex;align-items:center;gap:.5rem;margin-bottom:.45rem;font-size:.78rem;}
.lb-rank{font-family:var(--pixel);font-size:.38rem;color:var(--muted);min-width:18px;}
.lb-name{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:.78rem;}
.lb-score{font-family:var(--pixel);font-size:.38rem;color:var(--accent);}
.host-controls{display:flex;gap:.8rem;margin-top:.8rem;}
/* ── RESULTS ── */
#results-screen{justify-content:center;align-items:center;}
.res-inner{width:100%;display:flex;gap:2.5rem;align-items:flex-start;justify-content:center;}
.res-left{flex:1;max-width:480px;}
.res-q-num{font-family:var(--pixel);font-size:.4rem;color:var(--muted);letter-spacing:.1em;margin-bottom:.8rem;}
.correct-box{background:rgba(0,255,136,.06);border:1px solid rgba(0,255,136,.25);border-radius:10px;padding:1rem;margin-bottom:.8rem;}
.correct-box-lbl{font-family:var(--pixel);font-size:.38rem;color:var(--green);letter-spacing:.1em;margin-bottom:.4rem;}
.correct-box-text{font-size:.95rem;font-weight:500;}
.fun-fact-box{background:var(--surface);border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:0 10px 10px 0;padding:.9rem 1.1rem;font-size:.85rem;line-height:1.65;}
.res-right{width:260px;}
.full-lb{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:1rem;}
.full-lb-title{font-family:var(--pixel);font-size:.5rem;margin-bottom:.8rem;}
.flb-item{display:flex;align-items:center;gap:.6rem;padding:.45rem 0;border-bottom:1px solid var(--border);font-size:.82rem;}
.flb-item:last-child{border:none;}
.flb-rank{font-size:.9rem;min-width:24px;}
.flb-name{flex:1;}
.flb-score{font-family:var(--pixel);font-size:.38rem;color:var(--accent);}
.flb-dept{font-size:.68rem;color:var(--muted);}
/* ── PODIUM ── */
#podium-screen{align-items:center;justify-content:center;text-align:center;}
#podium-screen h1{font-family:var(--pixel);font-size:1.2rem;color:var(--amber);text-shadow:0 0 20px var(--amber);margin-bottom:.3rem;}
.podium-stage{display:flex;align-items:flex-end;gap:1rem;margin:1.5rem 0;}
.p-block{display:flex;flex-direction:column;align-items:center;width:160px;}
.p-avatar{font-size:1.8rem;margin-bottom:.4rem;}
.p-pname{font-family:var(--pixel);font-size:.45rem;margin-bottom:.2rem;line-height:1.5;}
.p-pdept{font-size:.7rem;color:var(--muted);margin-bottom:.4rem;}
.p-pscore{font-size:.65rem;color:var(--muted);margin-bottom:.4rem;}
.p-bar{width:100%;display:flex;align-items:center;justify-content:center;border-radius:8px 8px 0 0;font-family:var(--pixel);font-size:1.5rem;font-weight:800;}
.pb1{background:rgba(255,204,0,.15);border:1px solid rgba(255,204,0,.4);height:120px;color:var(--amber);}
.pb2{background:rgba(200,200,200,.08);border:1px solid rgba(200,200,200,.3);height:85px;color:#9ca3af;}
.pb3{background:rgba(180,120,60,.08);border:1px solid rgba(180,120,60,.3);height:60px;color:#b4783c;}
.rest-list{display:flex;flex-direction:column;gap:.4rem;max-width:380px;}
.rest-item{display:flex;align-items:center;gap:.7rem;background:var(--surface);border:1px solid var(--border);border-radius:6px;padding:.5rem .8rem;font-size:.8rem;}
.rest-rank{font-family:var(--pixel);font-size:.38rem;color:var(--muted);min-width:22px;}
.rest-name{flex:1;}
.rest-score{font-family:var(--pixel);font-size:.38rem;color:var(--accent);}
@keyframes blink{0%,100%{opacity:1}50%{opacity:0}}
@keyframes confetti{from{transform:translateY(-20px) rotate(0);opacity:1}to{transform:translateY(100vh) rotate(720deg);opacity:0}}
.cfp{position:fixed;width:8px;height:8px;border-radius:2px;animation:confetti linear forwards;pointer-events:none;}
/* ── AUTH GATE ── */
#auth-gate{
position:fixed;inset:0;z-index:9000;
background:var(--bg);
display:flex;align-items:center;justify-content:center;
}
#auth-box{
display:flex;flex-direction:column;align-items:center;gap:1rem;
background:var(--surface);border:1px solid var(--border);
border-radius:16px;padding:2.5rem 2rem;width:100%;max-width:340px;
}
#auth-logo{font-family:var(--pixel);font-size:1rem;color:var(--accent);text-shadow:0 0 20px var(--accent);}
#auth-sub{font-size:.75rem;color:var(--muted);margin-bottom:.5rem;}
#auth-input{
width:100%;padding:.85rem 1rem;
background:var(--surface2);border:1px solid var(--border);border-radius:6px;
color:var(--text);font-size:1rem;font-family:'DM Sans',sans-serif;
outline:none;text-align:center;letter-spacing:.15em;
}
#auth-input:focus{border-color:var(--accent);}
#auth-btn{
width:100%;padding:.85rem;
background:var(--accent);border:none;border-radius:6px;
color:#000;font-family:var(--pixel);font-size:.55rem;letter-spacing:.1em;
cursor:pointer;transition:opacity .15s;
}
#auth-btn:hover{opacity:.85;}
#auth-err{font-family:var(--pixel);font-size:.4rem;color:var(--red);min-height:1rem;}
</style>
</head>
<body>
<div class="grid-bg"></div>
<!-- LOBBY -->
<div class="screen active" id="lobby-screen">
<div class="logo">🌐 NI QUIZ</div>
<div class="tagline">Network Intelligence · LCPR — Host Panel</div>
<div class="lobby-main">
<div class="qr-box">
<div class="qr-label">SCAN TO PLAY</div>
<div id="qrcode"></div>
<div class="qr-url" id="join-url"></div>
</div>
<div class="players-box">
<div class="players-box-title">PLAYERS CONNECTED</div>
<div class="player-count-big" id="player-count">0</div>
<div class="player-count-sub">participants ready</div>
<div class="p-chips" id="p-chips"></div>
</div>
</div>
<div class="start-row">
<button class="hbtn hbtn-primary" id="start-btn" disabled onclick="startGame()">▶ START GAME</button>
<button class="hbtn hbtn-warning" onclick="clearLobby()">🧹 CLEAR LOBBY</button>
<button class="hbtn hbtn-danger" onclick="clearScores()">🗑 CLEAR SCORES</button>
<button class="hbtn hbtn-secondary" onclick="location.href='/scores'">📊 HISTORY</button>
</div>
</div>
<!-- QUESTION -->
<div class="screen" id="question-screen">
<div class="q-top">
<span class="q-meta" id="q-meta">Q 1 / 5</span>
<div class="timer-big" id="host-timer">15</div>
<button class="hbtn hbtn-secondary" onclick="showResults()">RESULTS →</button>
</div>
<div class="q-body">
<div class="q-left">
<div class="q-text" id="host-q-text"></div>
<div class="opts-grid" id="host-opts"></div>
</div>
<div class="q-right">
<div class="ans-box">
<div class="box-title">ANSWERED</div>
<div class="ans-count" id="ans-count">0</div>
<div class="ans-sub" id="ans-sub">of 0 players</div>
<div class="ans-bar-wrap"><div class="ans-bar" id="ans-bar" style="width:0%"></div></div>
</div>
<div class="lb-box">
<div class="box-title">LIVE RANKING</div>
<div id="live-lb"></div>
</div>
</div>
</div>
</div>
<!-- RESULTS -->
<div class="screen" id="results-screen">
<div class="res-inner">
<div class="res-left">
<div class="res-q-num" id="res-q-num">QUESTION 1</div>
<div class="correct-box">
<div class="correct-box-lbl">✓ CORRECT ANSWER</div>
<div class="correct-box-text" id="correct-ans-text"></div>
</div>
<div class="fun-fact-box" id="host-fun-fact"></div>
<div style="display:flex;gap:.8rem;margin-top:1.2rem;">
<button class="hbtn hbtn-primary" id="next-btn" onclick="nextQuestion()">NEXT →</button>
<button class="hbtn hbtn-secondary" onclick="resetGame()">RESET</button>
</div>
</div>
<div class="res-right">
<div class="full-lb">
<div class="full-lb-title">🏆 TOP 10</div>
<div id="full-lb-list"></div>
</div>
</div>
</div>
</div>
<!-- PODIUM -->
<div class="screen" id="podium-screen">
<h1>🏆 GAME OVER!</h1>
<div class="podium-stage" id="podium-stage"></div>
<div class="rest-list" id="rest-list"></div>
<div style="display:flex;gap:.8rem;margin-top:1.5rem;">
<button class="hbtn hbtn-primary" onclick="resetGame()">▶ PLAY AGAIN</button>
<button class="hbtn hbtn-secondary" onclick="location.href='/scores'">📊 FULL HISTORY</button>
</div>
</div>
<script>
let ws, currentTotal = 0, currentQ = 0, timerInterval = null;
// ── QR CODE ──────────────────────────────────────────────────────────────────
const baseUrl = `${location.protocol}//${location.hostname}${location.port?':'+location.port:''}`;
document.getElementById('join-url').textContent = baseUrl;
new QRCode(document.getElementById('qrcode'), {
text: baseUrl,
width: 160, height: 160,
colorDark: '#00c8ff', colorLight: '#0d1117',
correctLevel: QRCode.CorrectLevel.M
});
// ── WS ────────────────────────────────────────────────────────────────────────
function connectWS() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/ws/host`);
ws.onmessage = e => handleMessage(JSON.parse(e.data));
ws.onclose = () => setTimeout(connectWS, 2000);
}
function show(id) {
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
document.getElementById(id).classList.add('active');
}
function handleMessage(msg) {
if (msg.type === 'lobby_update') {
document.getElementById('player-count').textContent = msg.count;
document.getElementById('start-btn').disabled = msg.count < 1;
currentTotal = msg.count;
const chips = document.getElementById('p-chips');
chips.innerHTML = msg.players.map(p =>
`<span class="p-chip">${p.display || p.name}</span>`).join('');
}
else if (msg.type === 'question') {
currentQ = msg.number;
document.getElementById('q-meta').textContent = `Q ${msg.number} / ${msg.total}`;
const qParts = msg.q.split('\n\n[EN]');
const qEs = qParts[0].trim();
const qEn = qParts[1] ? qParts[1].trim() : '';
document.getElementById('host-q-text').innerHTML =
`<span class="q-es">${qEs}</span>${qEn ? `<span class="q-en">[EN] ${qEn}</span>` : ''}`;
const letters = ['A','B','C','D'];
const optColors = ['#5b9cf6','#cc44ff','#00ff88','#ffcc00'];
document.getElementById('host-opts').innerHTML = msg.options.map((o,i) => {
const parts = o.split(' / ');
const oEs = parts[0] || o;
const oEn = parts.slice(1).join(' / ') || '';
return `<div class="opt-box" id="hopt-${i}" style="border-color:${optColors[i]};background:${optColors[i]}18">
<span class="opt-letter" style="color:${optColors[i]}">${letters[i]}</span>
<span><span style="display:block;font-size:.83rem">${oEs}</span>${oEn ? `<span style="display:block;font-size:.7rem;color:var(--muted)">${oEn}</span>` : ''}</span>
</div>`;
}).join('');
document.getElementById('ans-count').textContent = '0';
document.getElementById('ans-sub').textContent = `of ${currentTotal} players`;
document.getElementById('ans-bar').style.width = '0%';
document.getElementById('live-lb').innerHTML = '';
show('question-screen');
startTimer(msg.time_limit);
}
else if (msg.type === 'answer_update') {
const pct = currentTotal > 0 ? (msg.answered / currentTotal * 100) : 0;
document.getElementById('ans-count').textContent = msg.answered;
document.getElementById('ans-sub').textContent = `of ${msg.total} players`;
document.getElementById('ans-bar').style.width = pct + '%';
// Live ranking from answer details
const lb = document.getElementById('live-lb');
lb.innerHTML = (msg.details || []).slice(0,10).map((d,i) =>
`<div class="lb-item">
<span class="lb-rank">${i+1}</span>
<span class="lb-name">${d.name}</span>
<span class="lb-score" style="color:${d.correct?'var(--green)':'var(--red)'}">${d.correct?d.elapsed+'s ✓':'✗'}</span>
</div>`).join('');
}
else if (msg.type === 'results') {
clearInterval(timerInterval);
document.getElementById('res-q-num').textContent = `QUESTION ${currentQ} / 5`;
document.getElementById('host-fun-fact').textContent = msg.fun_fact;
const opts = document.querySelectorAll('#host-opts .opt-box');
opts.forEach((o,i) => { if(i === msg.correct_idx) o.classList.add('correct'); });
document.getElementById('correct-ans-text').textContent =
opts[msg.correct_idx]?.textContent.trim().slice(1) || '';
document.getElementById('next-btn').textContent = currentQ >= 5 ? 'PODIUM →' : 'NEXT →';
// Top 10 leaderboard
const medals = ['🥇','🥈','🥉'];
document.getElementById('full-lb-list').innerHTML = msg.leaderboard.slice(0,10).map((p,i) =>
`<div class="flb-item">
<span class="flb-rank">${i<3?medals[i]:i+1}</span>
<span class="flb-name">${p.name}<br><span class="flb-dept">${p.dept||''}</span></span>
<span class="flb-score">${p.score.toLocaleString()}</span>
</div>`).join('');
show('results-screen');
}
else if (msg.type === 'podium') {
clearInterval(timerInterval);
buildPodium(msg.leaderboard);
}
}
function startTimer(secs) {
clearInterval(timerInterval);
let rem = secs;
const el = document.getElementById('host-timer');
function tick() {
el.textContent = rem;
el.classList.toggle('urgent', rem <= 5);
if(rem <= 0) clearInterval(timerInterval);
rem--;
}
tick();
timerInterval = setInterval(tick, 1000);
}
function startGame() { ws.send(JSON.stringify({type:'start_game'})); nextQuestion(); }
function nextQuestion() { ws.send(JSON.stringify({type:'next_question'})); }
function showResults() { clearInterval(timerInterval); ws.send(JSON.stringify({type:'show_results'})); }
function resetGame() { clearInterval(timerInterval); ws.send(JSON.stringify({type:'reset'})); show('lobby-screen'); }
async function clearScores() {
if (!confirm('¿Borrar todos los scores guardados? Esta acción no se puede deshacer.')) return;
const r = await fetch('/api/scores/clear', {method:'DELETE'});
if (r.ok) alert('Scores borrados.');
else alert('Error al borrar scores.');
}
function clearLobby() {
if (!confirm('¿Remover todos los jugadores del lobby? Los jugadores serán enviados de regreso al join screen.')) return;
ws.send(JSON.stringify({type: 'clear_lobby'}));
}
function buildPodium(lb) {
const medals = ['🥇','🥈','🥉'];
const pbClass = ['pb1','pb2','pb3'];
const order = [1,0,2];
const stage = document.getElementById('podium-stage');
stage.innerHTML = order.map(i => {
const p = lb[i]; if(!p) return '';
return `<div class="p-block">
<div class="p-avatar">${medals[i]}</div>
<div class="p-pname">${p.name}</div>
<div class="p-pdept">${p.dept||''}</div>
<div class="p-pscore">${p.score.toLocaleString()} pts</div>
<div class="p-bar ${pbClass[i]}">${i+1}</div>
</div>`;
}).join('');
document.getElementById('rest-list').innerHTML = lb.slice(3).map((p,i) =>
`<div class="rest-item">
<span class="rest-rank">${i+4}</span>
<span class="rest-name">${p.name} <span style="color:var(--muted);font-size:.72rem;">${p.dept||''}</span></span>
<span class="rest-score">${p.score.toLocaleString()}</span>
</div>`).join('');
show('podium-screen');
confetti();
}
function confetti() {
const colors = ['#00c8ff','#cc44ff','#00ff88','#ffcc00','#fff'];
for(let i=0;i<80;i++) {
setTimeout(()=>{
const el = document.createElement('div');
el.className = 'cfp';
el.style.cssText = `left:${Math.random()*100}vw;background:${colors[Math.floor(Math.random()*colors.length)]};animation-duration:${2+Math.random()*2}s;top:-10px;`;
document.body.appendChild(el);
setTimeout(()=>el.remove(), 4000);
}, i*40);
}
}
// ── AUTH ─────────────────────────────────────────────────────────────────────
const HOST_PASSWORD = 'ni2026'; // ← cambia esto
function checkAuth() {
const saved = sessionStorage.getItem('host_auth');
if (saved === HOST_PASSWORD) { connectWS(); return; }
// Build gate overlay
const gate = document.createElement('div');
gate.id = 'auth-gate';
gate.innerHTML = `
<div id="auth-box">
<div id="auth-logo">🌐 NI QUIZ</div>
<div id="auth-sub">Host Panel · Acceso restringido</div>
<input id="auth-input" type="password" placeholder="contraseña" autocomplete="off" />
<button id="auth-btn">ENTRAR →</button>
<div id="auth-err"></div>
</div>`;
document.body.appendChild(gate);
const input = document.getElementById('auth-input');
const btn = document.getElementById('auth-btn');
const err = document.getElementById('auth-err');
function attempt() {
if (input.value === HOST_PASSWORD) {
sessionStorage.setItem('host_auth', HOST_PASSWORD);
gate.remove();
connectWS();
} else {
err.textContent = 'contraseña incorrecta';
input.value = '';
input.focus();
setTimeout(() => err.textContent = '', 2000);
}
}
btn.addEventListener('click', attempt);
input.addEventListener('keydown', e => { if (e.key === 'Enter') attempt(); });
input.focus();
}
checkAuth();
</script>
</body>
</html>