vibe coding is the best

This commit is contained in:
2026-03-20 18:22:15 -05:00
parent 1075b59d2d
commit 8854e13671
6 changed files with 1988 additions and 1960 deletions

625
src/public/script.js Normal file
View File

@@ -0,0 +1,625 @@
// ══════════════════════════════════════════════════════
// GREEK ALPHABET (mirrors server for display)
// ══════════════════════════════════════════════════════
const GREEK = ["Alpha", "Beta", "Gamma", "Delta", "Epsilon", "Zeta", "Eta", "Theta", "Iota", "Kappa", "Lambda", "Mu", "Nu", "Xi", "Omicron", "Pi", "Rho", "Sigma", "Tau", "Upsilon", "Phi", "Chi", "Psi", "Omega"];
function toRoman(n) {
const vals = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1];
const syms = ["M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I"];
let out = "";
for (let i = 0; i < vals.length; i++) while (n >= vals[i]) { out += syms[i]; n -= vals[i]; }
return out;
}
function greekName(i) {
const cycle = Math.floor(i / GREEK.length);
return cycle === 0 ? GREEK[i % GREEK.length] : `${GREEK[i % GREEK.length]} ${toRoman(cycle + 1)}`;
}
// ══════════════════════════════════════════════════════
// COLORS — HSL wheel, infinite unique colors
// ══════════════════════════════════════════════════════
function teamColor(i) {
const hue = (i * 137.508) % 360; // golden angle spacing
return `hsl(${hue},90%,58%)`;
}
// ══════════════════════════════════════════════════════
// STATE
// ══════════════════════════════════════════════════════
let ws = null, role = null, room = null, myId = null;
let reconnTimer = null, reconnAttempts = 0;
let modTimerInterval = null, modTimerRemaining = 0, modTimerRunning = false;
let playerTimerRemaining = 0, playerTimerInterval = null;
// ══════════════════════════════════════════════════════
// STORAGE
// ══════════════════════════════════════════════════════
const saveMod = (id, s) => localStorage.setItem('mod', JSON.stringify({ id, s }));
const loadMod = () => { try { return JSON.parse(localStorage.getItem('mod') || 'null'); } catch { return null; } };
const clearMod = () => { localStorage.removeItem('mod'); document.getElementById('rejoin-bar').style.display = 'none'; };
const savePlay = (rid, pid, name) => localStorage.setItem('play', JSON.stringify({ rid, pid, name }));
const loadPlay = () => { try { return JSON.parse(localStorage.getItem('play') || 'null'); } catch { return null; } };
// ══════════════════════════════════════════════════════
// WEBSOCKET
// ══════════════════════════════════════════════════════
function connect(onOpen) {
if (ws && ws.readyState === WebSocket.OPEN) { onOpen?.(); return; }
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/ws`);
ws.onopen = () => { setConn(true); reconnAttempts = 0; onOpen?.(); };
ws.onmessage = e => { try { handle(JSON.parse(e.data)); } catch { } };
ws.onclose = () => { setConn(false); schedReconn(); };
ws.onerror = () => ws.close();
}
function ws_send(msg) { if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg)); }
function schedReconn() {
clearTimeout(reconnTimer);
const d = Math.min(8000, 500 * Math.pow(1.5, reconnAttempts++));
reconnTimer = setTimeout(() => {
if (role === 'mod') {
const m = loadMod(); if (m) connect(() => ws_send({ type: 'mod_rejoin', roomId: m.id, modSecret: m.s }));
} else if (role === 'player' && myId) {
const p = loadPlay(); if (p) connect(() => ws_send({ type: 'join_room', roomId: p.rid, playerName: p.name, playerId: p.pid }));
} else connect(null);
}, d);
}
// ══════════════════════════════════════════════════════
// MESSAGES
// ══════════════════════════════════════════════════════
function handle(msg) {
switch (msg.type) {
case 'room_created':
saveMod(msg.roomId, msg.modSecret);
room = msg.room; role = 'mod';
showScr('s-mod'); renderMod(); renderModSettings();
toast('Room ' + msg.roomId + ' created', 'ok');
break;
case 'mod_joined':
room = msg.room; role = 'mod';
showScr('s-mod'); renderMod(); renderModSettings();
toast('Rejoined as moderator', 'ok');
break;
case 'joined':
myId = msg.playerId; room = msg.room; role = 'player';
savePlay(room.id, myId, document.getElementById('ji-name').value || loadPlay()?.name || '');
showScr('s-player'); renderPlayer();
break;
case 'room_update':
room = msg.room;
if (role === 'mod') renderMod(); else renderPlayer();
break;
case 'settings_updated':
room = msg.room;
if (role === 'mod') { renderMod(); renderModSettings(); } else renderPlayer();
break;
case 'round_open':
room = msg.room;
if (role === 'mod') renderMod();
else { renderPlayerBuzzer(); startPlayerTimer(); toast('ROUND OPEN', 'ok'); }
break;
case 'round_closed':
room = msg.room;
if (role === 'mod') renderMod();
else { renderPlayerBuzzer(); stopPlayerTimer(); toast('ROUND CLOSED', 'warn'); }
break;
case 'buzzer_reset':
room = msg.room;
if (role === 'mod') renderMod(); else renderPlayerBuzzer();
break;
case 'buzz_event':
room = msg.room;
if (role === 'mod') renderModBuzz(msg); else { renderPlayerBuzzer(); addFeed(msg); }
break;
case 'buzz_rejected':
toast(msg.reason, 'warn'); break;
case 'kicked':
toast('Removed from room', 'err');
role = null; room = null; myId = null; localStorage.removeItem('play');
showScr('s-land'); break;
case 'room_ended':
toast('Room ended', 'warn');
role = null; room = null; myId = null;
clearMod(); localStorage.removeItem('play');
showScr('s-land'); break;
case 'mod_disconnected':
toast('Moderator disconnected', 'warn'); break;
case 'error':
toast(msg.message, 'err'); break;
}
}
// ══════════════════════════════════════════════════════
// SCREENS
// ══════════════════════════════════════════════════════
function showScr(id) {
document.querySelectorAll('.scr').forEach(s => { s.classList.remove('on'); s.style.display = 'none'; });
const el = document.getElementById(id);
el.style.display = 'flex'; el.classList.add('on');
const chip = document.getElementById('hdr-room');
if (room?.id) { chip.textContent = '[' + room.id + ']'; chip.style.display = 'block'; }
else chip.style.display = 'none';
}
function setConn(on) {
document.getElementById('cdot').className = 'conn-dot' + (on ? ' on' : '');
document.getElementById('clbl').textContent = on ? 'ONLINE' : 'OFFLINE';
}
// ══════════════════════════════════════════════════════
// LANDING
// ══════════════════════════════════════════════════════
function goSetup() { renderSetupTeamNames(); showScr('s-setup'); }
function joinRoom() {
const code = document.getElementById('ji-code').value.trim().toUpperCase();
const name = document.getElementById('ji-name').value.trim();
if (!code) { toast('Enter room code', 'err'); return; }
if (!name) { toast('Enter your name', 'err'); return; }
connect(() => ws_send({ type: 'join_room', roomId: code, playerName: name }));
}
function openRejoin() {
const m = loadMod(); if (!m) return;
document.getElementById('m-rejoin-code').textContent = m.id;
openModal('m-rejoin');
}
function doRejoin() {
const m = loadMod(); if (!m) return;
closeModal('m-rejoin');
connect(() => ws_send({ type: 'mod_rejoin', roomId: m.id, modSecret: m.s }));
}
// ══════════════════════════════════════════════════════
// SETUP PAGE
// ══════════════════════════════════════════════════════
function segSelect(groupId, el) {
document.querySelectorAll('#' + groupId + ' .seg-opt').forEach(b => b.classList.remove('active'));
el.classList.add('active');
if (groupId === 'seg-mode') {
const isTeams = el.dataset.v === 'teams';
document.getElementById('team-opts').style.display = isTeams ? 'flex' : 'none';
document.getElementById('team-opts').style.flexDirection = 'column';
}
}
function renderSetupTeamNames() {
const n = Math.max(2, Math.min(64, parseInt(document.getElementById('st-numteams').value) || 2));
const container = document.getElementById('setup-team-names');
const existing = Array.from(container.querySelectorAll('input')).map(i => i.value);
container.innerHTML = '';
for (let i = 0; i < n; i++) {
const row = document.createElement('div');
row.style.cssText = 'display:flex;align-items:center;gap:8px;';
const dot = document.createElement('div');
const c = teamColor(i);
dot.style.cssText = `width:11px;height:11px;border-radius:50%;background:${c};box-shadow:0 0 6px ${c};flex-shrink:0;`;
const inp = document.createElement('input');
inp.type = 'text'; inp.maxLength = 32;
inp.value = existing[i] || greekName(i);
inp.style.flex = '1';
row.appendChild(dot); row.appendChild(inp);
container.appendChild(row);
}
}
function toggleTimerField() {
document.getElementById('timer-field').style.display = document.getElementById('st-usetimer').checked ? 'block' : 'none';
}
function createRoom() {
const mode = document.querySelector('#seg-mode .seg-opt.active')?.dataset.v || 'individual';
const numTeams = Math.max(2, Math.min(64, parseInt(document.getElementById('st-numteams').value) || 2));
const teamNames = Array.from(document.querySelectorAll('#setup-team-names input')).map(i => i.value.trim() || greekName(0));
const useTimer = document.getElementById('st-usetimer').checked;
const timerSec = parseInt(document.getElementById('st-timersec').value) || 30;
connect(() => ws_send({
type: 'create_room',
settings: {
mode, numTeams, teamNames,
playerPickTeam: document.getElementById('st-playerpick').checked,
buzzerLockout: document.getElementById('st-lockout').checked,
showBuzzOrder: document.getElementById('st-showorder').checked,
timerSeconds: useTimer ? timerSec : 0,
}
}));
}
// ══════════════════════════════════════════════════════
// MOD TIMER
// ══════════════════════════════════════════════════════
function fmtTime(s) { return Math.floor(s / 60) + ':' + (s % 60 < 10 ? '0' : '') + (s % 60); }
function modTimerLoad() {
modTimerRemaining = Math.max(5, parseInt(document.getElementById('mod-timer-set').value) || 30);
modTimerRunning = false;
clearInterval(modTimerInterval);
document.getElementById('btn-timer-ss').textContent = 'START';
renderModTimerDisplay();
}
function modTimerReset() {
clearInterval(modTimerInterval);
modTimerRunning = false;
modTimerRemaining = Math.max(5, parseInt(document.getElementById('mod-timer-set').value) || 30);
document.getElementById('btn-timer-ss').textContent = 'START';
renderModTimerDisplay();
// broadcast reset to players
broadcastTimerToPlayers(modTimerRemaining, false);
}
function modTimerToggle() {
if (modTimerRunning) {
clearInterval(modTimerInterval); modTimerRunning = false;
document.getElementById('btn-timer-ss').textContent = 'START';
broadcastTimerToPlayers(modTimerRemaining, false);
} else {
if (modTimerRemaining <= 0) modTimerLoad();
modTimerRunning = true;
document.getElementById('btn-timer-ss').textContent = 'PAUSE';
broadcastTimerToPlayers(modTimerRemaining, true);
modTimerInterval = setInterval(() => {
modTimerRemaining--;
renderModTimerDisplay();
broadcastTimerToPlayers(modTimerRemaining, true);
if (modTimerRemaining <= 0) {
clearInterval(modTimerInterval); modTimerRunning = false;
document.getElementById('btn-timer-ss').textContent = 'START';
ws_send({ type: 'close_round' });
toast('TIME UP — round closed', 'warn');
}
}, 1000);
}
}
function renderModTimerDisplay() {
const el = document.getElementById('mod-timer-disp');
const s = modTimerRemaining;
el.textContent = fmtTime(s);
el.className = 'timer-digits' + (s <= 5 ? 'danger' : s <= 10 ? ' warn' : '');
}
// We sync timer to players via a side-channel: store in sessionStorage and poll
// (pure client-side sync — no extra server message needed)
function broadcastTimerToPlayers(sec, running) {
// encode into a BroadcastChannel so other tabs (players on same device) see it
try {
const bc = new BroadcastChannel('buzzer_timer');
bc.postMessage({ sec, running });
bc.close();
} catch { }
}
// ══════════════════════════════════════════════════════
// MOD RENDER
// ══════════════════════════════════════════════════════
function renderMod() {
if (!room) return;
document.getElementById('mod-code').textContent = room.id;
document.getElementById('mod-pcount').textContent = room.players.length + ' PLAYER' + (room.players.length !== 1 ? 'S' : '');
document.getElementById('mod-rstatus').textContent = 'ROUND: ' + (room.buzzerState.roundOpen ? 'OPEN' : 'CLOSED');
document.getElementById('lock-room-tog').checked = room.locked;
document.getElementById('lock-teams-tog').checked = room.teamLocked;
document.getElementById('pcount-badge').textContent = room.players.length;
document.getElementById('lock-teams-row').style.display = room.settings.mode === 'teams' ? 'flex' : 'none';
document.getElementById('tab-teams-btn').style.display = room.settings.mode === 'teams' ? 'block' : 'none';
if (!modTimerRunning && room.settings.timerSeconds > 0 && modTimerRemaining === 0) {
modTimerRemaining = room.settings.timerSeconds;
document.getElementById('mod-timer-set').value = room.settings.timerSeconds;
renderModTimerDisplay();
}
renderModBuzz(null);
renderModPlayerList();
renderModTeams();
}
function renderModBuzz(evt) {
const order = room?.buzzerState?.buzzOrder ?? [];
const emptyEl = document.getElementById('mod-bz-empty');
const listEl = document.getElementById('mod-bz-list');
if (order.length === 0) { emptyEl.style.display = 'block'; listEl.innerHTML = ''; return; }
emptyEl.style.display = 'none';
listEl.innerHTML = '';
const times = evt?.buzzTimes ?? {};
const firstTime = times[order[0]];
order.forEach((pid, idx) => {
const p = room.players.find(x => x.id === pid); if (!p) return;
const teamName = (room.settings.mode === 'teams' && p.teamIndex !== null) ? (room.settings.teamNames[p.teamIndex] ?? '') : null;
const color = p.teamIndex !== null ? teamColor(p.teamIndex) : 'var(--g)';
const ms = times[pid] && firstTime && idx > 0 ? '+' + (times[pid] - firstTime) + 'ms' : '';
const div = document.createElement('div');
div.className = 'bz-entry';
div.innerHTML = `
<div class="bz-rank ${idx === 0 ? 'first' : ''}">${idx + 1}</div>
<div class="bz-info">
<div class="bz-name">${esc(p.name)}</div>
${teamName ? `<div class="bz-team" style="color:${color}">${esc(teamName)}</div>` : ''}
${ms ? `<div class="bz-ms">${ms} after first</div>` : ''}
</div>
`;
listEl.appendChild(div);
});
}
function renderModPlayerList() {
if (!room) return;
const el = document.getElementById('mod-plist');
if (room.players.length === 0) { el.innerHTML = '<div class="empty">No players yet.</div>'; return; }
el.innerHTML = '';
room.players.forEach(p => {
const teamName = (room.settings.mode === 'teams' && p.teamIndex !== null) ? (room.settings.teamNames[p.teamIndex] ?? '') : null;
const color = p.teamIndex !== null ? teamColor(p.teamIndex) : null;
let teamSel = '';
if (room.settings.mode === 'teams') {
let opts = `<option value="-1" ${p.teamIndex === null ? 'selected' : ''}>No team</option>`;
for (let i = 0; i < room.settings.numTeams; i++) {
const tn = room.settings.teamNames[i] ?? greekName(i);
opts += `<option value="${i}" ${p.teamIndex === i ? 'selected' : ''}>${esc(tn)}</option>`;
}
teamSel = `<select style="margin-top:6px;font-size:12px;padding:4px 8px;" onchange="ws_send({type:'assign_team',playerId:'${p.id}',teamIndex:+this.value===-1?null:+this.value})">${opts}</select>`;
}
const row = document.createElement('div');
row.className = 'pl-row' + (p.isConnected ? '' : ' offline');
row.innerHTML = `
<div class="pl-info" style="flex:1;min-width:0;">
<div class="pl-name">${esc(p.name)} ${p.isConnected ? '' : '<span class="tag tag-red" style="font-size:9px">OFFLINE</span>'}</div>
${teamName ? `<div class="pl-meta" style="color:${color}">${esc(teamName)}</div>` : '<div class="pl-meta">No team</div>'}
${teamSel}
</div>
<div class="pl-actions">
<button class="btn btn-red btn-sm" onclick="ws_send({type:'kick_player',playerId:'${p.id}'})">KICK</button>
</div>
`;
el.appendChild(row);
});
}
function renderModTeams() {
if (!room || room.settings.mode !== 'teams') return;
const grid = document.getElementById('mod-team-grid');
grid.innerHTML = '';
for (let i = 0; i < room.settings.numTeams; i++) {
const members = room.players.filter(p => p.teamIndex === i);
const color = teamColor(i);
const name = room.settings.teamNames[i] ?? greekName(i);
const card = document.createElement('div');
card.className = 'team-card';
card.style.borderColor = color;
card.innerHTML = `
<div class="tc-n" style="color:${color}">${esc(name)}</div>
<div class="tc-c" style="color:${color}">${members.length}</div>
<div class="tc-m">${members.map(p => esc(p.name)).join('<br>') || '—'}</div>
`;
grid.appendChild(card);
}
}
function renderModSettings() {
if (!room) return;
const s = room.settings;
document.getElementById('ls-lockout').checked = s.buzzerLockout;
document.getElementById('ls-showorder').checked = s.showBuzzOrder;
document.getElementById('ls-playerpick').checked = s.playerPickTeam;
document.getElementById('ls-numteams').value = s.numTeams;
segActivate('ls-seg-mode', s.mode);
renderLiveTeamNames();
}
function segActivate(groupId, val) {
document.querySelectorAll('#' + groupId + ' .seg-opt').forEach(b => b.classList.toggle('active', b.dataset.v === val));
}
function renderLiveTeamNames() {
if (!room) return;
const container = document.getElementById('ls-team-names');
container.innerHTML = '';
for (let i = 0; i < room.settings.numTeams; i++) {
const row = document.createElement('div');
row.style.cssText = 'display:flex;align-items:center;gap:8px;';
const dot = document.createElement('div');
const c = teamColor(i);
dot.style.cssText = `width:11px;height:11px;border-radius:50%;background:${c};flex-shrink:0;`;
const inp = document.createElement('input');
inp.type = 'text'; inp.maxLength = 32;
inp.value = room.settings.teamNames[i] ?? greekName(i);
inp.onchange = () => updateLiveTeamName(i, inp.value);
row.appendChild(dot); row.appendChild(inp);
container.appendChild(row);
}
}
function updateLiveTeamName(idx, val) {
if (!room) return;
const names = [...room.settings.teamNames];
names[idx] = val.trim() || greekName(idx);
ws_send({ type: 'update_settings', settings: { teamNames: names } });
}
function pushSetting(key, val) { ws_send({ type: 'update_settings', settings: { [key]: val } }); }
// ══════════════════════════════════════════════════════
// PLAYER TIMER (receives from BroadcastChannel)
// ══════════════════════════════════════════════════════
let _bc = null;
function initPlayerTimer() {
try {
_bc = new BroadcastChannel('buzzer_timer');
_bc.onmessage = e => {
const { sec, running } = e.data;
playerTimerRemaining = sec;
clearInterval(playerTimerInterval);
renderPlayerTimer();
if (running && sec > 0) {
playerTimerInterval = setInterval(() => {
playerTimerRemaining--;
renderPlayerTimer();
if (playerTimerRemaining <= 0) clearInterval(playerTimerInterval);
}, 1000);
}
};
} catch { }
}
function stopPlayerTimer() { clearInterval(playerTimerInterval); }
function startPlayerTimer() {
// timer already synced via BroadcastChannel
}
function renderPlayerTimer() {
const el = document.getElementById('p-timer');
const s = playerTimerRemaining;
if (s <= 0 || !room?.settings.timerSeconds) { el.className = 'p-timer hidden'; return; }
el.textContent = fmtTime(s);
el.className = 'p-timer' + (s <= 5 ? ' danger' : s <= 10 ? ' warn' : '');
}
// ══════════════════════════════════════════════════════
// PLAYER RENDER
// ══════════════════════════════════════════════════════
function renderPlayer() {
if (!room) return;
document.getElementById('p-code').textContent = room.id;
const me = room.players.find(p => p.id === myId);
document.getElementById('p-namelbl').textContent = me?.name ?? '';
renderTeamPicker();
renderPlayerBuzzer();
renderRoster();
}
function renderTeamPicker() {
if (!room) return;
const s = room.settings;
const picker = document.getElementById('p-team-picker');
if (s.mode !== 'teams' || !s.playerPickTeam || room.teamLocked) { picker.style.display = 'none'; return; }
picker.style.display = 'block';
const me = room.players.find(p => p.id === myId);
const grid = document.getElementById('p-team-grid');
grid.innerHTML = '';
for (let i = 0; i < s.numTeams; i++) {
const members = room.players.filter(p => p.teamIndex === i);
const color = teamColor(i);
const isMine = me?.teamIndex === i;
const btn = document.createElement('button');
btn.className = 'team-btn' + (isMine ? ' mine' : '');
btn.style.borderColor = isMine ? color : 'var(--border)';
btn.style.color = isMine ? color : 'var(--text)';
btn.innerHTML = `<div>${esc(s.teamNames[i] ?? greekName(i))}</div><div class="tb-count">${members.length} player${members.length !== 1 ? 's' : ''}</div>`;
btn.onclick = () => ws_send({ type: 'pick_team', teamIndex: i });
grid.appendChild(btn);
}
}
function renderPlayerBuzzer() {
if (!room) return;
const btn = document.getElementById('buzz-btn');
const sts = document.getElementById('buzz-status');
const bz = room.buzzerState;
const already = bz.buzzOrder.includes(myId);
const isFirst = bz.buzzOrder[0] === myId;
if (!bz.roundOpen) {
btn.className = 's-closed'; btn.disabled = true; sts.textContent = 'WAITING FOR ROUND';
} else if (isFirst) {
btn.className = 's-first'; btn.disabled = false; sts.textContent = '⚡ YOU BUZZED FIRST!';
} else if (already) {
const pos = bz.buzzOrder.indexOf(myId) + 1;
btn.className = 's-buzzed'; btn.disabled = true; sts.textContent = 'BUZZED — #' + pos + ' IN ORDER';
} else if (room.settings.buzzerLockout && bz.buzzOrder.length > 0) {
btn.className = 's-locked'; btn.disabled = true; sts.textContent = 'BUZZER LOCKED OUT';
} else {
btn.className = 's-open'; btn.disabled = false; sts.textContent = 'ROUND OPEN — BUZZ!';
}
}
function renderRoster() {
if (!room) return;
const el = document.getElementById('p-roster');
if (room.players.length === 0) { el.innerHTML = '<div style="font-size:13px;color:var(--dim);padding:10px">No players yet.</div>'; return; }
el.innerHTML = '';
room.players.forEach(p => {
const isMe = p.id === myId;
const color = p.teamIndex !== null ? teamColor(p.teamIndex) : 'var(--g)';
const teamName = (room.settings.mode === 'teams' && p.teamIndex !== null) ? (room.settings.teamNames[p.teamIndex] ?? '') : '';
const row = document.createElement('div');
row.className = 'roster-row' + (isMe ? ' roster-me' : '');
row.innerHTML = `
<div class="roster-dot" style="background:${p.isConnected ? (isMe ? 'var(--g)' : color) : 'var(--border)'}"></div>
<div style="flex:1">${esc(p.name)}${isMe ? ' <span style="font-size:11px;color:var(--dim)">(YOU)</span>' : ''}</div>
${teamName ? `<div style="font-size:12px;color:${color}">${esc(teamName)}</div>` : ''}
`;
el.appendChild(row);
});
}
function addFeed(evt) {
const feed = document.getElementById('p-feed');
const isFirst = evt.buzzOrder?.[0] === evt.playerId;
const color = evt.teamIndex !== null ? teamColor(evt.teamIndex) : 'var(--g)';
const teamStr = (room?.settings.mode === 'teams' && evt.teamIndex !== null) ? ` [${esc(room.settings.teamNames[evt.teamIndex] ?? '')}]` : '';
const div = document.createElement('div');
div.className = 'feed-entry' + (isFirst ? ' first' : '');
div.style.borderColor = isFirst ? 'var(--yellow)' : color;
div.innerHTML = `<strong>${esc(evt.playerName)}</strong>${teamStr} buzzed${isFirst ? ' <span style="color:var(--yellow)">— FIRST!</span>' : ''}`;
feed.prepend(div);
while (feed.children.length > 30) feed.removeChild(feed.lastChild);
}
function doBuzz() { ws_send({ type: 'buzz' }); }
// ══════════════════════════════════════════════════════
// TABS
// ══════════════════════════════════════════════════════
function setTab(name, el) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('on'));
el.classList.add('on');
['buzzer', 'players', 'teams', 'settings'].forEach(t => {
const te = document.getElementById('tab-' + t);
if (te) te.style.display = t === name ? 'block' : 'none';
});
if (name === 'players') renderModPlayerList();
if (name === 'teams') renderModTeams();
if (name === 'settings') renderModSettings();
}
// ══════════════════════════════════════════════════════
// MODALS / TOAST / UTIL
// ══════════════════════════════════════════════════════
function openModal(id) { document.getElementById(id).classList.add('on'); }
function closeModal(id) { document.getElementById(id).classList.remove('on'); }
function copyCode() {
if (!room) return;
navigator.clipboard.writeText(room.id).then(() => toast('Code copied', 'ok'));
}
function toast(msg, type = '') {
const el = document.createElement('div');
el.className = 'toast ' + type; el.textContent = msg;
document.getElementById('toasts').appendChild(el);
setTimeout(() => { el.style.transition = 'opacity .3s'; el.style.opacity = '0'; setTimeout(() => el.remove(), 300); }, 2700);
}
function esc(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ══════════════════════════════════════════════════════
// KEYBOARD — SPACE TO BUZZ
// ══════════════════════════════════════════════════════
document.addEventListener('keydown', e => {
if (role !== 'player') return;
if (e.code === 'Space' || e.key === ' ') {
const tag = document.activeElement?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
e.preventDefault();
const btn = document.getElementById('buzz-btn');
if (!btn.disabled) { btn.click(); btn.style.transform = 'scale(.93)'; setTimeout(() => btn.style.transform = '', 120); }
}
});
// close modals on backdrop click
document.querySelectorAll('.modal-bg').forEach(bg => {
bg.addEventListener('click', e => { if (e.target === bg) bg.classList.remove('on'); });
});
// ══════════════════════════════════════════════════════
// INIT
// ══════════════════════════════════════════════════════
window.addEventListener('DOMContentLoaded', () => {
const m = loadMod();
if (m) { document.getElementById('rejoin-bar').style.display = 'block'; document.getElementById('m-rejoin-code').textContent = m.id; }
showScr('s-land');
connect(null);
initPlayerTimer();
renderSetupTeamNames();
// init seg mode display
const modeBtn = document.querySelector('#seg-mode .seg-opt.active');
if (modeBtn) segSelect('seg-mode', modeBtn);
});