Spaces:
Sleeping
Sleeping
| <html> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <title>Timer Admin</title> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <style> | |
| :root { color-scheme: dark; } | |
| body { margin:0; font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; background:#0b0f14; color:#e6edf3; } | |
| .wrap { max-width: 960px; margin: 40px auto; padding: 0 20px; } | |
| .card { background: #121820; border: 1px solid #1f2933; border-radius: 16px; padding: 20px; box-shadow: 0 6px 24px rgba(0,0,0,.35); } | |
| h1 { margin: 0 0 6px; font-weight: 700; letter-spacing: .2px; } | |
| p.sub { margin: 0 16px 20px 0; color: #8ea0b5; } | |
| .row { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; margin-bottom: 16px; } | |
| .btn { border: 0; border-radius: 12px; padding: 12px 18px; font-weight: 600; cursor: pointer; background:#1e2936; color:#fff; transition: transform .02s ease, background .2s ease } | |
| .btn:active { transform: translateY(1px); } | |
| .btn.start { background: #2261ff; } | |
| .btn.stop { background: #f43f5e; } | |
| .btn.reset { background: #0ea5e9; } | |
| .btn.blackon { background:#000; border:1px solid #2c3a4a; } | |
| .btn.blackoff { background:#16a34a; } | |
| .input { background: #0d141c; color:#e6edf3; border:1px solid #1f2933; border-radius:12px; padding: 10px 12px; min-width: 160px; } | |
| .badge { background:#10161f; border:1px solid #223041; padding: 6px 10px; border-radius: 999px; color:#9fb4cc; display:inline-flex; gap:8px; align-items: center; } | |
| .grid { display:grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; } | |
| .stat { background:#0d141c; border: 1px solid #1f2933; border-radius:12px; padding: 14px; } | |
| .stat b { font-size: 24px; } | |
| code { background: #0d141c; border:1px solid #1f2933; padding:2px 6px; border-radius:6px; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="wrap"> | |
| <div class="card"> | |
| <h1>Timer Admin</h1> | |
| <p class="sub">Controls all clients in the same room over WebSockets. Use a small start delay for tight sync.</p> | |
| <div class="row"> | |
| <label>Start delay (ms) | |
| <input id="delay" class="input" type="number" value="3000" min="200" step="100"> | |
| </label> | |
| <label>Label | |
| <input id="label" class="input" type="text" placeholder="e.g. Heat 1"> | |
| </label> | |
| <button id="start" class="btn start">Start</button> | |
| <button id="stop" class="btn stop">Stop</button> | |
| <button id="reset" class="btn reset">Reset</button> | |
| <span class="badge" id="stats">Clients: 0 • Admins: 1</span> | |
| </div> | |
| <div class="row"> | |
| <button id="blackOn" class="btn blackon">Blackout ON</button> | |
| <button id="blackOff" class="btn blackoff">Show Timer</button> | |
| </div> | |
| <div class="grid"> | |
| <div class="stat"> | |
| <div>Room</div> | |
| <b id="roomName">default</b> | |
| </div> | |
| <div class="stat"> | |
| <div>Server time (ms)</div> | |
| <b id="serverNow">--</b> | |
| </div> | |
| <div class="stat"> | |
| <div>Hint</div> | |
| <div>Open <code>/client</code> on each device. Use <code>?room=yourcode</code> to isolate groups.</div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script src="/socket.io/socket.io.js"></script> | |
| <script> | |
| const params = new URLSearchParams(location.search); | |
| const room = params.get('room') || 'default'; | |
| const socket = io({ query: { role: 'admin', room } }); | |
| const delayEl = document.getElementById('delay'); | |
| const labelEl = document.getElementById('label'); | |
| const statsEl = document.getElementById('stats'); | |
| const roomEl = document.getElementById('roomName'); | |
| const serverNowEl = document.getElementById('serverNow'); | |
| roomEl.textContent = room; | |
| function syncOnce() { socket.emit('sync:ping', { t0: Date.now() }); } | |
| socket.on('sync:pong', ({ t0, t1, t2 }) => { | |
| const t3 = Date.now(); | |
| const offset = ((t1 - t0) + (t2 - t3)) / 2; | |
| serverNowEl.textContent = String((Date.now() + offset)|0); | |
| }); | |
| setInterval(syncOnce, 1000); | |
| syncOnce(); | |
| socket.on('stats', ({ numAdmins, numClients }) => { | |
| statsEl.textContent = `Clients: ${numClients} • Admins: ${numAdmins}`; | |
| }); | |
| document.getElementById('start').onclick = () => { | |
| socket.emit('admin:start', { | |
| delayMs: Number(delayEl.value || 3000), | |
| label: labelEl.value || '' | |
| }); | |
| }; | |
| document.getElementById('stop').onclick = () => socket.emit('admin:stop'); | |
| document.getElementById('reset').onclick = () => socket.emit('admin:reset'); | |
| document.getElementById('blackOn').onclick = () => socket.emit('admin:blackout', { on: true }); | |
| document.getElementById('blackOff').onclick = () => socket.emit('admin:blackout', { on: false }); | |
| </script> | |
| </body> | |
| </html> | |