Compare commits
36 Commits
da28e052bb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a1c494ade | ||
| d9b2c5ee22 | |||
| 57c5f4e054 | |||
| 3c0ca7843f | |||
| a135746e37 | |||
| 56ecfaad54 | |||
| 1ad1d96cb2 | |||
| 1a2dc295e1 | |||
| 9d34d648d8 | |||
| 7e52e1768b | |||
| 3dc9bdcc21 | |||
| 22c4d28b42 | |||
| b5031c4f71 | |||
| b9d3b6115a | |||
| a9ccb42008 | |||
| 20f0122f59 | |||
| 548a7e29f0 | |||
| a01b584d10 | |||
| 191d66e6d4 | |||
| 462979e6f7 | |||
| e318a2c058 | |||
| c9795f816c | |||
| 01cd50abf7 | |||
| 9c9a95206d | |||
| ed46de26b3 | |||
| b69c442c90 | |||
| cddbdfaae8 | |||
| c00db744c5 | |||
| b76bcbffb2 | |||
| 67bd8d9e77 | |||
| 7696b6005b | |||
| c08c0831b1 | |||
| 6afb853874 | |||
| c0e7c08106 | |||
| 83bd893d93 | |||
| ce13860cd1 |
34
.gitignore
vendored
34
.gitignore
vendored
@@ -1,34 +1,6 @@
|
|||||||
# dependencies (bun install)
|
|
||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
# output
|
|
||||||
out
|
|
||||||
dist
|
dist
|
||||||
*.tgz
|
|
||||||
|
|
||||||
# code coverage
|
|
||||||
coverage
|
|
||||||
*.lcov
|
|
||||||
|
|
||||||
# logs
|
|
||||||
logs
|
|
||||||
_.log
|
|
||||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|
||||||
|
|
||||||
# dotenv environment variable files
|
|
||||||
.env
|
|
||||||
.env.development.local
|
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
.env.local
|
|
||||||
|
|
||||||
# caches
|
|
||||||
.eslintcache
|
|
||||||
.cache
|
|
||||||
*.tsbuildinfo
|
|
||||||
|
|
||||||
# IntelliJ based IDEs
|
|
||||||
.idea
|
|
||||||
|
|
||||||
# Finder (MacOS) folder config
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
server/public
|
||||||
|
vite.config.ts.*
|
||||||
|
*.tar.gz
|
||||||
31
.replit
Normal file
31
.replit
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
modules = ["bun-1.3"]
|
||||||
|
[agent]
|
||||||
|
expertMode = true
|
||||||
|
|
||||||
|
[workflows]
|
||||||
|
runButton = "Project"
|
||||||
|
|
||||||
|
[[workflows.workflow]]
|
||||||
|
name = "Project"
|
||||||
|
mode = "parallel"
|
||||||
|
author = "agent"
|
||||||
|
|
||||||
|
[[workflows.workflow.tasks]]
|
||||||
|
task = "workflow.run"
|
||||||
|
args = "Start application"
|
||||||
|
|
||||||
|
[[workflows.workflow]]
|
||||||
|
name = "Start application"
|
||||||
|
author = "agent"
|
||||||
|
|
||||||
|
[[workflows.workflow.tasks]]
|
||||||
|
task = "shell.exec"
|
||||||
|
args = "PORT=5000 bun --hot run src/server.ts"
|
||||||
|
waitForPort = 5000
|
||||||
|
|
||||||
|
[workflows.workflow.metadata]
|
||||||
|
outputType = "webview"
|
||||||
|
|
||||||
|
[[ports]]
|
||||||
|
localPort = 5000
|
||||||
|
externalPort = 80
|
||||||
6
bun.lock
6
bun.lock
@@ -1,15 +1,15 @@
|
|||||||
{
|
{
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
"configVersion": 1,
|
"configVersion": 0,
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "buzzer",
|
"name": "rest-express",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"hono": "^4.7.7",
|
"hono": "^4.7.7",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
"hono": ["hono@4.12.8", "", {}, "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A=="],
|
"hono": ["hono@4.12.9", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,7 @@
|
|||||||
<title>Buzzer Platform</title>
|
<title>Buzzer Platform</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700;800&display=swap"
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||||
rel="stylesheet" />
|
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -17,9 +16,9 @@
|
|||||||
|
|
||||||
<!-- HEADER -->
|
<!-- HEADER -->
|
||||||
<header>
|
<header>
|
||||||
<div>
|
<div class="logo-wrap">
|
||||||
<div class="logo">BUZZER</div>
|
<div class="logo">BUZZER</div>
|
||||||
<div class="logo-sub">// QUIZ CONTROL</div>
|
<div class="logo-sub">// QUIZ CONTROL PLATFORM</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hdr-r">
|
<div class="hdr-r">
|
||||||
<div class="room-chip" id="hdr-room"></div>
|
<div class="room-chip" id="hdr-room"></div>
|
||||||
@@ -33,27 +32,30 @@
|
|||||||
<!-- ══════════ LANDING ══════════ -->
|
<!-- ══════════ LANDING ══════════ -->
|
||||||
<div class="scr" id="s-land">
|
<div class="scr" id="s-land">
|
||||||
<div class="hero">
|
<div class="hero">
|
||||||
|
<div class="hero-decoration"></div>
|
||||||
<h1 class="glow">BUZZ</h1>
|
<h1 class="glow">BUZZ</h1>
|
||||||
<p>REAL-TIME QUIZ BUZZER SYSTEM</p>
|
<p>REAL-TIME QUIZ BUZZER SYSTEM</p>
|
||||||
|
<div class="hero-badge">
|
||||||
|
<div class="hero-badge-dot"></div>
|
||||||
|
WEBSOCKET POWERED
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="land-cards">
|
<div class="land-cards">
|
||||||
<div class="land-card">
|
<div class="land-card">
|
||||||
|
<div class="land-card-icon">⬛</div>
|
||||||
<h2>// HOST A SESSION</h2>
|
<h2>// HOST A SESSION</h2>
|
||||||
<p>Create a room, configure teams and rules, then control the buzzer live.</p>
|
<p>Create a room, configure teams and rules, then control the buzzer live.</p>
|
||||||
<button class="btn btn-g" onclick="goSetup()">CREATE ROOM →</button>
|
<button class="btn btn-g" onclick="goSetup()">CREATE ROOM →</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="land-card">
|
<div class="land-card">
|
||||||
|
<div class="land-card-icon">⬜</div>
|
||||||
<h2>// JOIN A SESSION</h2>
|
<h2>// JOIN A SESSION</h2>
|
||||||
<p>Enter a room code and your name to join an existing session.</p>
|
<p>Enter a room code to join an existing session. You'll be assigned a numeric ID.</p>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>ROOM CODE</label>
|
<label>ROOM CODE</label>
|
||||||
<input id="ji-code" maxlength="8" placeholder="XXXXXX" style="letter-spacing:4px;text-transform:uppercase"
|
<input id="ji-code" maxlength="8" placeholder="XXXXXX" style="letter-spacing:5px;text-transform:uppercase;font-size:20px;"
|
||||||
oninput="this.value=this.value.toUpperCase()" />
|
oninput="this.value=this.value.toUpperCase()" />
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
|
||||||
<label>YOUR NAME</label>
|
|
||||||
<input id="ji-name" maxlength="24" placeholder="Enter name…" />
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-g btn-full" onclick="joinRoom()">JOIN ROOM →</button>
|
<button class="btn btn-g btn-full" onclick="joinRoom()">JOIN ROOM →</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,7 +67,7 @@
|
|||||||
<!-- ══════════ SETUP ══════════ -->
|
<!-- ══════════ SETUP ══════════ -->
|
||||||
<div class="scr" id="s-setup">
|
<div class="scr" id="s-setup">
|
||||||
<div class="setup-wrap">
|
<div class="setup-wrap">
|
||||||
<div>
|
<div class="setup-header">
|
||||||
<div class="setup-title">// NEW ROOM SETUP</div>
|
<div class="setup-title">// NEW ROOM SETUP</div>
|
||||||
<div class="setup-sub">Configure before creating — all settings are adjustable in-game too.</div>
|
<div class="setup-sub">Configure before creating — all settings are adjustable in-game too.</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -81,28 +83,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="team-opts">
|
<div id="team-opts">
|
||||||
<div class="field" style="margin-top:16px;">
|
<div class="field" style="margin-top:20px;">
|
||||||
<label>NUMBER OF TEAMS</label>
|
<label>NUMBER OF TEAMS</label>
|
||||||
<div style="display:flex;align-items:center;gap:12px;">
|
<div style="display:flex;align-items:center;gap:14px;">
|
||||||
<input type="number" id="st-numteams" value="2" min="2" max="64" style="width:90px;"
|
<input type="number" id="st-numteams" value="2" min="2" max="64" style="width:100px;"
|
||||||
oninput="renderSetupTeamNames()" />
|
oninput="renderSetupTeamNames()" />
|
||||||
<span style="font-size:12px;color:var(--dim);">2 – 64</span>
|
<span style="font-size:12px;color:var(--dim);letter-spacing:1px;">2 – 64</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field" style="margin-top:12px;">
|
<div class="field" style="margin-top:14px;">
|
||||||
<label>TEAM NAMES <span style="color:var(--dim);font-size:9px;">(AUTO-FILLED WITH GREEK
|
<label>TEAM NAMES <span style="color:var(--dim);font-size:9px;letter-spacing:1px;">(AUTO-FILLED WITH GREEK ALPHABET)</span></label>
|
||||||
ALPHABET)</span></label>
|
|
||||||
<div id="setup-team-names"
|
<div id="setup-team-names"
|
||||||
style="display:flex;flex-direction:column;gap:8px;max-height:240px;overflow-y:auto;padding-right:4px;">
|
style="display:flex;flex-direction:column;gap:9px;max-height:240px;overflow-y:auto;padding-right:4px;">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tog-row" style="margin-top:12px;">
|
<div class="tog-row" style="margin-top:14px;">
|
||||||
<div>
|
<div>
|
||||||
<div class="lbl">PLAYERS PICK OWN TEAM</div>
|
<div class="lbl">PLAYERS PICK OWN TEAM</div>
|
||||||
<div class="lbl-sub">If off, moderator assigns teams manually</div>
|
<div class="lbl-sub">If off, moderator assigns teams manually</div>
|
||||||
</div>
|
</div>
|
||||||
<label class="tog"><input type="checkbox" id="st-playerpick" checked /><span
|
<label class="tog"><input type="checkbox" id="st-playerpick" checked /><span class="tog-track"></span></label>
|
||||||
class="tog-track"></span></label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,8 +121,7 @@
|
|||||||
<div class="lbl">SHOW FULL BUZZ ORDER TO PLAYERS</div>
|
<div class="lbl">SHOW FULL BUZZ ORDER TO PLAYERS</div>
|
||||||
<div class="lbl-sub">If off, players only see if they were first</div>
|
<div class="lbl-sub">If off, players only see if they were first</div>
|
||||||
</div>
|
</div>
|
||||||
<label class="tog"><input type="checkbox" id="st-showorder" checked /><span
|
<label class="tog"><input type="checkbox" id="st-showorder" checked /><span class="tog-track"></span></label>
|
||||||
class="tog-track"></span></label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -133,18 +132,17 @@
|
|||||||
<div class="lbl">ENABLE COUNTDOWN TIMER</div>
|
<div class="lbl">ENABLE COUNTDOWN TIMER</div>
|
||||||
<div class="lbl-sub">Auto-closes round when it hits zero</div>
|
<div class="lbl-sub">Auto-closes round when it hits zero</div>
|
||||||
</div>
|
</div>
|
||||||
<label class="tog"><input type="checkbox" id="st-usetimer" onchange="toggleTimerField()" /><span
|
<label class="tog"><input type="checkbox" id="st-usetimer" onchange="toggleTimerField()" /><span class="tog-track"></span></label>
|
||||||
class="tog-track"></span></label>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="timer-field" style="display:none;margin-top:14px;">
|
<div id="timer-field" style="display:none;margin-top:16px;">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>SECONDS PER ROUND</label>
|
<label>SECONDS PER ROUND</label>
|
||||||
<input type="number" id="st-timersec" value="30" min="5" max="600" style="max-width:160px;" />
|
<input type="number" id="st-timersec" value="30" min="5" max="600" style="max-width:180px;" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:flex;gap:10px;justify-content:flex-end;padding-bottom:32px;">
|
<div style="display:flex;gap:12px;justify-content:flex-end;padding-bottom:40px;">
|
||||||
<button class="btn btn-ghost" onclick="showScr('s-land')">← BACK</button>
|
<button class="btn btn-ghost" onclick="showScr('s-land')">← BACK</button>
|
||||||
<button class="btn btn-g" onclick="createRoom()">CREATE ROOM →</button>
|
<button class="btn btn-g" onclick="createRoom()">CREATE ROOM →</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,11 +153,11 @@
|
|||||||
<div class="scr" id="s-mod">
|
<div class="scr" id="s-mod">
|
||||||
<div class="mod-side">
|
<div class="mod-side">
|
||||||
<div class="side-sec">
|
<div class="side-sec">
|
||||||
<div class="side-label">ROOM</div>
|
<div class="side-label">ROOM CODE</div>
|
||||||
<div class="side-room-code glow" id="mod-code">──────</div>
|
<div class="side-room-code glow" id="mod-code">──────</div>
|
||||||
<div class="side-hint">SHARE WITH PLAYERS</div>
|
<div class="side-hint" style="margin-top:6px;">SHARE WITH PLAYERS</div>
|
||||||
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:10px;">
|
<div style="display:flex;gap:7px;flex-wrap:wrap;margin-top:14px;">
|
||||||
<button class="btn btn-ghost btn-sm" onclick="copyCode()">COPY</button>
|
<button class="btn btn-ghost btn-sm" onclick="copyCode()">COPY CODE</button>
|
||||||
<button class="btn btn-red btn-sm" onclick="openModal('m-end')">END ROOM</button>
|
<button class="btn btn-red btn-sm" onclick="openModal('m-end')">END ROOM</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -171,7 +169,7 @@
|
|||||||
<div class="timer-digits" id="mod-timer-disp">0:30</div>
|
<div class="timer-digits" id="mod-timer-disp">0:30</div>
|
||||||
<div class="timer-controls">
|
<div class="timer-controls">
|
||||||
<button class="btn btn-g btn-sm" id="btn-timer-ss" onclick="modTimerToggle()">START</button>
|
<button class="btn btn-g btn-sm" id="btn-timer-ss" onclick="modTimerToggle()">START</button>
|
||||||
<button class="btn btn-ghost btn-sm" onclick="modTimerReset()">↺</button>
|
<button class="btn btn-ghost btn-sm" onclick="modTimerReset()">↺ RESET</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="timer-set-row">
|
<div class="timer-set-row">
|
||||||
<span class="side-hint">SET</span>
|
<span class="side-hint">SET</span>
|
||||||
@@ -179,22 +177,27 @@
|
|||||||
onclick="this.select()" />
|
onclick="this.select()" />
|
||||||
<span class="side-hint">SEC</span>
|
<span class="side-hint">SEC</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="tog-row">
|
||||||
|
<div class="lbl">LINK TIMER</div>
|
||||||
|
<label class="tog">
|
||||||
|
<input type="checkbox" id="timer-tog" onchange="toggleTimerFromSwitch()" /><span class="tog-track"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- BUZZER CONTROLS -->
|
<!-- BUZZER CONTROLS -->
|
||||||
<div class="side-sec">
|
<div class="side-sec">
|
||||||
<div class="side-label">BUZZER</div>
|
<div class="side-label">ROUND CONTROL</div>
|
||||||
<div class="side-btn-group">
|
<div class="side-btn-group">
|
||||||
<button class="btn btn-g btn-full" onclick="ws_send({type:'open_round'})">▶ OPEN ROUND</button>
|
<button class="btn btn-g btn-full" id="mod-round-btn" onclick="toggleRound()">▶ OPEN ROUND</button>
|
||||||
<button class="btn btn-ghost btn-full" onclick="ws_send({type:'close_round'})">■ CLOSE ROUND</button>
|
<button class="btn btn-ghost btn-full" id="mod-resume-btn" style="display:none;" onclick="resumeRound()">▶ RESUME ROUND</button>
|
||||||
<button class="btn btn-yellow btn-full" onclick="ws_send({type:'reset_buzzer'})">↺ RESET BUZZER</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ROOM CONTROLS -->
|
<!-- ROOM CONTROLS -->
|
||||||
<div class="side-sec">
|
<div class="side-sec">
|
||||||
<div class="side-label">CONTROLS</div>
|
<div class="side-label">ROOM CONTROLS</div>
|
||||||
<div class="tog-row">
|
<div class="tog-row">
|
||||||
<div class="lbl">LOCK ROOM</div>
|
<div class="lbl">LOCK ROOM</div>
|
||||||
<label class="tog"><input type="checkbox" id="lock-room-tog"
|
<label class="tog"><input type="checkbox" id="lock-room-tog"
|
||||||
@@ -205,9 +208,8 @@
|
|||||||
<label class="tog"><input type="checkbox" id="lock-teams-tog"
|
<label class="tog"><input type="checkbox" id="lock-teams-tog"
|
||||||
onchange="ws_send({type:'lock_teams',locked:this.checked})" /><span class="tog-track"></span></label>
|
onchange="ws_send({type:'lock_teams',locked:this.checked})" /><span class="tog-track"></span></label>
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top:10px;display:flex;flex-direction:column;gap:5px;">
|
<div style="margin-top:12px;display:flex;flex-direction:column;gap:6px;">
|
||||||
<button class="btn btn-ghost btn-sm btn-full" onclick="ws_send({type:'reset_teams'})">CLEAR ALL
|
<button class="btn btn-ghost btn-sm btn-full" onclick="ws_send({type:'reset_teams'})">CLEAR ALL TEAMS</button>
|
||||||
TEAMS</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -230,7 +232,7 @@
|
|||||||
<div id="tab-buzzer">
|
<div id="tab-buzzer">
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-title">BUZZ ORDER</div>
|
<div class="panel-title">BUZZ ORDER</div>
|
||||||
<div class="empty" id="mod-bz-empty">No buzzes — open a round to begin.</div>
|
<div class="empty" id="mod-bz-empty">No buzzes</div>
|
||||||
<div class="buzz-list" id="mod-bz-list"></div>
|
<div class="buzz-list" id="mod-bz-list"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -238,7 +240,7 @@
|
|||||||
<div id="tab-players" style="display:none">
|
<div id="tab-players" style="display:none">
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-title">
|
<div class="panel-title">
|
||||||
<span>CONNECTED PLAYERS</span>
|
CONNECTED PLAYERS
|
||||||
<span class="tag tag-g" id="pcount-badge">0</span>
|
<span class="tag tag-g" id="pcount-badge">0</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="player-list" id="mod-plist">
|
<div class="player-list" id="mod-plist">
|
||||||
@@ -297,16 +299,16 @@
|
|||||||
<div class="sl">NUMBER OF TEAMS</div>
|
<div class="sl">NUMBER OF TEAMS</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;align-items:center;gap:8px;">
|
<div style="display:flex;align-items:center;gap:8px;">
|
||||||
<input type="number" id="ls-numteams" min="2" max="64" style="width:80px;"
|
<input type="number" id="ls-numteams" min="2" max="64" style="width:84px;"
|
||||||
onchange="pushSetting('numTeams',+this.value)" />
|
onchange="pushSetting('numTeams',+this.value)" />
|
||||||
<button class="btn btn-ghost btn-sm"
|
<button class="btn btn-ghost btn-sm"
|
||||||
onclick="pushSetting('numTeams',+document.getElementById('ls-numteams').value)">APPLY</button>
|
onclick="pushSetting('numTeams',+document.getElementById('ls-numteams').value)">APPLY</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top:18px;">
|
<div style="margin-top:20px;">
|
||||||
<div class="lbl" style="margin-bottom:10px;">TEAM NAMES</div>
|
<div class="lbl" style="margin-bottom:12px;">TEAM NAMES</div>
|
||||||
<div id="ls-team-names"
|
<div id="ls-team-names"
|
||||||
style="display:flex;flex-direction:column;gap:8px;max-height:260px;overflow-y:auto;padding-right:4px;">
|
style="display:flex;flex-direction:column;gap:9px;max-height:280px;overflow-y:auto;padding-right:4px;">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -351,8 +353,8 @@
|
|||||||
<div class="modal-bg" id="m-rejoin">
|
<div class="modal-bg" id="m-rejoin">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<h2>// REJOIN SESSION</h2>
|
<h2>// REJOIN SESSION</h2>
|
||||||
<p style="font-size:13px;color:var(--dim);">You have a saved mod session. Rejoin it?</p>
|
<p style="font-size:13px;color:var(--dim);line-height:1.7;">You have a saved mod session. Rejoin it?</p>
|
||||||
<div style="font-size:24px;letter-spacing:6px;color:var(--g);font-weight:700;" id="m-rejoin-code"></div>
|
<div style="font-size:28px;letter-spacing:8px;color:var(--g);font-weight:700;text-shadow:0 0 12px var(--g);" id="m-rejoin-code"></div>
|
||||||
<div class="modal-btns">
|
<div class="modal-btns">
|
||||||
<button class="btn btn-ghost btn-sm" onclick="clearMod();closeModal('m-rejoin')">DISCARD</button>
|
<button class="btn btn-ghost btn-sm" onclick="clearMod();closeModal('m-rejoin')">DISCARD</button>
|
||||||
<button class="btn btn-g btn-sm" onclick="doRejoin()">REJOIN →</button>
|
<button class="btn btn-g btn-sm" onclick="doRejoin()">REJOIN →</button>
|
||||||
@@ -363,7 +365,7 @@
|
|||||||
<div class="modal-bg" id="m-end">
|
<div class="modal-bg" id="m-end">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<h2>// END ROOM</h2>
|
<h2>// END ROOM</h2>
|
||||||
<p style="font-size:13px;color:var(--dim);">All players will be disconnected and the room deleted.</p>
|
<p style="font-size:13px;color:var(--dim);line-height:1.7;">All players will be disconnected and the room deleted.</p>
|
||||||
<div class="modal-btns">
|
<div class="modal-btns">
|
||||||
<button class="btn btn-ghost btn-sm" onclick="closeModal('m-end')">CANCEL</button>
|
<button class="btn btn-ghost btn-sm" onclick="closeModal('m-end')">CANCEL</button>
|
||||||
<button class="btn btn-red btn-sm" onclick="ws_send({type:'end_room'});closeModal('m-end')">END ROOM</button>
|
<button class="btn btn-red btn-sm" onclick="ws_send({type:'end_room'});closeModal('m-end')">END ROOM</button>
|
||||||
@@ -372,6 +374,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="toasts"></div>
|
<div id="toasts"></div>
|
||||||
|
|
||||||
|
<!-- GSAP -->
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
|
||||||
<script src="/script.js"></script>
|
<script src="/script.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
@@ -2,25 +2,13 @@
|
|||||||
// GREEK ALPHABET (mirrors server for display)
|
// 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"];
|
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) {
|
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;}
|
||||||
const vals = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1];
|
function greekName(i){const cycle=Math.floor(i/GREEK.length);return cycle===0?GREEK[i%GREEK.length]:`${GREEK[i%GREEK.length]} ${toRoman(cycle+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
|
// COLORS — HSL wheel, infinite unique colors
|
||||||
// ══════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════
|
||||||
function teamColor(i) {
|
function teamColor(i){const hue=(i*137.508)%360;return `hsl(${hue},90%,58%)`;}
|
||||||
const hue = (i * 137.508) % 360; // golden angle spacing
|
|
||||||
return `hsl(${hue},90%,58%)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════
|
||||||
// STATE
|
// STATE
|
||||||
@@ -36,7 +24,7 @@ let playerTimerRemaining = 0, playerTimerInterval = null;
|
|||||||
const saveMod=(id,s)=>localStorage.setItem('mod',JSON.stringify({id,s}));
|
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 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 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 savePlay=(rid,pid)=>localStorage.setItem('play',JSON.stringify({rid,pid}));
|
||||||
const loadPlay=()=>{try{return JSON.parse(localStorage.getItem('play')||'null');}catch{return null;}};
|
const loadPlay=()=>{try{return JSON.parse(localStorage.getItem('play')||'null');}catch{return null;}};
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════
|
||||||
@@ -56,11 +44,9 @@ function schedReconn() {
|
|||||||
clearTimeout(reconnTimer);
|
clearTimeout(reconnTimer);
|
||||||
const d=Math.min(8000,500*Math.pow(1.5,reconnAttempts++));
|
const d=Math.min(8000,500*Math.pow(1.5,reconnAttempts++));
|
||||||
reconnTimer=setTimeout(()=>{
|
reconnTimer=setTimeout(()=>{
|
||||||
if (role === 'mod') {
|
if(role==='mod'){const m=loadMod();if(m)connect(()=>ws_send({type:'mod_rejoin',roomId:m.id,modSecret:m.s}));}
|
||||||
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 if (role === 'player' && myId) {
|
else connect(null);
|
||||||
const p = loadPlay(); if (p) connect(() => ws_send({ type: 'join_room', roomId: p.rid, playerName: p.name, playerId: p.pid }));
|
|
||||||
} else connect(null);
|
|
||||||
},d);
|
},d);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,34 +68,42 @@ function handle(msg) {
|
|||||||
break;
|
break;
|
||||||
case 'joined':
|
case 'joined':
|
||||||
myId=msg.playerId;room=msg.room;role='player';
|
myId=msg.playerId;room=msg.room;role='player';
|
||||||
savePlay(room.id, myId, document.getElementById('ji-name').value || loadPlay()?.name || '');
|
savePlay(room.id,myId);
|
||||||
showScr('s-player');renderPlayer();
|
showScr('s-player');renderPlayer();
|
||||||
break;
|
break;
|
||||||
case 'room_update':
|
case 'room_update':
|
||||||
room=msg.room;
|
room=msg.room;
|
||||||
if (role === 'mod') renderMod(); else renderPlayer();
|
if(role==='mod'){renderMod();renderRoundButtons();}else renderPlayer();
|
||||||
break;
|
break;
|
||||||
case 'settings_updated':
|
case 'settings_updated':
|
||||||
room=msg.room;
|
room=msg.room;
|
||||||
if (role === 'mod') { renderMod(); renderModSettings(); } else renderPlayer();
|
if(role==='mod'){renderMod();renderRoundButtons();renderModSettings();}else renderPlayer();
|
||||||
|
break;
|
||||||
|
case 'new_round':
|
||||||
|
toast('NEW ROUND STARTED','ok');
|
||||||
break;
|
break;
|
||||||
case 'round_open':
|
case 'round_open':
|
||||||
room=msg.room;
|
room=msg.room;
|
||||||
if (role === 'mod') renderMod();
|
if(role==='mod'){renderMod();renderRoundButtons();}
|
||||||
else { renderPlayerBuzzer(); startPlayerTimer(); toast('ROUND OPEN', 'ok'); }
|
else{renderPlayerBuzzer();startPlayerTimer();}
|
||||||
|
// If timer is enabled and this is a fresh open, load the timer
|
||||||
|
if(room.settings.timerSeconds>0 && modTimerRemaining===0 && !modTimerRunning){
|
||||||
|
modTimerRemaining=room.settings.timerSeconds;
|
||||||
|
renderModTimerDisplay();
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'round_closed':
|
case 'round_closed':
|
||||||
room=msg.room;
|
room=msg.room;
|
||||||
if (role === 'mod') renderMod();
|
if(role==='mod'){renderMod();renderRoundButtons();toast('ROUND CLOSED','warn');}
|
||||||
else { renderPlayerBuzzer(); stopPlayerTimer(); toast('ROUND CLOSED', 'warn'); }
|
else{renderPlayerBuzzer();stopPlayerTimer();}
|
||||||
break;
|
break;
|
||||||
case 'buzzer_reset':
|
case 'buzzer_reset':
|
||||||
room=msg.room;
|
room=msg.room;
|
||||||
if (role === 'mod') renderMod(); else renderPlayerBuzzer();
|
if(role==='mod'){renderMod();renderRoundButtons();}else renderPlayerBuzzer();
|
||||||
break;
|
break;
|
||||||
case 'buzz_event':
|
case 'buzz_event':
|
||||||
room=msg.room;
|
room=msg.room;
|
||||||
if (role === 'mod') renderModBuzz(msg); else { renderPlayerBuzzer(); addFeed(msg); }
|
if(role==='mod'){renderMod();renderRoundButtons();}else{renderPlayerBuzzer();addFeed(msg);}
|
||||||
break;
|
break;
|
||||||
case 'buzz_rejected':
|
case 'buzz_rejected':
|
||||||
toast(msg.reason,'warn');break;
|
toast(msg.reason,'warn');break;
|
||||||
@@ -130,19 +124,53 @@ function handle(msg) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════
|
||||||
// SCREENS
|
// SCREENS — with GSAP transitions
|
||||||
// ══════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════
|
||||||
|
let _currentScr = null;
|
||||||
function showScr(id){
|
function showScr(id){
|
||||||
document.querySelectorAll('.scr').forEach(s => { s.classList.remove('on'); s.style.display = 'none'; });
|
const prev = _currentScr;
|
||||||
const el = document.getElementById(id);
|
const next = document.getElementById(id);
|
||||||
el.style.display = 'flex'; el.classList.add('on');
|
|
||||||
|
// Hide all screens
|
||||||
|
document.querySelectorAll('.scr').forEach(s=>{
|
||||||
|
if(s.id !== id){
|
||||||
|
s.style.display='none';
|
||||||
|
s.classList.remove('on');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
next.style.display='flex';
|
||||||
|
next.classList.add('on');
|
||||||
|
_currentScr = id;
|
||||||
|
|
||||||
|
// GSAP entrance
|
||||||
|
if(typeof gsap !== 'undefined'){
|
||||||
|
gsap.fromTo(next,
|
||||||
|
{opacity:0, y:prev?14:22},
|
||||||
|
{opacity:1, y:0, duration:0.45, ease:'power3.out'}
|
||||||
|
);
|
||||||
|
// stagger children
|
||||||
|
const children = next.querySelectorAll(':scope > *');
|
||||||
|
gsap.fromTo(children,
|
||||||
|
{opacity:0, y:16},
|
||||||
|
{opacity:1, y:0, duration:0.5, stagger:0.06, ease:'power3.out', delay:0.05}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
next.style.opacity='1';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update room chip
|
||||||
const chip=document.getElementById('hdr-room');
|
const chip=document.getElementById('hdr-room');
|
||||||
if(room?.id){chip.textContent='['+room.id+']';chip.style.display='block';}
|
if(room?.id){chip.textContent='['+room.id+']';chip.style.display='block';}
|
||||||
else chip.style.display='none';
|
else chip.style.display='none';
|
||||||
}
|
}
|
||||||
|
|
||||||
function setConn(on){
|
function setConn(on){
|
||||||
document.getElementById('cdot').className='conn-dot'+(on?' on':'');
|
document.getElementById('cdot').className='conn-dot'+(on?' on':'');
|
||||||
document.getElementById('clbl').textContent=on?'ONLINE':'OFFLINE';
|
document.getElementById('clbl').textContent=on?'ONLINE':'OFFLINE';
|
||||||
|
if(on && typeof gsap!=='undefined'){
|
||||||
|
gsap.fromTo('#clbl',{opacity:0.3},{opacity:1,duration:0.4});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════
|
||||||
@@ -151,10 +179,8 @@ function setConn(on) {
|
|||||||
function goSetup(){renderSetupTeamNames();showScr('s-setup');}
|
function goSetup(){renderSetupTeamNames();showScr('s-setup');}
|
||||||
function joinRoom(){
|
function joinRoom(){
|
||||||
const code=document.getElementById('ji-code').value.trim().toUpperCase();
|
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(!code){toast('Enter room code','err');return;}
|
||||||
if (!name) { toast('Enter your name', 'err'); return; }
|
connect(()=>ws_send({type:'join_room',roomId:code}));
|
||||||
connect(() => ws_send({ type: 'join_room', roomId: code, playerName: name }));
|
|
||||||
}
|
}
|
||||||
function openRejoin(){
|
function openRejoin(){
|
||||||
const m=loadMod();if(!m)return;
|
const m=loadMod();if(!m)return;
|
||||||
@@ -175,8 +201,23 @@ function segSelect(groupId, el) {
|
|||||||
el.classList.add('active');
|
el.classList.add('active');
|
||||||
if(groupId==='seg-mode'){
|
if(groupId==='seg-mode'){
|
||||||
const isTeams=el.dataset.v==='teams';
|
const isTeams=el.dataset.v==='teams';
|
||||||
document.getElementById('team-opts').style.display = isTeams ? 'flex' : 'none';
|
const teamOpts=document.getElementById('team-opts');
|
||||||
document.getElementById('team-opts').style.flexDirection = 'column';
|
if(isTeams){
|
||||||
|
teamOpts.style.display='flex';
|
||||||
|
teamOpts.style.flexDirection='column';
|
||||||
|
if(typeof gsap!=='undefined'){
|
||||||
|
gsap.fromTo(teamOpts,{opacity:0,y:-8},{opacity:1,y:0,duration:0.3,ease:'power2.out'});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if(typeof gsap!=='undefined'){
|
||||||
|
gsap.to(teamOpts,{opacity:0,y:-8,duration:0.2,ease:'power2.in',onComplete:()=>{
|
||||||
|
teamOpts.style.display='none';
|
||||||
|
teamOpts.style.opacity='1';
|
||||||
|
}});
|
||||||
|
} else {
|
||||||
|
teamOpts.style.display='none';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,10 +228,10 @@ function renderSetupTeamNames() {
|
|||||||
container.innerHTML='';
|
container.innerHTML='';
|
||||||
for(let i=0;i<n;i++){
|
for(let i=0;i<n;i++){
|
||||||
const row=document.createElement('div');
|
const row=document.createElement('div');
|
||||||
row.style.cssText = 'display:flex;align-items:center;gap:8px;';
|
row.style.cssText='display:flex;align-items:center;gap:10px;';
|
||||||
const dot=document.createElement('div');
|
const dot=document.createElement('div');
|
||||||
const c=teamColor(i);
|
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;`;
|
dot.style.cssText=`width:12px;height:12px;border-radius:50%;background:${c};box-shadow:0 0 7px ${c};flex-shrink:0;`;
|
||||||
const inp=document.createElement('input');
|
const inp=document.createElement('input');
|
||||||
inp.type='text';inp.maxLength=32;
|
inp.type='text';inp.maxLength=32;
|
||||||
inp.value=existing[i]||greekName(i);
|
inp.value=existing[i]||greekName(i);
|
||||||
@@ -201,7 +242,20 @@ function renderSetupTeamNames() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleTimerField(){
|
function toggleTimerField(){
|
||||||
document.getElementById('timer-field').style.display = document.getElementById('st-usetimer').checked ? 'block' : 'none';
|
const field=document.getElementById('timer-field');
|
||||||
|
const show=document.getElementById('st-usetimer').checked;
|
||||||
|
if(show){
|
||||||
|
field.style.display='block';
|
||||||
|
if(typeof gsap!=='undefined'){
|
||||||
|
gsap.fromTo(field,{opacity:0,y:-6},{opacity:1,y:0,duration:0.25,ease:'power2.out'});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if(typeof gsap!=='undefined'){
|
||||||
|
gsap.to(field,{opacity:0,duration:0.2,onComplete:()=>{field.style.display='none';field.style.opacity='1';}});
|
||||||
|
} else {
|
||||||
|
field.style.display='none';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createRoom(){
|
function createRoom(){
|
||||||
@@ -226,7 +280,6 @@ function createRoom() {
|
|||||||
// MOD TIMER
|
// MOD TIMER
|
||||||
// ══════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════
|
||||||
function fmtTime(s){return Math.floor(s/60)+':'+(s%60<10?'0':'')+(s%60);}
|
function fmtTime(s){return Math.floor(s/60)+':'+(s%60<10?'0':'')+(s%60);}
|
||||||
|
|
||||||
function modTimerLoad(){
|
function modTimerLoad(){
|
||||||
modTimerRemaining=Math.max(5,parseInt(document.getElementById('mod-timer-set').value)||30);
|
modTimerRemaining=Math.max(5,parseInt(document.getElementById('mod-timer-set').value)||30);
|
||||||
modTimerRunning=false;
|
modTimerRunning=false;
|
||||||
@@ -240,7 +293,6 @@ function modTimerReset() {
|
|||||||
modTimerRemaining=Math.max(5,parseInt(document.getElementById('mod-timer-set').value)||30);
|
modTimerRemaining=Math.max(5,parseInt(document.getElementById('mod-timer-set').value)||30);
|
||||||
document.getElementById('btn-timer-ss').textContent='START';
|
document.getElementById('btn-timer-ss').textContent='START';
|
||||||
renderModTimerDisplay();
|
renderModTimerDisplay();
|
||||||
// broadcast reset to players
|
|
||||||
broadcastTimerToPlayers(modTimerRemaining,false);
|
broadcastTimerToPlayers(modTimerRemaining,false);
|
||||||
}
|
}
|
||||||
function modTimerToggle(){
|
function modTimerToggle(){
|
||||||
@@ -262,10 +314,49 @@ function modTimerToggle() {
|
|||||||
document.getElementById('btn-timer-ss').textContent='START';
|
document.getElementById('btn-timer-ss').textContent='START';
|
||||||
ws_send({type:'close_round'});
|
ws_send({type:'close_round'});
|
||||||
toast('TIME UP — round closed','warn');
|
toast('TIME UP — round closed','warn');
|
||||||
|
if(typeof gsap!=='undefined'){
|
||||||
|
gsap.to('#mod-timer-disp',{scale:1.08,duration:0.1,yoyo:true,repeat:3,ease:'power2.inOut'});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},1000);
|
},1000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleTimer(){
|
||||||
|
modTimerToggle();
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
if(typeof gsap!=='undefined'){
|
||||||
|
gsap.to('#mod-timer-disp',{scale:1.08,duration:0.1,yoyo:true,repeat:3,ease:'power2.inOut'});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTimerFromSwitch(){
|
||||||
|
modTimerToggle();
|
||||||
|
}
|
||||||
|
|
||||||
function renderModTimerDisplay(){
|
function renderModTimerDisplay(){
|
||||||
const el=document.getElementById('mod-timer-disp');
|
const el=document.getElementById('mod-timer-disp');
|
||||||
const s=modTimerRemaining;
|
const s=modTimerRemaining;
|
||||||
@@ -273,10 +364,24 @@ function renderModTimerDisplay() {
|
|||||||
el.className='timer-digits'+(s<=5?' danger':s<=10?' warn':'');
|
el.className='timer-digits'+(s<=5?' danger':s<=10?' warn':'');
|
||||||
}
|
}
|
||||||
|
|
||||||
// We sync timer to players via a side-channel: store in sessionStorage and poll
|
function modTimerLoad(){
|
||||||
// (pure client-side sync — no extra server message needed)
|
const sec=parseInt(document.getElementById('mod-timer-set').value)||30;
|
||||||
|
modTimerRemaining=sec;
|
||||||
|
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);
|
||||||
|
renderModTimerDisplay();
|
||||||
|
broadcastTimerToPlayers(modTimerRemaining,false);
|
||||||
|
}
|
||||||
|
|
||||||
function broadcastTimerToPlayers(sec,running){
|
function broadcastTimerToPlayers(sec,running){
|
||||||
// encode into a BroadcastChannel so other tabs (players on same device) see it
|
|
||||||
try{
|
try{
|
||||||
const bc=new BroadcastChannel('buzzer_timer');
|
const bc=new BroadcastChannel('buzzer_timer');
|
||||||
bc.postMessage({sec,running});
|
bc.postMessage({sec,running});
|
||||||
@@ -284,6 +389,78 @@ function broadcastTimerToPlayers(sec, running) {
|
|||||||
}catch{}
|
}catch{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════
|
||||||
|
// MOD ROUND CONTROL
|
||||||
|
// ══════════════════════════════════════════════════════
|
||||||
|
function toggleRound(){
|
||||||
|
if(room.buzzerState.roundOpen){
|
||||||
|
ws_send({type:'close_round'});
|
||||||
|
// If linked (toggle ON) and timer running, pause it
|
||||||
|
if(document.getElementById('timer-tog').checked && modTimerRunning){
|
||||||
|
modTimerToggle();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ws_send({type:'open_round'});
|
||||||
|
// If linked (toggle ON) and timer stopped, reset and start it
|
||||||
|
if(document.getElementById('timer-tog').checked && !modTimerRunning){
|
||||||
|
modTimerLoad();
|
||||||
|
modTimerRunning=true;
|
||||||
|
document.getElementById('btn-timer-ss').textContent='PAUSE';
|
||||||
|
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 resumeRound(){
|
||||||
|
ws_send({type:'resume_round'});
|
||||||
|
// If linked (toggle ON) and timer stopped, resume it (preserve current value)
|
||||||
|
if(document.getElementById('timer-tog').checked && !modTimerRunning){
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════
|
||||||
|
// TIMER
|
||||||
|
// ══════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function renderRoundButtons(){
|
||||||
|
const btn=document.getElementById('mod-round-btn');
|
||||||
|
const resumeBtn=document.getElementById('mod-resume-btn');
|
||||||
|
|
||||||
|
if(room.buzzerState.roundOpen){
|
||||||
|
btn.innerHTML='■ PAUSE ROUND';
|
||||||
|
btn.className='btn btn-red btn-full';
|
||||||
|
resumeBtn.style.display='none';
|
||||||
|
} else {
|
||||||
|
btn.innerHTML='▶ OPEN ROUND';
|
||||||
|
btn.className='btn btn-g btn-full';
|
||||||
|
resumeBtn.style.display='block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════
|
||||||
// MOD RENDER
|
// MOD RENDER
|
||||||
// ══════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════
|
||||||
@@ -305,6 +482,7 @@ function renderMod() {
|
|||||||
renderModBuzz(null);
|
renderModBuzz(null);
|
||||||
renderModPlayerList();
|
renderModPlayerList();
|
||||||
renderModTeams();
|
renderModTeams();
|
||||||
|
renderRoundButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderModBuzz(evt){
|
function renderModBuzz(evt){
|
||||||
@@ -326,12 +504,19 @@ function renderModBuzz(evt) {
|
|||||||
div.innerHTML=`
|
div.innerHTML=`
|
||||||
<div class="bz-rank ${idx===0?'first':''}">${idx+1}</div>
|
<div class="bz-rank ${idx===0?'first':''}">${idx+1}</div>
|
||||||
<div class="bz-info">
|
<div class="bz-info">
|
||||||
<div class="bz-name">${esc(p.name)}</div>
|
<div class="bz-name">${pid}</div>
|
||||||
${teamName?`<div class="bz-team" style="color:${color}">${esc(teamName)}</div>`:''}
|
${teamName?`<div class="bz-team" style="color:${color}">${esc(teamName)}</div>`:''}
|
||||||
${ms?`<div class="bz-ms">${ms} after first</div>`:''}
|
${ms?`<div class="bz-ms">${ms} after first</div>`:''}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
listEl.appendChild(div);
|
listEl.appendChild(div);
|
||||||
|
// GSAP entrance for new entries
|
||||||
|
if(typeof gsap!=='undefined'){
|
||||||
|
gsap.fromTo(div,
|
||||||
|
{opacity:0,x:-24},
|
||||||
|
{opacity:1,x:0,duration:0.4,ease:'power3.out',delay:idx*0.05}
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,13 +535,13 @@ function renderModPlayerList() {
|
|||||||
const tn=room.settings.teamNames[i]??greekName(i);
|
const tn=room.settings.teamNames[i]??greekName(i);
|
||||||
opts+=`<option value="${i}" ${p.teamIndex===i?'selected':''}>${esc(tn)}</option>`;
|
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>`;
|
teamSel=`<select style="margin-top:8px;font-size:12px;padding:6px 10px;" onchange="ws_send({type:'assign_team',playerId:'${p.id}',teamIndex:+this.value===-1?null:+this.value})">${opts}</select>`;
|
||||||
}
|
}
|
||||||
const row=document.createElement('div');
|
const row=document.createElement('div');
|
||||||
row.className='pl-row'+(p.isConnected?'':' offline');
|
row.className='pl-row'+(p.isConnected?'':' offline');
|
||||||
row.innerHTML=`
|
row.innerHTML=`
|
||||||
<div class="pl-info" style="flex:1;min-width:0;">
|
<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>
|
<div class="pl-name">${p.id} ${p.isConnected?'':`<span class="tag tag-red" style="font-size:9px;padding:2px 6px;">OFFLINE</span>`}</div>
|
||||||
${teamName?`<div class="pl-meta" style="color:${color}">${esc(teamName)}</div>`:'<div class="pl-meta">No team</div>'}
|
${teamName?`<div class="pl-meta" style="color:${color}">${esc(teamName)}</div>`:'<div class="pl-meta">No team</div>'}
|
||||||
${teamSel}
|
${teamSel}
|
||||||
</div>
|
</div>
|
||||||
@@ -382,9 +567,12 @@ function renderModTeams() {
|
|||||||
card.innerHTML=`
|
card.innerHTML=`
|
||||||
<div class="tc-n" style="color:${color}">${esc(name)}</div>
|
<div class="tc-n" style="color:${color}">${esc(name)}</div>
|
||||||
<div class="tc-c" style="color:${color}">${members.length}</div>
|
<div class="tc-c" style="color:${color}">${members.length}</div>
|
||||||
<div class="tc-m">${members.map(p => esc(p.name)).join('<br>') || '—'}</div>
|
<div class="tc-m">${members.map(p=>p.id).join('<br>')||'—'}</div>
|
||||||
`;
|
`;
|
||||||
grid.appendChild(card);
|
grid.appendChild(card);
|
||||||
|
if(typeof gsap!=='undefined'){
|
||||||
|
gsap.fromTo(card,{opacity:0,scale:0.92},{opacity:1,scale:1,duration:0.35,ease:'back.out(1.2)',delay:i*0.04});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,6 +585,8 @@ function renderModSettings() {
|
|||||||
document.getElementById('ls-numteams').value=s.numTeams;
|
document.getElementById('ls-numteams').value=s.numTeams;
|
||||||
segActivate('ls-seg-mode',s.mode);
|
segActivate('ls-seg-mode',s.mode);
|
||||||
renderLiveTeamNames();
|
renderLiveTeamNames();
|
||||||
|
renderRoundButtons();
|
||||||
|
renderModTimerDisplay();
|
||||||
}
|
}
|
||||||
|
|
||||||
function segActivate(groupId,val){
|
function segActivate(groupId,val){
|
||||||
@@ -409,10 +599,10 @@ function renderLiveTeamNames() {
|
|||||||
container.innerHTML='';
|
container.innerHTML='';
|
||||||
for(let i=0;i<room.settings.numTeams;i++){
|
for(let i=0;i<room.settings.numTeams;i++){
|
||||||
const row=document.createElement('div');
|
const row=document.createElement('div');
|
||||||
row.style.cssText = 'display:flex;align-items:center;gap:8px;';
|
row.style.cssText='display:flex;align-items:center;gap:10px;';
|
||||||
const dot=document.createElement('div');
|
const dot=document.createElement('div');
|
||||||
const c=teamColor(i);
|
const c=teamColor(i);
|
||||||
dot.style.cssText = `width:11px;height:11px;border-radius:50%;background:${c};flex-shrink:0;`;
|
dot.style.cssText=`width:12px;height:12px;border-radius:50%;background:${c};flex-shrink:0;`;
|
||||||
const inp=document.createElement('input');
|
const inp=document.createElement('input');
|
||||||
inp.type='text';inp.maxLength=32;
|
inp.type='text';inp.maxLength=32;
|
||||||
inp.value=room.settings.teamNames[i]??greekName(i);
|
inp.value=room.settings.teamNames[i]??greekName(i);
|
||||||
@@ -453,9 +643,7 @@ function initPlayerTimer() {
|
|||||||
}catch{}
|
}catch{}
|
||||||
}
|
}
|
||||||
function stopPlayerTimer(){clearInterval(playerTimerInterval);}
|
function stopPlayerTimer(){clearInterval(playerTimerInterval);}
|
||||||
function startPlayerTimer() {
|
function startPlayerTimer(){}
|
||||||
// timer already synced via BroadcastChannel
|
|
||||||
}
|
|
||||||
function renderPlayerTimer(){
|
function renderPlayerTimer(){
|
||||||
const el=document.getElementById('p-timer');
|
const el=document.getElementById('p-timer');
|
||||||
const s=playerTimerRemaining;
|
const s=playerTimerRemaining;
|
||||||
@@ -471,7 +659,7 @@ function renderPlayer() {
|
|||||||
if(!room)return;
|
if(!room)return;
|
||||||
document.getElementById('p-code').textContent=room.id;
|
document.getElementById('p-code').textContent=room.id;
|
||||||
const me=room.players.find(p=>p.id===myId);
|
const me=room.players.find(p=>p.id===myId);
|
||||||
document.getElementById('p-namelbl').textContent = me?.name ?? '';
|
document.getElementById('p-namelbl').textContent=me?.id||'';
|
||||||
renderTeamPicker();
|
renderTeamPicker();
|
||||||
renderPlayerBuzzer();
|
renderPlayerBuzzer();
|
||||||
renderRoster();
|
renderRoster();
|
||||||
@@ -492,9 +680,13 @@ function renderTeamPicker() {
|
|||||||
const isMine=me?.teamIndex===i;
|
const isMine=me?.teamIndex===i;
|
||||||
const btn=document.createElement('button');
|
const btn=document.createElement('button');
|
||||||
btn.className='team-btn'+(isMine?' mine':'');
|
btn.className='team-btn'+(isMine?' mine':'');
|
||||||
btn.style.borderColor = isMine ? color : 'var(--border)';
|
btn.style.borderColor=isMine?color:'var(--border2)';
|
||||||
btn.style.color=isMine?color:'var(--text)';
|
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.style.padding='16px';
|
||||||
|
btn.style.fontSize='20px';
|
||||||
|
btn.style.fontWeight='600';
|
||||||
|
btn.style.cursor='pointer';
|
||||||
|
btn.innerHTML=`<div style="font-size:24px;">${esc(s.teamNames[i]??greekName(i))}</div><div style="font-size:14px;color:var(--dim);margin-top:4px;">${members.length} player${members.length!==1?'s':''}</div>`;
|
||||||
btn.onclick=()=>ws_send({type:'pick_team',teamIndex:i});
|
btn.onclick=()=>ws_send({type:'pick_team',teamIndex:i});
|
||||||
grid.appendChild(btn);
|
grid.appendChild(btn);
|
||||||
}
|
}
|
||||||
@@ -507,24 +699,39 @@ function renderPlayerBuzzer() {
|
|||||||
const bz=room.buzzerState;
|
const bz=room.buzzerState;
|
||||||
const already=bz.buzzOrder.includes(myId);
|
const already=bz.buzzOrder.includes(myId);
|
||||||
const isFirst=bz.buzzOrder[0]===myId;
|
const isFirst=bz.buzzOrder[0]===myId;
|
||||||
|
|
||||||
|
const hint=document.getElementById('spacebar-hint');
|
||||||
|
let newState='';
|
||||||
if(!bz.roundOpen){
|
if(!bz.roundOpen){
|
||||||
|
newState='closed';
|
||||||
btn.className='s-closed';btn.disabled=true;sts.textContent='WAITING FOR ROUND';
|
btn.className='s-closed';btn.disabled=true;sts.textContent='WAITING FOR ROUND';
|
||||||
|
if(hint)hint.classList.remove('visible');
|
||||||
} else if(isFirst){
|
} else if(isFirst){
|
||||||
btn.className = 's-first'; btn.disabled = false; sts.textContent = '⚡ YOU BUZZED FIRST!';
|
newState='first';
|
||||||
|
btn.className='s-first';btn.disabled=false;sts.textContent='YOU BUZZED FIRST!';
|
||||||
|
if(hint)hint.classList.remove('visible');
|
||||||
} else if(already){
|
} else if(already){
|
||||||
const pos=bz.buzzOrder.indexOf(myId)+1;
|
const pos=bz.buzzOrder.indexOf(myId)+1;
|
||||||
|
newState='buzzed';
|
||||||
btn.className='s-buzzed';btn.disabled=true;sts.textContent='BUZZED — #'+pos+' IN ORDER';
|
btn.className='s-buzzed';btn.disabled=true;sts.textContent='BUZZED — #'+pos+' IN ORDER';
|
||||||
|
if(hint)hint.classList.remove('visible');
|
||||||
} else if(room.settings.buzzerLockout&&bz.buzzOrder.length>0){
|
} else if(room.settings.buzzerLockout&&bz.buzzOrder.length>0){
|
||||||
|
newState='locked';
|
||||||
btn.className='s-locked';btn.disabled=true;sts.textContent='BUZZER LOCKED OUT';
|
btn.className='s-locked';btn.disabled=true;sts.textContent='BUZZER LOCKED OUT';
|
||||||
|
if(hint)hint.classList.remove('visible');
|
||||||
} else {
|
} else {
|
||||||
|
newState='open';
|
||||||
btn.className='s-open';btn.disabled=false;sts.textContent='ROUND OPEN — BUZZ!';
|
btn.className='s-open';btn.disabled=false;sts.textContent='ROUND OPEN — BUZZ!';
|
||||||
|
if(hint)hint.classList.add('visible');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_lastBuzzState=newState;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRoster(){
|
function renderRoster(){
|
||||||
if(!room)return;
|
if(!room)return;
|
||||||
const el=document.getElementById('p-roster');
|
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; }
|
if(room.players.length===0){el.innerHTML='<div style="font-size:18px;color:var(--dim);padding:20px;">No players yet.</div>';return;}
|
||||||
el.innerHTML='';
|
el.innerHTML='';
|
||||||
room.players.forEach(p=>{
|
room.players.forEach(p=>{
|
||||||
const isMe=p.id===myId;
|
const isMe=p.id===myId;
|
||||||
@@ -533,9 +740,9 @@ function renderRoster() {
|
|||||||
const row=document.createElement('div');
|
const row=document.createElement('div');
|
||||||
row.className='roster-row'+(isMe?' roster-me':'');
|
row.className='roster-row'+(isMe?' roster-me':'');
|
||||||
row.innerHTML=`
|
row.innerHTML=`
|
||||||
<div class="roster-dot" style="background:${p.isConnected ? (isMe ? 'var(--g)' : color) : 'var(--border)'}"></div>
|
<div class="roster-dot" style="width:16px;height:16px;border-radius:50%;background:${p.isConnected?(isMe?'var(--g)':color):'var(--border2)'}"></div>
|
||||||
<div style="flex:1">${esc(p.name)}${isMe ? ' <span style="font-size:11px;color:var(--dim)">(YOU)</span>' : ''}</div>
|
<div style="font-size:20px;font-weight:700;">#${p.id}</div>
|
||||||
${teamName ? `<div style="font-size:12px;color:${color}">${esc(teamName)}</div>` : ''}
|
${teamName?`<div style="font-size:14px;color:${color};margin-top:2px;">${esc(teamName)}</div>`:''}
|
||||||
`;
|
`;
|
||||||
el.appendChild(row);
|
el.appendChild(row);
|
||||||
});
|
});
|
||||||
@@ -548,13 +755,15 @@ function addFeed(evt) {
|
|||||||
const teamStr=(room?.settings.mode==='teams'&&evt.teamIndex!==null)?` [${esc(room.settings.teamNames[evt.teamIndex]??'')}]`:'';
|
const teamStr=(room?.settings.mode==='teams'&&evt.teamIndex!==null)?` [${esc(room.settings.teamNames[evt.teamIndex]??'')}]`:'';
|
||||||
const div=document.createElement('div');
|
const div=document.createElement('div');
|
||||||
div.className='feed-entry'+(isFirst?' first':'');
|
div.className='feed-entry'+(isFirst?' first':'');
|
||||||
div.style.borderColor = isFirst ? 'var(--yellow)' : color;
|
div.style.borderLeftColor=isFirst?'var(--yellow)':color;
|
||||||
div.innerHTML = `<strong>${esc(evt.playerName)}</strong>${teamStr} buzzed${isFirst ? ' <span style="color:var(--yellow)">— FIRST!</span>' : ''}`;
|
div.innerHTML=`<strong>#${evt.playerId}</strong>${teamStr} buzzed${isFirst?' <span style="color:var(--yellow);font-weight:700;"> — FIRST!</span>':''}`;
|
||||||
feed.prepend(div);
|
feed.prepend(div);
|
||||||
while(feed.children.length>30)feed.removeChild(feed.lastChild);
|
while(feed.children.length>30)feed.removeChild(feed.lastChild);
|
||||||
}
|
}
|
||||||
|
|
||||||
function doBuzz() { ws_send({ type: 'buzz' }); }
|
function doBuzz(){
|
||||||
|
ws_send({type:'buzz'});
|
||||||
|
}
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════
|
||||||
// TABS
|
// TABS
|
||||||
@@ -569,13 +778,35 @@ function setTab(name, el) {
|
|||||||
if(name==='players')renderModPlayerList();
|
if(name==='players')renderModPlayerList();
|
||||||
if(name==='teams')renderModTeams();
|
if(name==='teams')renderModTeams();
|
||||||
if(name==='settings')renderModSettings();
|
if(name==='settings')renderModSettings();
|
||||||
|
// Animate tab content
|
||||||
|
const activeTab=document.getElementById('tab-'+name);
|
||||||
|
if(activeTab&&typeof gsap!=='undefined'){
|
||||||
|
gsap.fromTo(activeTab,{opacity:0,y:8},{opacity:1,y:0,duration:0.3,ease:'power2.out'});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════
|
||||||
// MODALS / TOAST / UTIL
|
// MODALS / TOAST / UTIL
|
||||||
// ══════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════
|
||||||
function openModal(id) { document.getElementById(id).classList.add('on'); }
|
function openModal(id){
|
||||||
function closeModal(id) { document.getElementById(id).classList.remove('on'); }
|
const bg=document.getElementById(id);
|
||||||
|
bg.classList.add('on');
|
||||||
|
if(typeof gsap!=='undefined'){
|
||||||
|
const modal=bg.querySelector('.modal');
|
||||||
|
gsap.fromTo(bg,{opacity:0},{opacity:1,duration:0.25,ease:'power2.out'});
|
||||||
|
gsap.fromTo(modal,{scale:0.88,y:20,opacity:0},{scale:1,y:0,opacity:1,duration:0.35,ease:'back.out(1.5)'});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function closeModal(id){
|
||||||
|
const bg=document.getElementById(id);
|
||||||
|
if(typeof gsap!=='undefined'){
|
||||||
|
const modal=bg.querySelector('.modal');
|
||||||
|
gsap.to(modal,{scale:0.92,opacity:0,duration:0.2,ease:'power2.in'});
|
||||||
|
gsap.to(bg,{opacity:0,duration:0.25,ease:'power2.in',onComplete:()=>bg.classList.remove('on')});
|
||||||
|
} else {
|
||||||
|
bg.classList.remove('on');
|
||||||
|
}
|
||||||
|
}
|
||||||
function copyCode(){
|
function copyCode(){
|
||||||
if(!room)return;
|
if(!room)return;
|
||||||
navigator.clipboard.writeText(room.id).then(()=>toast('Code copied','ok'));
|
navigator.clipboard.writeText(room.id).then(()=>toast('Code copied','ok'));
|
||||||
@@ -584,8 +815,18 @@ function toast(msg, type = '') {
|
|||||||
const el=document.createElement('div');
|
const el=document.createElement('div');
|
||||||
el.className='toast '+type;el.textContent=msg;
|
el.className='toast '+type;el.textContent=msg;
|
||||||
document.getElementById('toasts').appendChild(el);
|
document.getElementById('toasts').appendChild(el);
|
||||||
|
if(typeof gsap!=='undefined'){
|
||||||
|
gsap.fromTo(el,
|
||||||
|
{opacity:0,x:20},
|
||||||
|
{opacity:1,x:0,duration:0.3,ease:'power3.out'}
|
||||||
|
);
|
||||||
|
setTimeout(()=>{
|
||||||
|
gsap.to(el,{opacity:0,x:12,duration:0.3,ease:'power2.in',onComplete:()=>el.remove()});
|
||||||
|
},2700);
|
||||||
|
} else {
|
||||||
setTimeout(()=>{ el.style.transition='opacity .3s';el.style.opacity='0';setTimeout(()=>el.remove(),300);},2700);
|
setTimeout(()=>{ el.style.transition='opacity .3s';el.style.opacity='0';setTimeout(()=>el.remove(),300);},2700);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
function esc(s){
|
function esc(s){
|
||||||
return String(s??'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
return String(s??'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
}
|
}
|
||||||
@@ -600,15 +841,36 @@ document.addEventListener('keydown', e => {
|
|||||||
if(tag==='INPUT'||tag==='TEXTAREA'||tag==='SELECT')return;
|
if(tag==='INPUT'||tag==='TEXTAREA'||tag==='SELECT')return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const btn=document.getElementById('buzz-btn');
|
const btn=document.getElementById('buzz-btn');
|
||||||
if (!btn.disabled) { btn.click(); btn.style.transform = 'scale(.93)'; setTimeout(() => btn.style.transform = '', 120); }
|
if(!btn.disabled){btn.click();}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// close modals on backdrop click
|
// close modals on backdrop click
|
||||||
document.querySelectorAll('.modal-bg').forEach(bg=>{
|
document.querySelectorAll('.modal-bg').forEach(bg=>{
|
||||||
bg.addEventListener('click', e => { if (e.target === bg) bg.classList.remove('on'); });
|
bg.addEventListener('click',e=>{if(e.target===bg)closeModal(bg.id);});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════
|
||||||
|
// LANDING PAGE ENTRANCE ANIMATION
|
||||||
|
// ══════════════════════════════════════════════════════
|
||||||
|
function animateLanding(){
|
||||||
|
if(typeof gsap==='undefined')return;
|
||||||
|
const h1=document.querySelector('#s-land .hero h1');
|
||||||
|
const sub=document.querySelector('#s-land .hero p');
|
||||||
|
const badge=document.querySelector('#s-land .hero-badge');
|
||||||
|
const cards=document.querySelectorAll('#s-land .land-card');
|
||||||
|
const rejoin=document.getElementById('rejoin-bar');
|
||||||
|
|
||||||
|
const tl=gsap.timeline({delay:0.1});
|
||||||
|
tl.fromTo(h1,{opacity:0,scale:0.85,y:24},{opacity:1,scale:1,y:0,duration:0.65,ease:'back.out(1.6)'});
|
||||||
|
tl.fromTo(sub,{opacity:0,y:12},{opacity:1,y:0,duration:0.4,ease:'power3.out'},'-=0.35');
|
||||||
|
if(badge)tl.fromTo(badge,{opacity:0,y:8},{opacity:1,y:0,duration:0.35,ease:'power2.out'},'-=0.25');
|
||||||
|
tl.fromTo(cards,{opacity:0,y:30},{opacity:1,y:0,duration:0.5,stagger:0.1,ease:'power3.out'},'-=0.2');
|
||||||
|
if(rejoin&&rejoin.style.display!=='none'){
|
||||||
|
tl.fromTo(rejoin,{opacity:0},{opacity:1,duration:0.4},'-=0.1');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════
|
||||||
// INIT
|
// INIT
|
||||||
// ══════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════
|
||||||
@@ -622,4 +884,6 @@ window.addEventListener('DOMContentLoaded', () => {
|
|||||||
// init seg mode display
|
// init seg mode display
|
||||||
const modeBtn=document.querySelector('#seg-mode .seg-opt.active');
|
const modeBtn=document.querySelector('#seg-mode .seg-opt.active');
|
||||||
if(modeBtn)segSelect('seg-mode',modeBtn);
|
if(modeBtn)segSelect('seg-mode',modeBtn);
|
||||||
|
// animate landing after GSAP loads
|
||||||
|
setTimeout(animateLanding,80);
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,30 @@
|
|||||||
import { readFileSync } from "fs";
|
import { readFileSync } from "fs";
|
||||||
import { handleMessage, handleClose } from "./ws-handler";
|
import { handleMessage, handleClose } from "./ws-handler";
|
||||||
|
|
||||||
const HTML = readFileSync("./src/public/index.html", "utf-8");
|
const PORT = parseInt(process.env.PORT || "3009", 10);
|
||||||
const CSS = readFileSync("./src/public/styles.css", "utf-8");
|
|
||||||
const JS = readFileSync("./src/public/script.js", "utf-8");
|
|
||||||
|
|
||||||
const server = Bun.serve({
|
const server = Bun.serve({
|
||||||
port: 3009,
|
port: PORT,
|
||||||
|
hostname: "0.0.0.0",
|
||||||
fetch(req, server) {
|
fetch(req, server) {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
if (url.pathname === "/ws") {
|
if (url.pathname === "/ws") {
|
||||||
if (!server.upgrade(req)) return new Response("WS upgrade failed", { status: 400 });
|
if (!server.upgrade(req)) return new Response("WS upgrade failed", { status: 400 });
|
||||||
return undefined as any;
|
return undefined as any;
|
||||||
}
|
}
|
||||||
if (url.pathname === "/styles.css") return new Response(CSS, { headers: { "Content-Type": "text/css" } });
|
if (url.pathname === "/styles.css") {
|
||||||
if (url.pathname === "/script.js") return new Response(JS, { headers: { "Content-Type": "text/javascript" } });
|
return new Response(readFileSync("./src/public/styles.css", "utf-8"), {
|
||||||
return new Response(HTML, { headers: { "Content-Type": "text/html; charset=utf-8" } });
|
headers: { "Content-Type": "text/css" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.pathname === "/script.js") {
|
||||||
|
return new Response(readFileSync("./src/public/script.js", "utf-8"), {
|
||||||
|
headers: { "Content-Type": "text/javascript" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response(readFileSync("./src/public/index.html", "utf-8"), {
|
||||||
|
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||||
|
});
|
||||||
},
|
},
|
||||||
websocket: {
|
websocket: {
|
||||||
perMessageDeflate: true,
|
perMessageDeflate: true,
|
||||||
|
|||||||
@@ -65,16 +65,19 @@ export function handleMessage(ws: WS, raw: string) {
|
|||||||
if (!room) { er(ws, "Room not found"); return; }
|
if (!room) { er(ws, "Room not found"); return; }
|
||||||
if (room.locked) { er(ws, "Room is locked"); return; }
|
if (room.locked) { er(ws, "Room is locked"); return; }
|
||||||
|
|
||||||
const name = sanitize(msg.playerName ?? "Player", 24) || "Player";
|
// Calculate numeric ID based on join order
|
||||||
const existingId = sanitize(msg.playerId ?? "", 12);
|
const joinCount = room.players.size + 1;
|
||||||
|
const playerId = joinCount.toString();
|
||||||
|
const name = sanitize(msg.playerName ?? "", 24) || ""; // Keep name for compatibility but don't use it
|
||||||
|
|
||||||
let player: Player | undefined;
|
let player: Player | undefined;
|
||||||
|
|
||||||
if (existingId && room.players.has(existingId)) {
|
if (playerId && room.players.has(playerId)) {
|
||||||
player = room.players.get(existingId)!;
|
player = room.players.get(playerId)!;
|
||||||
player.ws = ws; player.isConnected = true; player.name = name;
|
player.ws = ws; player.isConnected = true; player.name = name;
|
||||||
} else {
|
} else {
|
||||||
player = { id: genId(), name, teamIndex: null, ws, isConnected: true };
|
player = { id: playerId, name, teamIndex: null, ws, isConnected: true };
|
||||||
room.players.set(player.id, player);
|
room.players.set(playerId, player);
|
||||||
}
|
}
|
||||||
|
|
||||||
wsToPlayer.set(ws, { roomId: room.id, playerId: player.id });
|
wsToPlayer.set(ws, { roomId: room.id, playerId: player.id });
|
||||||
@@ -132,6 +135,15 @@ export function handleMessage(ws: WS, raw: string) {
|
|||||||
room.buzzerState = freshBuzzer();
|
room.buzzerState = freshBuzzer();
|
||||||
room.buzzerState.roundOpen = true;
|
room.buzzerState.roundOpen = true;
|
||||||
broadcast(room, { type: "round_open", room: publicRoom(room) });
|
broadcast(room, { type: "round_open", room: publicRoom(room) });
|
||||||
|
// Send new_round notification to all players (with toast)
|
||||||
|
const d = JSON.stringify({ type: "new_round" });
|
||||||
|
for (const p of room.players.values()) if (p.ws && p.isConnected) try { p.ws.send(d); } catch {}
|
||||||
|
if (room.modWs) try { room.modWs.send(d); } catch {}
|
||||||
|
break;
|
||||||
|
case "resume_round":
|
||||||
|
// Open round WITHOUT clearing existing buzzes (for accidental close recovery)
|
||||||
|
room.buzzerState.roundOpen = true;
|
||||||
|
broadcast(room, { type: "round_open", room: publicRoom(room) });
|
||||||
break;
|
break;
|
||||||
case "close_round":
|
case "close_round":
|
||||||
room.buzzerState.roundOpen = false;
|
room.buzzerState.roundOpen = false;
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ESNext",
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"strict": true,
|
|
||||||
"types": ["bun-types"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user