index html added
This commit is contained in:
953
src/public/index.html
Normal file
953
src/public/index.html
Normal file
@@ -0,0 +1,953 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>BUZZER//SYS</title>
|
||||
<style>
|
||||
:root {
|
||||
--green: #00ff41;
|
||||
--green-dim: #00c832;
|
||||
--green-dark: #003b0e;
|
||||
--green-muted: #0d2e14;
|
||||
--bg: #080808;
|
||||
--bg2: #0d0d0d;
|
||||
--bg3: #111;
|
||||
--panel: #0d150e;
|
||||
--border: #162e18;
|
||||
--border-hi: #00ff41;
|
||||
--text: #b8ffcc;
|
||||
--text-dim: #3d7a4d;
|
||||
--text-mid: #6db882;
|
||||
--red: #ff3b3b;
|
||||
--red-dim: #8b0000;
|
||||
--yellow: #ffe141;
|
||||
--cyan: #41ffe4;
|
||||
--font: 'Courier New', Courier, monospace;
|
||||
}
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--font); font-size: 14px; overflow-x: hidden; }
|
||||
::-webkit-scrollbar { width: 5px; } ::-webkit-scrollbar-track { background: var(--bg2); } ::-webkit-scrollbar-thumb { background: var(--green-dark); } ::-webkit-scrollbar-thumb:hover { background: var(--green-dim); }
|
||||
|
||||
/* HEADER */
|
||||
header { padding: 10px 20px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 12px; background: var(--bg2); position: sticky; top: 0; z-index: 100; }
|
||||
.logo { font-size: 16px; font-weight: bold; letter-spacing: 5px; color: var(--green); text-shadow: 0 0 10px var(--green); }
|
||||
.logo-sub { font-size: 9px; color: var(--text-dim); letter-spacing: 2px; }
|
||||
.hdr-right { margin-left: auto; display: flex; align-items: center; gap: 16px; }
|
||||
.conn-badge { display: flex; align-items: center; gap: 5px; font-size: 10px; color: var(--text-dim); letter-spacing: 1px; }
|
||||
.conn-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--red); transition: background 0.3s; }
|
||||
.conn-dot.on { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
||||
.room-badge { font-size: 10px; color: var(--green-dim); letter-spacing: 2px; display: none; }
|
||||
|
||||
/* SCREENS */
|
||||
.screen { display: none; min-height: calc(100vh - 45px); }
|
||||
.screen.active { display: block; }
|
||||
|
||||
/* LANDING */
|
||||
#s-landing { display: none; flex-direction: column; align-items: center; justify-content: center; gap: 40px; padding: 60px 20px; min-height: calc(100vh - 45px); }
|
||||
#s-landing.active { display: flex; }
|
||||
.landing-hero { text-align: center; }
|
||||
.landing-hero h1 { font-size: clamp(32px, 8vw, 72px); letter-spacing: 10px; color: var(--green); text-shadow: 0 0 30px var(--green), 0 0 60px rgba(0,255,65,0.3); font-weight: bold; }
|
||||
.landing-hero p { color: var(--text-dim); letter-spacing: 4px; font-size: 11px; margin-top: 8px; }
|
||||
.card-row { display: flex; gap: 20px; flex-wrap: wrap; justify-content: center; width: 100%; max-width: 680px; }
|
||||
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 3px; padding: 28px; flex: 1; min-width: 260px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.card:hover { border-color: var(--green-dark); }
|
||||
.card h2 { font-size: 11px; letter-spacing: 3px; color: var(--green); }
|
||||
.card p { font-size: 11px; color: var(--text-dim); line-height: 1.7; }
|
||||
|
||||
/* FORMS */
|
||||
.field { display: flex; flex-direction: column; gap: 5px; }
|
||||
label { font-size: 10px; color: var(--text-dim); letter-spacing: 1.5px; }
|
||||
input, select { background: var(--bg3); border: 1px solid var(--border); color: var(--text); font-family: var(--font); font-size: 12px; padding: 8px 10px; border-radius: 2px; width: 100%; outline: none; transition: border-color 0.15s; }
|
||||
input:focus, select:focus { border-color: var(--green-dim); }
|
||||
input::placeholder { color: var(--text-dim); }
|
||||
select option { background: var(--bg3); }
|
||||
|
||||
/* BUTTONS */
|
||||
.btn { font-family: var(--font); font-size: 11px; letter-spacing: 2px; padding: 9px 18px; border: 1px solid; border-radius: 2px; cursor: pointer; transition: all 0.15s; text-transform: uppercase; }
|
||||
.btn-primary { background: transparent; border-color: var(--green); color: var(--green); }
|
||||
.btn-primary:hover { background: var(--green); color: var(--bg); }
|
||||
.btn-danger { background: transparent; border-color: var(--red); color: var(--red); }
|
||||
.btn-danger:hover { background: var(--red); color: var(--bg); }
|
||||
.btn-ghost { background: transparent; border-color: var(--border); color: var(--text-dim); }
|
||||
.btn-ghost:hover { border-color: var(--text-mid); color: var(--text); }
|
||||
.btn-yellow { background: transparent; border-color: var(--yellow); color: var(--yellow); }
|
||||
.btn-yellow:hover { background: var(--yellow); color: var(--bg); }
|
||||
.btn-cyan { background: transparent; border-color: var(--cyan); color: var(--cyan); }
|
||||
.btn-cyan:hover { background: var(--cyan); color: var(--bg); }
|
||||
.btn-sm { padding: 5px 10px; font-size: 10px; letter-spacing: 1px; }
|
||||
.btn-full { width: 100%; }
|
||||
|
||||
/* MOD LAYOUT */
|
||||
#s-mod { display: none; }
|
||||
#s-mod.active { display: grid; grid-template-columns: 280px 1fr; grid-template-rows: auto 1fr; min-height: calc(100vh - 45px); }
|
||||
.mod-sidebar { border-right: 1px solid var(--border); background: var(--bg2); display: flex; flex-direction: column; grid-row: 1 / 3; overflow-y: auto; }
|
||||
.mod-main { padding: 20px; overflow-y: auto; }
|
||||
.sidebar-section { padding: 14px; border-bottom: 1px solid var(--border); }
|
||||
.sidebar-section h3 { font-size: 9px; letter-spacing: 3px; color: var(--text-dim); margin-bottom: 10px; }
|
||||
|
||||
/* BUZZER PANEL */
|
||||
.buzzer-panel { background: var(--panel); border: 1px solid var(--border); border-radius: 3px; padding: 20px; margin-bottom: 16px; }
|
||||
.buzzer-panel h2 { font-size: 11px; letter-spacing: 3px; color: var(--green); margin-bottom: 14px; }
|
||||
.round-controls { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
/* BUZZ ORDER */
|
||||
.buzz-list { display: flex; flex-direction: column; gap: 6px; margin-top: 12px; }
|
||||
.buzz-entry { display: flex; align-items: center; gap: 10px; padding: 8px 12px; background: var(--bg3); border: 1px solid var(--border); border-radius: 2px; }
|
||||
.buzz-rank { font-size: 18px; font-weight: bold; color: var(--green); width: 28px; flex-shrink: 0; }
|
||||
.buzz-rank.first { color: var(--yellow); text-shadow: 0 0 8px var(--yellow); }
|
||||
.buzz-name { flex: 1; font-size: 13px; }
|
||||
.buzz-team { font-size: 10px; color: var(--text-dim); }
|
||||
.buzz-actions { display: flex; gap: 5px; }
|
||||
|
||||
/* PLAYERS TABLE */
|
||||
.players-panel { background: var(--panel); border: 1px solid var(--border); border-radius: 3px; padding: 20px; }
|
||||
.players-panel h2 { font-size: 11px; letter-spacing: 3px; color: var(--green); margin-bottom: 14px; display: flex; align-items: center; justify-content: space-between; }
|
||||
.player-row { display: grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--border); }
|
||||
.player-row:last-child { border-bottom: none; }
|
||||
.player-info { display: flex; flex-direction: column; gap: 2px; }
|
||||
.player-name { font-size: 12px; }
|
||||
.player-meta { font-size: 10px; color: var(--text-dim); }
|
||||
.player-offline { opacity: 0.4; }
|
||||
.score-cell { font-size: 16px; font-weight: bold; color: var(--green); min-width: 60px; text-align: right; }
|
||||
.score-adj { display: flex; gap: 4px; }
|
||||
.team-pill { display: inline-block; padding: 1px 7px; border-radius: 10px; font-size: 9px; letter-spacing: 1px; border: 1px solid; }
|
||||
|
||||
/* SETTINGS PANEL */
|
||||
.settings-panel { background: var(--panel); border: 1px solid var(--border); border-radius: 3px; padding: 20px; margin-bottom: 16px; }
|
||||
.settings-panel h2 { font-size: 11px; letter-spacing: 3px; color: var(--green); margin-bottom: 16px; }
|
||||
.settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
@media (max-width: 500px) { .settings-grid { grid-template-columns: 1fr; } }
|
||||
.toggle-row { display: flex; align-items: center; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid var(--border); }
|
||||
.toggle-row label { font-size: 11px; color: var(--text); letter-spacing: 0; cursor: pointer; }
|
||||
.toggle { position: relative; width: 36px; height: 18px; flex-shrink: 0; }
|
||||
.toggle input { opacity: 0; width: 0; height: 0; position: absolute; }
|
||||
.toggle-track { position: absolute; inset: 0; background: var(--bg3); border: 1px solid var(--border); border-radius: 10px; cursor: pointer; transition: background 0.2s; }
|
||||
.toggle input:checked + .toggle-track { background: var(--green-dark); border-color: var(--green-dim); }
|
||||
.toggle-track::after { content: ''; position: absolute; width: 12px; height: 12px; border-radius: 50%; background: var(--text-dim); top: 2px; left: 2px; transition: all 0.2s; }
|
||||
.toggle input:checked + .toggle-track::after { background: var(--green); left: 20px; }
|
||||
.team-name-row { display: flex; gap: 6px; align-items: center; margin-bottom: 6px; }
|
||||
.team-name-row input { flex: 1; }
|
||||
.team-color-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||
|
||||
/* PLAYER SCREEN */
|
||||
#s-player { display: none; }
|
||||
#s-player.active { display: flex; flex-direction: column; align-items: center; padding: 30px 20px; gap: 24px; min-height: calc(100vh - 45px); }
|
||||
.player-room-info { text-align: center; }
|
||||
.player-room-code { font-size: 28px; letter-spacing: 8px; color: var(--green); font-weight: bold; }
|
||||
.player-room-label { font-size: 10px; color: var(--text-dim); letter-spacing: 2px; margin-top: 4px; }
|
||||
|
||||
/* THE BUZZER BUTTON */
|
||||
.buzzer-wrap { display: flex; flex-direction: column; align-items: center; gap: 16px; }
|
||||
#buzz-btn {
|
||||
width: clamp(180px, 40vw, 260px); height: clamp(180px, 40vw, 260px);
|
||||
border-radius: 50%; border: 3px solid var(--green);
|
||||
background: var(--green-muted); color: var(--green);
|
||||
font-family: var(--font); font-size: clamp(14px, 3vw, 20px);
|
||||
letter-spacing: 4px; cursor: pointer; text-transform: uppercase;
|
||||
transition: all 0.1s; box-shadow: 0 0 30px rgba(0,255,65,0.15), inset 0 0 30px rgba(0,255,65,0.05);
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
#buzz-btn:hover:not(:disabled) { box-shadow: 0 0 50px rgba(0,255,65,0.3), inset 0 0 40px rgba(0,255,65,0.1); border-color: var(--green); }
|
||||
#buzz-btn:active:not(:disabled) { transform: scale(0.96); }
|
||||
#buzz-btn:disabled { cursor: not-allowed; }
|
||||
#buzz-btn.state-open { border-color: var(--green); color: var(--green); }
|
||||
#buzz-btn.state-buzzed { border-color: var(--yellow); color: var(--yellow); background: rgba(255,225,65,0.05); box-shadow: 0 0 50px rgba(255,225,65,0.25); }
|
||||
#buzz-btn.state-first { border-color: var(--yellow); color: var(--yellow); animation: pulse-win 0.8s ease-in-out infinite alternate; }
|
||||
#buzz-btn.state-locked { border-color: var(--red); color: var(--red); background: rgba(255,59,59,0.05); }
|
||||
#buzz-btn.state-closed { border-color: var(--border); color: var(--text-dim); background: transparent; }
|
||||
@keyframes pulse-win { from { box-shadow: 0 0 30px rgba(255,225,65,0.3); } to { box-shadow: 0 0 80px rgba(255,225,65,0.7); } }
|
||||
.buzz-status { font-size: 12px; letter-spacing: 2px; color: var(--text-dim); text-align: center; }
|
||||
|
||||
/* SCOREBOARD (player view) */
|
||||
.scoreboard { width: 100%; max-width: 480px; background: var(--panel); border: 1px solid var(--border); border-radius: 3px; padding: 16px; }
|
||||
.scoreboard h3 { font-size: 10px; letter-spacing: 3px; color: var(--text-dim); margin-bottom: 12px; }
|
||||
.score-row { display: flex; align-items: center; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 12px; }
|
||||
.score-row:last-child { border-bottom: none; }
|
||||
.score-row.me { color: var(--green); }
|
||||
.score-val { font-weight: bold; font-size: 14px; }
|
||||
|
||||
/* BUZZ FEED (player) */
|
||||
.buzz-feed { width: 100%; max-width: 480px; }
|
||||
.buzz-feed h3 { font-size: 10px; letter-spacing: 3px; color: var(--text-dim); margin-bottom: 10px; }
|
||||
.feed-entry { padding: 8px 12px; background: var(--bg2); border-left: 2px solid var(--green); margin-bottom: 6px; font-size: 12px; animation: slideIn 0.2s ease; }
|
||||
@keyframes slideIn { from { transform: translateX(-10px); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
||||
.feed-entry.first { border-color: var(--yellow); }
|
||||
|
||||
/* MODAL */
|
||||
.modal-bg { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.85); z-index: 500; align-items: center; justify-content: center; }
|
||||
.modal-bg.open { display: flex; }
|
||||
.modal { background: var(--bg2); border: 1px solid var(--border-hi); border-radius: 3px; padding: 28px; width: 90%; max-width: 480px; display: flex; flex-direction: column; gap: 16px; }
|
||||
.modal h2 { font-size: 12px; letter-spacing: 3px; color: var(--green); }
|
||||
.modal-btns { display: flex; gap: 8px; justify-content: flex-end; }
|
||||
|
||||
/* TOAST */
|
||||
#toast-container { position: fixed; top: 56px; right: 16px; z-index: 999; display: flex; flex-direction: column; gap: 8px; pointer-events: none; }
|
||||
.toast { padding: 9px 16px; background: var(--bg2); border: 1px solid var(--border); font-size: 11px; letter-spacing: 1px; color: var(--text); border-radius: 2px; animation: toastIn 0.2s ease; max-width: 300px; }
|
||||
.toast.success { border-color: var(--green); color: var(--green); }
|
||||
.toast.error { border-color: var(--red); color: var(--red); }
|
||||
.toast.warn { border-color: var(--yellow); color: var(--yellow); }
|
||||
@keyframes toastIn { from { transform: translateX(20px); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
||||
|
||||
/* TABS */
|
||||
.tab-bar { display: flex; border-bottom: 1px solid var(--border); margin-bottom: 16px; }
|
||||
.tab { padding: 8px 16px; font-size: 10px; letter-spacing: 2px; color: var(--text-dim); cursor: pointer; border-bottom: 2px solid transparent; transition: all 0.15s; }
|
||||
.tab:hover { color: var(--text); }
|
||||
.tab.active { color: var(--green); border-bottom-color: var(--green); }
|
||||
|
||||
/* TEAM SCORES (team mode) */
|
||||
.team-scores { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 10px; margin-bottom: 16px; }
|
||||
.team-score-card { background: var(--panel); border: 1px solid var(--border); border-radius: 3px; padding: 14px; text-align: center; }
|
||||
.team-score-card .ts-name { font-size: 10px; letter-spacing: 2px; margin-bottom: 6px; }
|
||||
.team-score-card .ts-val { font-size: 28px; font-weight: bold; }
|
||||
|
||||
/* MISC */
|
||||
.divider { border: none; border-top: 1px solid var(--border); }
|
||||
.tag { display: inline-block; padding: 2px 8px; font-size: 9px; letter-spacing: 1px; border: 1px solid; border-radius: 2px; }
|
||||
.tag-green { border-color: var(--green-dim); color: var(--green-dim); }
|
||||
.tag-red { border-color: var(--red); color: var(--red); }
|
||||
.tag-yellow { border-color: var(--yellow); color: var(--yellow); }
|
||||
.empty-state { color: var(--text-dim); font-size: 11px; letter-spacing: 1px; text-align: center; padding: 20px; }
|
||||
.monospace { font-family: var(--font); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header>
|
||||
<div>
|
||||
<div class="logo">BUZZER</div>
|
||||
<div class="logo-sub">// QUIZ CONTROL SYS</div>
|
||||
</div>
|
||||
<div class="hdr-right">
|
||||
<div class="room-badge" id="hdr-room"></div>
|
||||
<div class="conn-badge">
|
||||
<div class="conn-dot" id="conn-dot"></div>
|
||||
<span id="conn-label">OFFLINE</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- LANDING -->
|
||||
<div class="screen" id="s-landing">
|
||||
<div class="landing-hero">
|
||||
<h1>BUZZER</h1>
|
||||
<p>REAL-TIME QUIZ CONTROL SYSTEM</p>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<div class="card">
|
||||
<h2>// HOST SESSION</h2>
|
||||
<p>Create a room and act as moderator. Control the buzzer, manage teams, and track scores.</p>
|
||||
<button class="btn btn-primary" onclick="showModSetup()">CREATE ROOM</button>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>// JOIN SESSION</h2>
|
||||
<p>Join an existing room with a room code. Enter your name and start buzzing.</p>
|
||||
<div class="field">
|
||||
<label>ROOM CODE</label>
|
||||
<input id="join-code" maxlength="8" placeholder="XXXXXX" style="text-transform:uppercase;letter-spacing:4px;" oninput="this.value=this.value.toUpperCase()"/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>YOUR NAME</label>
|
||||
<input id="join-name" maxlength="24" placeholder="Enter name..."/>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="joinRoom()">JOIN ROOM</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:10px;color:var(--text-dim);letter-spacing:1px;">
|
||||
<span id="rejoin-link" style="display:none;">
|
||||
<a href="#" onclick="showRejoinModal()" style="color:var(--green-dim);text-decoration:none;">↩ REJOIN PREVIOUS SESSION</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MOD VIEW -->
|
||||
<div class="screen" id="s-mod">
|
||||
<div class="mod-sidebar">
|
||||
<div class="sidebar-section">
|
||||
<h3>SESSION</h3>
|
||||
<div style="font-size:22px;letter-spacing:6px;color:var(--green);font-weight:bold;" id="mod-room-code">──────</div>
|
||||
<div style="font-size:10px;color:var(--text-dim);margin-top:4px;margin-bottom:10px;">SHARE CODE WITH PLAYERS</div>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap;">
|
||||
<button class="btn btn-ghost btn-sm" onclick="copyRoomCode()">COPY CODE</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="confirmEndRoom()">END ROOM</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-section">
|
||||
<h3>BUZZER</h3>
|
||||
<div style="display:flex;flex-direction:column;gap:6px;">
|
||||
<button class="btn btn-primary btn-full" id="btn-open" onclick="modSend('open_round')">▶ OPEN ROUND</button>
|
||||
<button class="btn btn-ghost btn-full" id="btn-close" onclick="modSend('close_round')">■ CLOSE ROUND</button>
|
||||
<button class="btn btn-yellow btn-full" onclick="modSend('reset_buzzer')">↺ RESET BUZZER</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-section">
|
||||
<h3>ROOM CONTROLS</h3>
|
||||
<div class="toggle-row">
|
||||
<label for="lock-toggle">LOCK ROOM</label>
|
||||
<label class="toggle"><input type="checkbox" id="lock-toggle" onchange="modSend('lock_room',{locked:this.checked})"><span class="toggle-track"></span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-section">
|
||||
<h3>DANGER ZONE</h3>
|
||||
<div style="display:flex;flex-direction:column;gap:6px;">
|
||||
<button class="btn btn-ghost btn-sm btn-full" onclick="modSend('reset_scores')">RESET ALL SCORES</button>
|
||||
<button class="btn btn-ghost btn-sm btn-full" onclick="modSend('reset_teams')">CLEAR ALL TEAMS</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex:1;"></div>
|
||||
<div class="sidebar-section" style="font-size:10px;color:var(--text-dim);">
|
||||
<div id="mod-player-count">0 PLAYERS</div>
|
||||
<div id="mod-round-status" style="margin-top:4px;">ROUND: CLOSED</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mod-main">
|
||||
<div class="tab-bar">
|
||||
<div class="tab active" onclick="setTab('buzzer')">BUZZER</div>
|
||||
<div class="tab" onclick="setTab('players')">PLAYERS</div>
|
||||
<div class="tab" onclick="setTab('settings')">SETTINGS</div>
|
||||
</div>
|
||||
|
||||
<!-- BUZZER TAB -->
|
||||
<div id="tab-buzzer">
|
||||
<div id="mod-team-scores" style="display:none;"></div>
|
||||
<div class="buzzer-panel">
|
||||
<h2>// BUZZ ORDER</h2>
|
||||
<div id="mod-buzz-status" class="empty-state">No buzzes yet — open a round to begin.</div>
|
||||
<div class="buzz-list" id="mod-buzz-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PLAYERS TAB -->
|
||||
<div id="tab-players" style="display:none;">
|
||||
<div class="players-panel">
|
||||
<h2>
|
||||
<span>// PLAYERS</span>
|
||||
<span id="player-count-badge" class="tag tag-green">0</span>
|
||||
</h2>
|
||||
<div id="mod-player-list"><div class="empty-state">No players connected.</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SETTINGS TAB -->
|
||||
<div id="tab-settings" style="display:none;">
|
||||
<div class="settings-panel">
|
||||
<h2>// GAME SETTINGS</h2>
|
||||
<div style="display:flex;flex-direction:column;gap:0;">
|
||||
<div class="toggle-row">
|
||||
<label>TEAM MODE</label>
|
||||
<label class="toggle"><input type="checkbox" id="st-teamMode" onchange="pushSettings()"><span class="toggle-track"></span></label>
|
||||
</div>
|
||||
<div class="toggle-row">
|
||||
<label>BUZZER LOCKOUT (first only)</label>
|
||||
<label class="toggle"><input type="checkbox" id="st-buzzerLockout" onchange="pushSettings()"><span class="toggle-track"></span></label>
|
||||
</div>
|
||||
<div class="toggle-row">
|
||||
<label>SHOW FULL BUZZ ORDER</label>
|
||||
<label class="toggle"><input type="checkbox" id="st-showOrder" onchange="pushSettings()"><span class="toggle-track"></span></label>
|
||||
</div>
|
||||
<div class="toggle-row">
|
||||
<label>ALLOW RE-BUZZ</label>
|
||||
<label class="toggle"><input type="checkbox" id="st-allowRebuzz" onchange="pushSettings()"><span class="toggle-track"></span></label>
|
||||
</div>
|
||||
<div class="toggle-row">
|
||||
<label>NEGATIVE SCORING</label>
|
||||
<label class="toggle"><input type="checkbox" id="st-pointsNeg" onchange="pushSettings()"><span class="toggle-track"></span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-grid" style="margin-top:16px;">
|
||||
<div class="field">
|
||||
<label>POINTS FOR CORRECT</label>
|
||||
<input type="number" id="st-pointsCorrect" min="0" max="10000" value="100" onchange="pushSettings()"/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>POINTS FOR INCORRECT</label>
|
||||
<input type="number" id="st-pointsIncorrect" min="0" max="10000" value="0" onchange="pushSettings()"/>
|
||||
</div>
|
||||
<div class="field" id="num-teams-field">
|
||||
<label>NUMBER OF TEAMS</label>
|
||||
<select id="st-numTeams" onchange="pushSettings(); renderTeamNames();">
|
||||
<option value="2">2</option><option value="3">3</option><option value="4">4</option>
|
||||
<option value="5">5</option><option value="6">6</option><option value="7">7</option><option value="8">8</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div id="team-names-section" style="margin-top:16px;display:none;">
|
||||
<label style="margin-bottom:8px;display:block;">TEAM NAMES</label>
|
||||
<div id="team-name-inputs"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PLAYER VIEW -->
|
||||
<div class="screen" id="s-player">
|
||||
<div class="player-room-info">
|
||||
<div class="player-room-label">ROOM CODE</div>
|
||||
<div class="player-room-code" id="p-room-code">──────</div>
|
||||
<div style="font-size:11px;color:var(--text-dim);margin-top:6px;" id="p-player-name"></div>
|
||||
</div>
|
||||
|
||||
<div class="buzzer-wrap">
|
||||
<button id="buzz-btn" class="state-closed" disabled onclick="doBuzz()">BUZZ</button>
|
||||
<div class="buzz-status" id="buzz-status">WAITING FOR ROUND TO OPEN</div>
|
||||
</div>
|
||||
|
||||
<div style="width:100%;max-width:480px;display:flex;flex-direction:column;gap:16px;">
|
||||
<div class="scoreboard">
|
||||
<h3>// SCOREBOARD</h3>
|
||||
<div id="p-scoreboard"><div class="empty-state" style="padding:10px;">No players yet.</div></div>
|
||||
</div>
|
||||
<div class="buzz-feed">
|
||||
<h3>// BUZZ FEED</h3>
|
||||
<div id="p-feed"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MODALS -->
|
||||
<div class="modal-bg" id="modal-rejoin">
|
||||
<div class="modal">
|
||||
<h2>// REJOIN SESSION</h2>
|
||||
<p style="font-size:11px;color:var(--text-dim);">You have a saved moderator session. Rejoin it?</p>
|
||||
<div style="font-size:20px;letter-spacing:6px;color:var(--green);" id="rejoin-code-display"></div>
|
||||
<div class="modal-btns">
|
||||
<button class="btn btn-ghost btn-sm" onclick="closeModal('modal-rejoin');clearModSession()">DISCARD</button>
|
||||
<button class="btn btn-primary btn-sm" onclick="rejoinMod()">REJOIN</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-bg" id="modal-score-edit">
|
||||
<div class="modal">
|
||||
<h2>// SET SCORE</h2>
|
||||
<div class="field">
|
||||
<label>PLAYER</label>
|
||||
<div id="score-edit-name" style="font-size:13px;color:var(--green);"></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>SCORE</label>
|
||||
<input type="number" id="score-edit-val"/>
|
||||
</div>
|
||||
<div class="modal-btns">
|
||||
<button class="btn btn-ghost btn-sm" onclick="closeModal('modal-score-edit')">CANCEL</button>
|
||||
<button class="btn btn-primary btn-sm" onclick="commitScoreEdit()">SET</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-bg" id="modal-end-room">
|
||||
<div class="modal">
|
||||
<h2>// END ROOM</h2>
|
||||
<p style="font-size:11px;color:var(--text-dim);">This will disconnect all players and delete the room. Are you sure?</p>
|
||||
<div class="modal-btns">
|
||||
<button class="btn btn-ghost btn-sm" onclick="closeModal('modal-end-room')">CANCEL</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="modSend('end_room');closeModal('modal-end-room')">END ROOM</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast-container"></div>
|
||||
|
||||
<script>
|
||||
// ── STATE ────────────────────────────────────────────────────────────────────
|
||||
let ws = null;
|
||||
let role = null; // 'mod' | 'player'
|
||||
let room = null;
|
||||
let myPlayerId = null;
|
||||
let reconnectTimer = null;
|
||||
let reconnectAttempts = 0;
|
||||
let currentTab = 'buzzer';
|
||||
let scoreEditPlayerId = null;
|
||||
let pendingJoin = null; // { name, roomId } for reconnect
|
||||
const TEAM_COLORS = ['#00ff41','#41ffe4','#ffe141','#ff3b3b','#a041ff','#ff8c41','#41a8ff','#ff41c8'];
|
||||
|
||||
// ── STORAGE ──────────────────────────────────────────────────────────────────
|
||||
function saveModSession(roomId, secret) { localStorage.setItem('mod_session', JSON.stringify({ roomId, secret })); }
|
||||
function loadModSession() { try { return JSON.parse(localStorage.getItem('mod_session') || 'null'); } catch { return null; } }
|
||||
function clearModSession() { localStorage.removeItem('mod_session'); document.getElementById('rejoin-link').style.display = 'none'; }
|
||||
function savePlayerSession(roomId, playerId, name) { localStorage.setItem('player_session', JSON.stringify({ roomId, playerId, name })); }
|
||||
function loadPlayerSession() { try { return JSON.parse(localStorage.getItem('player_session') || 'null'); } catch { return null; } }
|
||||
|
||||
// ── WEBSOCKET ─────────────────────────────────────────────────────────────────
|
||||
function connectWS(onOpen) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) { if (onOpen) onOpen(); return; }
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
ws = new WebSocket(`${proto}://${location.host}/ws`);
|
||||
ws.onopen = () => {
|
||||
setConnected(true);
|
||||
reconnectAttempts = 0;
|
||||
if (onOpen) onOpen();
|
||||
};
|
||||
ws.onmessage = (e) => { try { handleMsg(JSON.parse(e.data)); } catch {} };
|
||||
ws.onclose = () => {
|
||||
setConnected(false);
|
||||
scheduleReconnect();
|
||||
};
|
||||
ws.onerror = () => { ws.close(); };
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
clearTimeout(reconnectTimer);
|
||||
const delay = Math.min(8000, 500 * Math.pow(1.5, reconnectAttempts));
|
||||
reconnectAttempts++;
|
||||
reconnectTimer = setTimeout(() => {
|
||||
if (role === 'mod') {
|
||||
const sess = loadModSession();
|
||||
if (sess) connectWS(() => send({ type: 'mod_rejoin', roomId: sess.roomId, modSecret: sess.secret }));
|
||||
} else if (role === 'player' && myPlayerId) {
|
||||
const sess = loadPlayerSession();
|
||||
if (sess) connectWS(() => send({ type: 'join_room', roomId: sess.roomId, playerName: sess.name, playerId: sess.playerId }));
|
||||
} else {
|
||||
connectWS(null);
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function send(msg) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
// ── MESSAGES ──────────────────────────────────────────────────────────────────
|
||||
function handleMsg(msg) {
|
||||
switch (msg.type) {
|
||||
case 'room_created':
|
||||
saveModSession(msg.roomId, msg.modSecret);
|
||||
room = msg.room;
|
||||
role = 'mod';
|
||||
showScreen('s-mod');
|
||||
renderModRoom();
|
||||
toast('Room created: ' + msg.roomId, 'success');
|
||||
break;
|
||||
case 'mod_joined':
|
||||
room = msg.room;
|
||||
role = 'mod';
|
||||
showScreen('s-mod');
|
||||
renderModRoom();
|
||||
toast('Rejoined as moderator', 'success');
|
||||
break;
|
||||
case 'joined':
|
||||
myPlayerId = msg.playerId;
|
||||
room = msg.room;
|
||||
role = 'player';
|
||||
savePlayerSession(room.id, myPlayerId, document.getElementById('join-name').value || loadPlayerSession()?.name || 'Player');
|
||||
showScreen('s-player');
|
||||
renderPlayerRoom();
|
||||
break;
|
||||
case 'room_update':
|
||||
room = msg.room;
|
||||
if (role === 'mod') renderModRoom();
|
||||
else renderPlayerRoom();
|
||||
break;
|
||||
case 'settings_updated':
|
||||
room = msg.room;
|
||||
renderModSettings();
|
||||
renderModRoom();
|
||||
break;
|
||||
case 'round_open':
|
||||
room = msg.room;
|
||||
if (role === 'mod') renderModRoom();
|
||||
else { renderPlayerBuzzer(); toast('ROUND OPEN', 'success'); }
|
||||
break;
|
||||
case 'round_closed':
|
||||
room = msg.room;
|
||||
if (role === 'mod') renderModRoom();
|
||||
else { renderPlayerBuzzer(); toast('ROUND CLOSED', 'warn'); }
|
||||
break;
|
||||
case 'buzzer_reset':
|
||||
room = msg.room;
|
||||
if (role === 'mod') renderModRoom();
|
||||
else { renderPlayerBuzzer(); }
|
||||
break;
|
||||
case 'buzz_event':
|
||||
room = msg.room;
|
||||
if (role === 'mod') { renderModBuzzList(msg); }
|
||||
else { renderPlayerBuzzer(); addFeedEntry(msg); }
|
||||
break;
|
||||
case 'buzz_rejected':
|
||||
toast(msg.reason, 'warn');
|
||||
break;
|
||||
case 'kicked':
|
||||
toast('You were removed from the room', 'error');
|
||||
role = null; room = null; myPlayerId = null;
|
||||
localStorage.removeItem('player_session');
|
||||
showScreen('s-landing');
|
||||
break;
|
||||
case 'room_ended':
|
||||
toast('Room ended by moderator', 'warn');
|
||||
role = null; room = null; myPlayerId = null;
|
||||
clearModSession(); localStorage.removeItem('player_session');
|
||||
showScreen('s-landing');
|
||||
break;
|
||||
case 'mod_disconnected':
|
||||
toast('Moderator disconnected', 'warn');
|
||||
break;
|
||||
case 'error':
|
||||
toast(msg.message, 'error');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── SCREENS ───────────────────────────────────────────────────────────────────
|
||||
function showScreen(id) {
|
||||
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
|
||||
document.getElementById(id).classList.add('active');
|
||||
const roomCode = room?.id ?? null;
|
||||
const hdrRoom = document.getElementById('hdr-room');
|
||||
if (roomCode) { hdrRoom.textContent = '[ ' + roomCode + ' ]'; hdrRoom.style.display = 'block'; }
|
||||
else hdrRoom.style.display = 'none';
|
||||
}
|
||||
|
||||
function setConnected(on) {
|
||||
document.getElementById('conn-dot').className = 'conn-dot' + (on ? ' on' : '');
|
||||
document.getElementById('conn-label').textContent = on ? 'ONLINE' : 'OFFLINE';
|
||||
}
|
||||
|
||||
// ── LANDING ───────────────────────────────────────────────────────────────────
|
||||
function showModSetup() {
|
||||
connectWS(() => send({ type: 'create_room' }));
|
||||
}
|
||||
|
||||
function joinRoom() {
|
||||
const code = document.getElementById('join-code').value.trim().toUpperCase();
|
||||
const name = document.getElementById('join-name').value.trim();
|
||||
if (!code) { toast('Enter a room code', 'error'); return; }
|
||||
if (!name) { toast('Enter your name', 'error'); return; }
|
||||
connectWS(() => send({ type: 'join_room', roomId: code, playerName: name }));
|
||||
}
|
||||
|
||||
function showRejoinModal() {
|
||||
const sess = loadModSession();
|
||||
if (!sess) return;
|
||||
document.getElementById('rejoin-code-display').textContent = sess.roomId;
|
||||
openModal('modal-rejoin');
|
||||
}
|
||||
|
||||
function rejoinMod() {
|
||||
const sess = loadModSession();
|
||||
if (!sess) return;
|
||||
closeModal('modal-rejoin');
|
||||
connectWS(() => send({ type: 'mod_rejoin', roomId: sess.roomId, modSecret: sess.secret }));
|
||||
}
|
||||
|
||||
// ── MOD ───────────────────────────────────────────────────────────────────────
|
||||
function modSend(type, extra) {
|
||||
send({ type, ...extra });
|
||||
}
|
||||
|
||||
function renderModRoom() {
|
||||
if (!room) return;
|
||||
document.getElementById('mod-room-code').textContent = room.id;
|
||||
document.getElementById('mod-player-count').textContent = room.players.length + ' PLAYER' + (room.players.length !== 1 ? 'S' : '');
|
||||
document.getElementById('mod-round-status').textContent = 'ROUND: ' + (room.buzzerState.roundOpen ? 'OPEN' : 'CLOSED');
|
||||
document.getElementById('lock-toggle').checked = room.locked;
|
||||
document.getElementById('player-count-badge').textContent = room.players.length;
|
||||
renderModSettings();
|
||||
renderModPlayerList();
|
||||
renderModBuzzList(null);
|
||||
renderTeamScores();
|
||||
}
|
||||
|
||||
function renderModSettings() {
|
||||
if (!room) return;
|
||||
const s = room.settings;
|
||||
document.getElementById('st-teamMode').checked = s.teamMode;
|
||||
document.getElementById('st-buzzerLockout').checked = s.buzzerLockout;
|
||||
document.getElementById('st-showOrder').checked = s.showOrder;
|
||||
document.getElementById('st-allowRebuzz').checked = s.allowRebuzz;
|
||||
document.getElementById('st-pointsNeg').checked = s.pointsNeg;
|
||||
document.getElementById('st-pointsCorrect').value = s.pointsCorrect;
|
||||
document.getElementById('st-pointsIncorrect').value = s.pointsIncorrect;
|
||||
document.getElementById('st-numTeams').value = s.numTeams;
|
||||
document.getElementById('num-teams-field').style.display = s.teamMode ? 'flex' : 'none';
|
||||
document.getElementById('team-names-section').style.display = s.teamMode ? 'block' : 'none';
|
||||
renderTeamNames();
|
||||
}
|
||||
|
||||
function renderTeamNames() {
|
||||
if (!room) return;
|
||||
const n = room.settings.numTeams;
|
||||
const names = room.settings.teamNames;
|
||||
const container = document.getElementById('team-name-inputs');
|
||||
container.innerHTML = '';
|
||||
for (let i = 0; i < n; i++) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'team-name-row';
|
||||
row.innerHTML = `
|
||||
<div class="team-color-dot" style="background:${TEAM_COLORS[i]};box-shadow:0 0 6px ${TEAM_COLORS[i]};"></div>
|
||||
<input value="${escHtml(names[i] ?? 'Team '+(i+1))}" maxlength="32" onchange="updateTeamName(${i},this.value)"/>
|
||||
`;
|
||||
container.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function updateTeamName(idx, val) {
|
||||
if (!room) return;
|
||||
const names = [...room.settings.teamNames];
|
||||
names[idx] = val.trim() || 'Team '+(idx+1);
|
||||
send({ type: 'update_settings', settings: { teamNames: names } });
|
||||
}
|
||||
|
||||
function pushSettings() {
|
||||
if (!room) return;
|
||||
send({
|
||||
type: 'update_settings',
|
||||
settings: {
|
||||
teamMode: document.getElementById('st-teamMode').checked,
|
||||
buzzerLockout: document.getElementById('st-buzzerLockout').checked,
|
||||
showOrder: document.getElementById('st-showOrder').checked,
|
||||
allowRebuzz: document.getElementById('st-allowRebuzz').checked,
|
||||
pointsNeg: document.getElementById('st-pointsNeg').checked,
|
||||
pointsCorrect: parseInt(document.getElementById('st-pointsCorrect').value) || 100,
|
||||
pointsIncorrect: parseInt(document.getElementById('st-pointsIncorrect').value) || 0,
|
||||
numTeams: parseInt(document.getElementById('st-numTeams').value) || 2,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderTeamScores() {
|
||||
if (!room) return;
|
||||
const container = document.getElementById('mod-team-scores');
|
||||
if (!room.settings.teamMode) { container.style.display = 'none'; return; }
|
||||
container.style.display = 'grid';
|
||||
container.className = 'team-scores';
|
||||
const teamTotals = {};
|
||||
for (const p of room.players) {
|
||||
if (p.teamIndex !== null) {
|
||||
teamTotals[p.teamIndex] = (teamTotals[p.teamIndex] || 0) + p.score;
|
||||
}
|
||||
}
|
||||
container.innerHTML = '';
|
||||
for (let i = 0; i < room.settings.numTeams; i++) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'team-score-card';
|
||||
card.style.borderColor = TEAM_COLORS[i];
|
||||
const name = room.settings.teamNames[i] ?? 'Team '+(i+1);
|
||||
const score = teamTotals[i] ?? 0;
|
||||
card.innerHTML = `<div class="ts-name" style="color:${TEAM_COLORS[i]}">${escHtml(name)}</div><div class="ts-val" style="color:${TEAM_COLORS[i]}">${score}</div>`;
|
||||
container.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
let lastBuzzMsg = null;
|
||||
function renderModBuzzList(buzzMsg) {
|
||||
if (buzzMsg) lastBuzzMsg = buzzMsg;
|
||||
const container = document.getElementById('mod-buzz-list');
|
||||
const statusEl = document.getElementById('mod-buzz-status');
|
||||
if (!room) return;
|
||||
const order = room.buzzerState.buzzOrder;
|
||||
if (!order || order.length === 0) {
|
||||
container.innerHTML = '';
|
||||
statusEl.style.display = 'block';
|
||||
statusEl.textContent = room.buzzerState.roundOpen ? 'Round open — waiting for buzzes...' : 'No buzzes yet — open a round to begin.';
|
||||
return;
|
||||
}
|
||||
statusEl.style.display = 'none';
|
||||
container.innerHTML = '';
|
||||
order.forEach((pid, idx) => {
|
||||
const p = room.players.find(pl => pl.id === pid);
|
||||
if (!p) return;
|
||||
const entry = document.createElement('div');
|
||||
entry.className = 'buzz-entry';
|
||||
const teamName = (room.settings.teamMode && p.teamIndex !== null) ? (room.settings.teamNames[p.teamIndex] ?? 'Team '+(p.teamIndex+1)) : null;
|
||||
const teamColor = p.teamIndex !== null ? TEAM_COLORS[p.teamIndex] : 'var(--green)';
|
||||
entry.innerHTML = `
|
||||
<div class="buzz-rank ${idx===0?'first':''}">${idx+1}</div>
|
||||
<div style="flex:1;">
|
||||
<div class="buzz-name">${escHtml(p.name)}</div>
|
||||
${teamName ? `<div class="buzz-team" style="color:${teamColor}">${escHtml(teamName)}</div>` : ''}
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--text-dim);">+${room.settings.pointsCorrect}pts</div>
|
||||
<div class="buzz-actions">
|
||||
<button class="btn btn-cyan btn-sm" onclick="send({type:'mark_correct',playerId:'${pid}'})">✓</button>
|
||||
${room.settings.pointsNeg ? `<button class="btn btn-danger btn-sm" onclick="send({type:'mark_incorrect',playerId:'${pid}'})">✗</button>` : ''}
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(entry);
|
||||
});
|
||||
}
|
||||
|
||||
function renderModPlayerList() {
|
||||
if (!room) return;
|
||||
const container = document.getElementById('mod-player-list');
|
||||
if (room.players.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state">No players connected.</div>';
|
||||
return;
|
||||
}
|
||||
const sorted = [...room.players].sort((a,b) => b.score - a.score);
|
||||
container.innerHTML = '';
|
||||
sorted.forEach(p => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'player-row' + (p.isConnected ? '' : ' player-offline');
|
||||
const teamName = (room.settings.teamMode && p.teamIndex !== null) ? (room.settings.teamNames[p.teamIndex] ?? 'Team '+(p.teamIndex+1)) : null;
|
||||
const teamColor = p.teamIndex !== null ? TEAM_COLORS[p.teamIndex] : null;
|
||||
// team selector options
|
||||
let teamOpts = `<option value="-1">No Team</option>`;
|
||||
if (room.settings.teamMode) {
|
||||
for (let i = 0; i < room.settings.numTeams; i++) {
|
||||
const tn = room.settings.teamNames[i] ?? 'Team '+(i+1);
|
||||
teamOpts += `<option value="${i}" ${p.teamIndex===i?'selected':''}>${escHtml(tn)}</option>`;
|
||||
}
|
||||
}
|
||||
row.innerHTML = `
|
||||
<div class="player-info">
|
||||
<div class="player-name">${escHtml(p.name)} ${p.isConnected ? '' : '<span class="tag tag-red" style="font-size:8px;">OFFLINE</span>'}</div>
|
||||
${teamName ? `<div class="player-meta" style="color:${teamColor}">${escHtml(teamName)}</div>` : '<div class="player-meta" style="color:var(--text-dim);">No team</div>'}
|
||||
${room.settings.teamMode ? `<select style="margin-top:4px;font-size:10px;padding:3px 6px;" onchange="send({type:'assign_team',playerId:'${p.id}',teamIndex:+this.value===-1?null:+this.value})">${teamOpts}</select>` : ''}
|
||||
</div>
|
||||
<div class="score-cell" style="cursor:pointer;" onclick="openScoreEdit('${p.id}','${escHtml(p.name)}',${p.score})">${p.score}</div>
|
||||
<div class="score-adj">
|
||||
<button class="btn btn-primary btn-sm" onclick="send({type:'adjust_score',playerId:'${p.id}',delta:${room.settings.pointsCorrect}})">+${room.settings.pointsCorrect}</button>
|
||||
${room.settings.pointsNeg ? `<button class="btn btn-danger btn-sm" onclick="send({type:'adjust_score',playerId:'${p.id}',delta:-${room.settings.pointsIncorrect}})">-${room.settings.pointsIncorrect}</button>` : ''}
|
||||
</div>
|
||||
<div><button class="btn btn-danger btn-sm" onclick="send({type:'kick_player',playerId:'${p.id}'})">KICK</button></div>
|
||||
`;
|
||||
container.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function openScoreEdit(playerId, name, current) {
|
||||
scoreEditPlayerId = playerId;
|
||||
document.getElementById('score-edit-name').textContent = name;
|
||||
document.getElementById('score-edit-val').value = current;
|
||||
openModal('modal-score-edit');
|
||||
}
|
||||
function commitScoreEdit() {
|
||||
const val = parseInt(document.getElementById('score-edit-val').value);
|
||||
if (isNaN(val)) return;
|
||||
send({ type: 'set_score', playerId: scoreEditPlayerId, score: val });
|
||||
closeModal('modal-score-edit');
|
||||
}
|
||||
|
||||
function copyRoomCode() {
|
||||
if (!room) return;
|
||||
navigator.clipboard.writeText(room.id).then(() => toast('Room code copied!', 'success'));
|
||||
}
|
||||
|
||||
function confirmEndRoom() { openModal('modal-end-room'); }
|
||||
|
||||
function setTab(name) {
|
||||
currentTab = name;
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
event.target.classList.add('active');
|
||||
document.getElementById('tab-buzzer').style.display = name === 'buzzer' ? 'block' : 'none';
|
||||
document.getElementById('tab-players').style.display = name === 'players' ? 'block' : 'none';
|
||||
document.getElementById('tab-settings').style.display = name === 'settings' ? 'block' : 'none';
|
||||
if (name === 'players') renderModPlayerList();
|
||||
}
|
||||
|
||||
// ── PLAYER ────────────────────────────────────────────────────────────────────
|
||||
function doBuzz() {
|
||||
send({ type: 'buzz' });
|
||||
}
|
||||
|
||||
function renderPlayerRoom() {
|
||||
if (!room) return;
|
||||
document.getElementById('p-room-code').textContent = room.id;
|
||||
const me = room.players.find(p => p.id === myPlayerId);
|
||||
document.getElementById('p-player-name').textContent = me ? me.name : '';
|
||||
renderPlayerBuzzer();
|
||||
renderPlayerScoreboard();
|
||||
}
|
||||
|
||||
function renderPlayerBuzzer() {
|
||||
if (!room) return;
|
||||
const btn = document.getElementById('buzz-btn');
|
||||
const status = document.getElementById('buzz-status');
|
||||
const bz = room.buzzerState;
|
||||
const alreadyBuzzed = bz.buzzOrder.includes(myPlayerId);
|
||||
const isFirst = bz.buzzOrder[0] === myPlayerId;
|
||||
|
||||
if (!bz.roundOpen) {
|
||||
btn.className = 'state-closed'; btn.disabled = true;
|
||||
status.textContent = 'WAITING FOR ROUND TO OPEN';
|
||||
} else if (isFirst) {
|
||||
btn.className = 'state-first'; btn.disabled = false;
|
||||
status.textContent = '⚡ YOU BUZZED FIRST!';
|
||||
} else if (alreadyBuzzed) {
|
||||
btn.className = 'state-buzzed'; btn.disabled = true;
|
||||
status.textContent = 'BUZZED — POSITION #' + (bz.buzzOrder.indexOf(myPlayerId)+1);
|
||||
} else if (room.settings.buzzerLockout && bz.buzzOrder.length > 0) {
|
||||
btn.className = 'state-locked'; btn.disabled = true;
|
||||
status.textContent = 'BUZZER LOCKED';
|
||||
} else {
|
||||
btn.className = 'state-open'; btn.disabled = false;
|
||||
status.textContent = 'ROUND OPEN — BUZZ NOW!';
|
||||
}
|
||||
}
|
||||
|
||||
function renderPlayerScoreboard() {
|
||||
if (!room) return;
|
||||
const container = document.getElementById('p-scoreboard');
|
||||
const sorted = [...room.players].sort((a,b) => b.score - a.score);
|
||||
if (sorted.length === 0) { container.innerHTML = '<div class="empty-state" style="padding:10px;">No players yet.</div>'; return; }
|
||||
container.innerHTML = '';
|
||||
sorted.forEach((p, i) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'score-row' + (p.id === myPlayerId ? ' me' : '');
|
||||
const teamName = (room.settings.teamMode && p.teamIndex !== null) ? (room.settings.teamNames[p.teamIndex] ?? '') : '';
|
||||
row.innerHTML = `
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<span style="color:var(--text-dim);font-size:11px;">#${i+1}</span>
|
||||
<div>
|
||||
<div>${escHtml(p.name)}${p.id===myPlayerId?' <span style="font-size:9px;color:var(--text-dim);">(YOU)</span>':''}</div>
|
||||
${teamName ? `<div style="font-size:9px;color:var(--text-dim);">${escHtml(teamName)}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="score-val">${p.score}</div>
|
||||
`;
|
||||
container.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function addFeedEntry(buzzMsg) {
|
||||
const feed = document.getElementById('p-feed');
|
||||
const entry = document.createElement('div');
|
||||
const isFirst = buzzMsg.buzzOrder && buzzMsg.buzzOrder[0] === buzzMsg.playerId;
|
||||
entry.className = 'feed-entry' + (isFirst ? ' first' : '');
|
||||
const teamName = (room?.settings.teamMode && buzzMsg.teamIndex !== null && buzzMsg.teamIndex !== undefined)
|
||||
? (room.settings.teamNames[buzzMsg.teamIndex] ?? '') : '';
|
||||
entry.innerHTML = `<strong>${escHtml(buzzMsg.playerName)}</strong>${teamName ? ' ['+escHtml(teamName)+']' : ''} buzzed${isFirst ? ' — <span style="color:var(--yellow)">FIRST!</span>' : ''}`;
|
||||
feed.prepend(entry);
|
||||
// cap feed at 20
|
||||
while (feed.children.length > 20) feed.removeChild(feed.lastChild);
|
||||
}
|
||||
|
||||
// ── MODALS ─────────────────────────────────────────────────────────────────────
|
||||
function openModal(id) { document.getElementById(id).classList.add('open'); }
|
||||
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
|
||||
|
||||
// ── TOAST ──────────────────────────────────────────────────────────────────────
|
||||
function toast(msg, type='') {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'toast ' + type;
|
||||
el.textContent = msg;
|
||||
document.getElementById('toast-container').appendChild(el);
|
||||
setTimeout(() => { el.style.opacity = '0'; el.style.transition = 'opacity 0.3s'; setTimeout(() => el.remove(), 300); }, 2800);
|
||||
}
|
||||
|
||||
// ── UTILS ──────────────────────────────────────────────────────────────────────
|
||||
function escHtml(str) {
|
||||
return String(str ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ── INIT ───────────────────────────────────────────────────────────────────────
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
const modSess = loadModSession();
|
||||
if (modSess) {
|
||||
document.getElementById('rejoin-link').style.display = 'inline';
|
||||
document.getElementById('rejoin-code-display').textContent = modSess.roomId;
|
||||
}
|
||||
showScreen('s-landing');
|
||||
connectWS(null);
|
||||
});
|
||||
|
||||
// close modals on bg click
|
||||
document.querySelectorAll('.modal-bg').forEach(bg => {
|
||||
bg.addEventListener('click', e => { if (e.target === bg) bg.classList.remove('open'); });
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user