Courtside MVP: engine, web app, Docker + Cloudflare Tunnel deploy, docs

Tournament engine (pools, single/double elimination, standings with proper
tiebreaks, withdrawals, two-court queue with ETAs and alerts), a single-process
Node server with SQLite via node:sqlite and a WebSocket live board, organizer
desk, QR landing page that follows the tournament phase, Dockerfile and
compose with cloudflared, and documentation for deploying and running a day.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MB7nCCAscYsb3zzkT6LHZi
This commit is contained in:
2026-09-03 20:31:46 +00:00
co-authored by Claude Fable 5.1
commit 5ade592384
39 changed files with 3103 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
// Organizer authentication. Two options, both simple:
// 1. ADMIN_PASSWORD — a login form sets a signed cookie for 12 hours.
// 2. Cloudflare Access — if TRUST_CF_ACCESS=true, a request that carries the
// Cf-Access-Authenticated-User-Email header (set by Cloudflare after the user passes
// the Access policy on /admin) is treated as an organizer. Only enable this when the
// app is reachable exclusively through the tunnel, otherwise the header can be forged.
import { timingSafeEqual, randomBytes } from 'node:crypto';
import { parseCookies, sign, verify } from './http.js';
const COOKIE = 'courtside_admin';
const TTL_MS = 12 * 3600 * 1000;
export class Auth {
constructor({ password, secret, trustCfAccess = false }) {
this.password = password || null;
this.secret = secret || randomBytes(32).toString('hex'); // random secret = sessions reset on restart; fine for a fallback
this.trustCfAccess = trustCfAccess;
if (!this.password && !this.trustCfAccess) console.warn('[auth] ADMIN_PASSWORD is not set and TRUST_CF_ACCESS is off: the organizer desk is unreachable.');
}
/** Returns the organizer identity or null. */
identify(req) {
if (this.trustCfAccess && req.headers['cf-access-authenticated-user-email']) return String(req.headers['cf-access-authenticated-user-email']);
const c = parseCookies(req)[COOKIE];
const v = verify(c, this.secret);
if (!v) return null;
const [exp, who] = v.split('|');
if (Number(exp) < Date.now()) return null;
return who || 'organizer';
}
checkPassword(candidate) {
if (!this.password) return false;
const a = Buffer.from(String(candidate)), b = Buffer.from(this.password);
return a.length === b.length && timingSafeEqual(a, b);
}
cookie(who = 'organizer', secure = false) {
const v = sign(`${Date.now() + TTL_MS}|${who}`, this.secret);
return `${COOKIE}=${encodeURIComponent(v)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${TTL_MS / 1000}${secure ? '; Secure' : ''}`;
}
clearCookie() { return `${COOKIE}=; Path=/; HttpOnly; Max-Age=0`; }
}
+74
View File
@@ -0,0 +1,74 @@
// A very small router and helpers over node:http. No framework so the deploy has no
// dependency surface beyond `ws` and `qrcode`.
import { createHmac, timingSafeEqual } from 'node:crypto';
import { readFile, stat } from 'node:fs/promises';
import { extname, join, normalize } from 'node:path';
export class Router {
constructor() { this.routes = []; }
add(method, pattern, handler) {
const keys = [];
const re = new RegExp('^' + pattern.replace(/\//g, '\\/').replace(/:(\w+)/g, (_, k) => { keys.push(k); return '([^\\/]+)'; }) + '\\/?$');
this.routes.push({ method, re, keys, handler });
return this;
}
get(p, h) { return this.add('GET', p, h); }
post(p, h) { return this.add('POST', p, h); }
match(method, path) {
for (const r of this.routes) {
if (r.method !== method) continue;
const m = path.match(r.re);
if (m) return { handler: r.handler, params: Object.fromEntries(r.keys.map((k, i) => [k, decodeURIComponent(m[i + 1])])) };
}
return null;
}
}
export class HttpError extends Error { constructor(status, message) { super(message); this.status = status; } }
export async function readBody(req, limit = 64 * 1024) {
const chunks = []; let size = 0;
for await (const c of req) { size += c.length; if (size > limit) throw new HttpError(413, 'Body too large'); chunks.push(c); }
const raw = Buffer.concat(chunks).toString('utf8');
const ct = req.headers['content-type'] ?? '';
if (ct.includes('application/json')) return raw ? JSON.parse(raw) : {};
if (ct.includes('application/x-www-form-urlencoded')) return Object.fromEntries(new URLSearchParams(raw));
return raw;
}
export function parseCookies(req) {
return Object.fromEntries((req.headers.cookie ?? '').split(';').map(s => s.trim()).filter(Boolean).map(s => { const i = s.indexOf('='); return [s.slice(0, i), decodeURIComponent(s.slice(i + 1))]; }));
}
export const send = {
html(res, body, status = 200, headers = {}) { res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', ...headers }); res.end(body); },
json(res, obj, status = 200, headers = {}) { res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...headers }); res.end(JSON.stringify(obj)); },
redirect(res, to, headers = {}) { res.writeHead(303, { location: to, ...headers }); res.end(); },
text(res, body, status = 200) { res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8' }); res.end(body); },
};
const MIME = { '.css': 'text/css', '.js': 'text/javascript', '.png': 'image/png', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.webmanifest': 'application/manifest+json', '.json': 'application/json' };
export async function serveStatic(res, root, urlPath) {
const p = normalize(join(root, urlPath));
if (!p.startsWith(root)) throw new HttpError(404, 'Not found');
try {
const s = await stat(p);
if (!s.isFile()) throw new HttpError(404, 'Not found');
const body = await readFile(p);
res.writeHead(200, { 'content-type': MIME[extname(p)] ?? 'application/octet-stream', 'cache-control': 'public, max-age=300' });
res.end(body);
} catch (e) { if (e instanceof HttpError) throw e; throw new HttpError(404, 'Not found'); }
}
/** Signed cookie values: `payload.signature`. */
export function sign(value, secret) { return `${value}.${createHmac('sha256', secret).update(value).digest('base64url')}`; }
export function verify(signed, secret) {
if (!signed || !signed.includes('.')) return null;
const i = signed.lastIndexOf('.');
const value = signed.slice(0, i), sig = signed.slice(i + 1);
const expected = createHmac('sha256', secret).update(value).digest('base64url');
if (sig.length !== expected.length || !timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null;
return value;
}
export const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
+70
View File
@@ -0,0 +1,70 @@
// Courtside server entry point. One process: HTTP pages + WebSocket fan-out + SQLite.
import { createServer } from 'node:http';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { WebSocketServer } from 'ws';
import { Store } from './store.js';
import { Registry } from './state.js';
import { Auth } from './auth.js';
import { buildRouter } from './routes.js';
import { HttpError, send, serveStatic } from './http.js';
import { publicState } from './public-state.js';
const here = dirname(fileURLToPath(import.meta.url));
const env = process.env;
const PORT = Number(env.PORT ?? 3000);
const HOST = env.HOST ?? '0.0.0.0';
const DB_PATH = env.DB_PATH ?? join(here, '..', 'data', 'courtside.sqlite');
const store = new Store(DB_PATH);
const registry = new Registry(store);
const auth = new Auth({ password: env.ADMIN_PASSWORD, secret: env.SESSION_SECRET, trustCfAccess: env.TRUST_CF_ACCESS === 'true' });
const router = buildRouter({ registry, auth, store });
const staticRoot = join(here, 'public');
const server = createServer(async (req, res) => {
const url = new URL(req.url, 'http://x');
try {
if (url.pathname.startsWith('/static/')) return await serveStatic(res, staticRoot, url.pathname.slice('/static'.length));
const m = router.match(req.method, url.pathname);
if (!m) throw new HttpError(404, 'Not found');
await m.handler(req, res, m.params);
} catch (e) {
const status = e instanceof HttpError ? e.status : 500;
if (status === 500) console.error(`[${new Date().toISOString()}] ${req.method} ${req.url}`, e);
if (!res.headersSent) send.html(res, `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${status}</title><body style="font-family:sans-serif;padding:40px"><h1>${status}</h1><p>${escape(e.message)}</p><p><a href="/">Home</a></p>`, status);
else res.end();
}
});
// ---------- WebSocket: /ws/<slug> pushes the public state on every change ----------
const wss = new WebSocketServer({ noServer: true, maxPayload: 1024 });
server.on('upgrade', (req, socket, head) => {
const m = req.url.match(/^\/ws\/([a-z0-9-]+)\/?$/);
const item = m && registry.get(m[1]);
if (!item) { socket.write('HTTP/1.1 404 Not Found\r\n\r\n'); socket.destroy(); return; }
wss.handleUpgrade(req, socket, head, ws => {
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
ws.on('message', () => { /* clients never send; ignore */ });
const unsub = registry.subscribe(m[1], state => { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(state)); });
ws.on('close', unsub);
ws.send(JSON.stringify(publicState(item)));
});
});
const heartbeat = setInterval(() => { for (const ws of wss.clients) { if (!ws.isAlive) return ws.terminate(); ws.isAlive = false; ws.ping(); } }, 30000);
// ---------- ETA refresh: queue ETAs drift as time passes even with no results ----------
const etaTimer = setInterval(() => { for (const [slug, { t }] of registry.items) if (t.phase === 'live' && registry.subscribers.get(slug)?.size) registry.notify(slug); }, 60000);
server.listen(PORT, HOST, () => console.log(`Courtside listening on http://${HOST}:${PORT} db=${DB_PATH} tournaments=${registry.items.size}`));
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => {
console.log(`\n${sig}: shutting down`);
clearInterval(heartbeat); clearInterval(etaTimer);
for (const ws of wss.clients) ws.close();
server.close(() => { store.close(); process.exit(0); });
setTimeout(() => process.exit(0), 3000).unref();
});
function escape(s) { return String(s).replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c])); }
+30
View File
@@ -0,0 +1,30 @@
// The one JSON document every public page renders from. No phone numbers, no team codes.
export function publicState({ t, engine }) {
const name = id => t.teamName(id);
const view = m => ({
id: m.id, stage: m.stage, round: m.round, slot: m.slot, label: m.label ?? null, status: m.status, court: m.court,
a: m.teamA ? { id: m.teamA, name: name(m.teamA) } : null,
b: m.teamB ? { id: m.teamB, name: name(m.teamB) } : null,
winner: m.winner, sets: m.sets, live: m.live ?? null, feeds: m.feeds, feedsSide: m.feedsSide, conditional: !!m.conditional,
});
const board = t.phase === 'live' ? engine.board() : null;
const pools = t.pools.map(p => ({
id: p.id,
standings: t.standings(p.id).map(r => ({ teamId: r.teamId, name: r.name, w: r.w, l: r.l, setsW: r.setsW, setsL: r.setsL, pf: r.pf, pa: r.pa, pointDiff: r.pointDiff, decidedBy: r.decidedBy, status: r.status })),
matches: t.matches.filter(m => m.poolId === p.id).map(view),
}));
const bracket = t.matches.filter(m => m.stage !== 'pool').map(view);
const teams = t.activeTeams().map(x => t.publicTeam(x.id));
const withdrawn = [...t.teams.values()].filter(x => x.status === 'withdrawn').map(x => t.publicTeam(x.id));
return {
slug: t.slug, name: t.name, date: t.date, notes: t.notes ?? '', phase: t.phase, banner: t.banner ?? null,
rules: t.rules, stages: t.stages, courtCount: t.courtCount,
teams, withdrawn, pools, bracket,
courts: board ? board.courts.map(c => ({ ...c, match: c.match ? view(t.match(c.match.id)) : null })) : [],
upNext: board ? board.upNext.map(u => ({ ...view(t.match(u.id)), court: u.court, etaMin: u.etaMin })) : [],
avgMatchMin: board?.avgMatchMin ?? null,
champion: t.phase === 'final' ? t.champion() : null,
recentAlerts: engine.alerts.slice(-20).map(a => ({ kind: a.kind, text: a.text, at: a.at, teams: a.teams ?? [], match: a.match ?? null })),
generatedAt: Date.now(),
};
}
+35
View File
@@ -0,0 +1,35 @@
/* Organizer desk: live score pad. Each tap posts the running score so the public board
shows it; "Mark final" submits the set as the result. Plain fetch, no framework. */
(() => {
const slug = location.pathname.split('/')[3];
const post = (path, body) => fetch(`/admin/t/${slug}/${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }).then(async r => { const j = await r.json().catch(() => ({})); if (!r.ok) throw new Error(j.error || r.statusText); return j; });
document.querySelectorAll('.scorepad').forEach(pad => {
const id = pad.dataset.match;
const a = pad.querySelector('[data-side=a]'), b = pad.querySelector('[data-side=b]');
let timer = null;
const push = () => { clearTimeout(timer); timer = setTimeout(() => post('live', { match: id, a: +a.textContent, b: +b.textContent }).catch(e => alert(e.message)), 150); };
pad.querySelectorAll('[data-op]').forEach(btn => btn.addEventListener('click', () => {
const [side, op] = btn.dataset.op;
const el = side === 'a' ? a : b;
el.textContent = Math.max(0, +el.textContent + (op === '+' ? 1 : -1));
push();
}));
const finalBtn = pad.nextElementSibling.querySelector('[data-final]');
finalBtn.addEventListener('click', () => {
const sa = +a.textContent, sb = +b.textContent;
if (!confirm(`Final: ${sa}${sb}?`)) return;
post('score', { match: id, sets: `${sa}-${sb}` }).then(() => location.reload()).catch(e => alert(e.message));
});
});
// keep the desk fresh when another organizer enters a score
const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/${slug}`);
let last = null;
ws.onmessage = ev => {
const s = JSON.parse(ev.data);
const sig = JSON.stringify([s.phase, s.courts.map(c => [c.match?.id, c.match?.status, c.status]), s.upNext.map(u => u.id)]);
if (last && sig !== last && !document.querySelector('.scorepad:hover')) location.reload();
last = sig;
};
})();
+95
View File
@@ -0,0 +1,95 @@
:root{
--bg:#F7F8F6;--surface:#fff;--ink:#17202A;--muted:#5B6875;--line:#DCE1DE;
--accent:#1D6FB5;--accent-ink:#fff;--accent-soft:#E4EFF9;--sand:#B98F3F;--sand-soft:#F6EBD2;
--good:#2E8B57;--warn:#C97B1C;--crit:#C0392B;--court:#1A6FB5;--court-ink:#fff;
}
@media (prefers-color-scheme:dark){:root{--bg:#12181F;--surface:#1A222B;--ink:#E8ECEF;--muted:#9AA6B1;--line:#2B3640;--accent:#5AA3E6;--accent-ink:#0E1A26;--accent-soft:#1B3040;--sand:#D9B26E;--sand-soft:#3A3120;--good:#5FCB8A;--warn:#E39A3B;--crit:#E5655A;--court:#1D5F99}}
*{box-sizing:border-box}
html{-webkit-text-size-adjust:100%}
body{margin:0;background:var(--bg);color:var(--ink);font-family:"Source Sans 3",-apple-system,"Segoe UI",Helvetica,Arial,sans-serif;font-size:17px;line-height:1.5}
a{color:var(--accent)}
h1,h2,h3{font-family:"Barlow Condensed","Arial Narrow",Arial,sans-serif;line-height:1.1;margin:0;text-wrap:balance}
h1{font-size:44px;font-weight:700}h2{font-size:26px;font-weight:600}h3{font-size:20px;font-weight:600;color:var(--muted)}
.mono{font-family:"JetBrains Mono",ui-monospace,Menlo,monospace;font-variant-numeric:tabular-nums}
.muted{color:var(--muted)}.small{font-size:14px}.tbd{color:var(--muted);font-style:italic}
main{max-width:960px;margin:0 auto;padding:16px 16px 64px}
.top{display:flex;justify-content:space-between;align-items:center;padding:10px 16px;border-bottom:1px solid var(--line);background:var(--surface)}
.brand{font-family:"Barlow Condensed",sans-serif;font-weight:700;font-size:22px;letter-spacing:.04em;text-transform:uppercase;color:var(--ink);text-decoration:none}
.top nav a{margin-left:16px;font-family:"Barlow Condensed",sans-serif;text-transform:uppercase;letter-spacing:.06em;font-size:15px;text-decoration:none}
.foot{text-align:center;color:var(--muted);font-size:13px;padding:24px}
.eyebrow{font-family:"Barlow Condensed",sans-serif;text-transform:uppercase;letter-spacing:.12em;font-size:13px;font-weight:600;color:var(--muted)}
.hero{padding:18px 0 8px}.hero.compact{padding:8px 0}.lede{color:var(--muted);margin:6px 0 0}.notes{white-space:pre-line}
.pill{display:inline-block;font-family:"Barlow Condensed",sans-serif;text-transform:uppercase;letter-spacing:.08em;font-size:12px;font-weight:600;padding:2px 8px;border-radius:3px;line-height:1.5;vertical-align:middle;background:var(--accent-soft);color:var(--accent)}
.pill.live{background:var(--good);color:#fff}.pill.final{background:var(--sand-soft);color:var(--sand)}.pill.checkin{background:var(--accent);color:var(--accent-ink)}
.card{background:var(--surface);border:1px solid var(--line);border-radius:8px;padding:16px;margin:14px 0}
.card h2{margin-bottom:8px}
.list .row{display:grid;grid-template-columns:auto 1fr auto;gap:12px;align-items:center;padding:12px 14px;margin:8px 0;background:var(--surface);border:1px solid var(--line);border-radius:8px;color:var(--ink);text-decoration:none}
.flash{padding:10px 14px;border-radius:6px;margin:12px 0;background:var(--accent-soft)}.flash.error{background:var(--sand-soft);color:var(--crit);border-left:3px solid var(--crit)}
.banner{background:var(--sand-soft);border-left:3px solid var(--sand);padding:10px 14px;border-radius:0 6px 6px 0;margin:12px 0;font-weight:600}
.champion{text-align:center;padding:20px;margin:14px 0;border:2px solid var(--sand);border-radius:8px;background:var(--sand-soft)}.champion h2{font-size:40px}
/* forms */
.form label{display:block;margin:12px 0;font-weight:600}
.form input,.form textarea,.form select{display:block;width:100%;margin-top:4px;padding:10px 12px;font:inherit;font-weight:400;border:1px solid var(--line);border-radius:6px;background:var(--bg);color:var(--ink)}
button,.button{font-family:"Barlow Condensed",sans-serif;text-transform:uppercase;letter-spacing:.06em;font-size:17px;font-weight:600;padding:10px 18px;border-radius:6px;border:1px solid var(--line);background:var(--surface);color:var(--ink);cursor:pointer;text-decoration:none;display:inline-block;line-height:1.2}
button.primary,.button.primary{background:var(--accent);color:var(--accent-ink);border-color:var(--accent)}
button.danger{color:var(--crit);border-color:var(--crit)}button.small{font-size:14px;padding:6px 10px}
button:focus-visible,a:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.linkbox{word-break:break-all;background:var(--bg);padding:10px;border-radius:6px}
/* courts */
.courts{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:12px;margin:14px 0}
.court{background:var(--court);color:var(--court-ink);border-radius:8px;padding:12px 14px;position:relative;overflow:hidden;min-height:96px}
.court:before{content:"";position:absolute;left:50%;top:0;bottom:0;border-left:2px dashed rgba(255,255,255,.35)}
.court.idle,.court.paused{background:var(--surface);color:var(--muted);border:1px dashed var(--line)}.court.idle:before,.court.paused:before{display:none}
.court.mine{outline:3px solid var(--sand)}
.court .lbl{font-family:"Barlow Condensed",sans-serif;font-size:12px;letter-spacing:.12em;text-transform:uppercase;opacity:.85;position:relative}
.court .teams{display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:10px;margin-top:6px;position:relative}
.court .t{font-family:"Barlow Condensed",sans-serif;font-size:24px;font-weight:600;line-height:1.05}
.court .t:last-child{text-align:right}
.court .s{font-size:30px;background:#0F1418;color:#fff;padding:2px 10px;border-radius:4px}
.queue{background:#0F1418;color:#F2F5F7;border-radius:8px;padding:12px 16px;margin:12px 0;display:grid;grid-template-columns:auto 1fr auto;gap:6px 14px;align-items:center}
.queue .eyebrow{grid-column:1/-1;color:#8E9BA6}
.qrow{display:contents}.qrow .k{font-family:"Barlow Condensed",sans-serif;font-size:12px;letter-spacing:.12em;text-transform:uppercase;color:#8E9BA6}
.qrow:first-of-type span:nth-child(2){color:#F0C46B;font-weight:600}.qrow.mine span:nth-child(2){text-decoration:underline}.qrow .eta{font-size:13px;color:#8E9BA6}
/* pools */
.pools{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:12px}
table{width:100%;border-collapse:collapse;font-size:15px}th{text-align:left;font-family:"Barlow Condensed",sans-serif;text-transform:uppercase;letter-spacing:.08em;font-size:12px;color:var(--muted);padding:4px 6px;border-bottom:1px solid var(--ink)}
td{padding:6px;border-bottom:1px solid var(--line)}.num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
tr.withdrawn td{color:var(--muted);text-decoration:line-through}tr.mine td{background:var(--sand-soft)}.tb{color:var(--sand);margin-left:3px}
details{margin-top:8px}summary{cursor:pointer;color:var(--muted);font-size:14px}
ul.plain,ol.plain{list-style:none;padding:0;margin:8px 0}ol.plain{counter-reset:n}ol.plain li{padding:4px 0}
.cols{columns:2;column-gap:24px}
.matches li,.sched li{display:grid;grid-template-columns:auto 1fr auto 1fr;gap:8px;padding:5px 0;border-bottom:1px solid var(--line);font-size:15px;align-items:center}
.sched li{grid-template-columns:auto 1fr auto}.matches li span:first-child{color:var(--muted);font-size:12px}.matches li span:last-child{text-align:right}
.matches li.live,.sched li.live{background:var(--accent-soft)}.won{font-weight:600}.matches li.mine{outline:1px solid var(--sand)}
.mine.card{border-color:var(--sand);border-width:2px}.big{font-size:20px}
/* bracket */
.bracketwrap{overflow-x:auto}.bstage{margin-top:12px}
.bracket{display:flex;gap:16px;align-items:stretch;min-width:max-content;padding-bottom:6px}
.round{display:flex;flex-direction:column;justify-content:space-around;gap:10px;min-width:190px}
.rlbl{font-family:"Barlow Condensed",sans-serif;text-transform:uppercase;letter-spacing:.1em;font-size:12px;color:var(--muted);text-align:center}
.bm{border:1px solid var(--line);border-radius:6px;background:var(--bg);font-size:15px}
.bm>div{display:flex;justify-content:space-between;padding:5px 10px;gap:8px}.bm>div+div{border-top:1px solid var(--line)}
.bm .mlbl{font-family:"Barlow Condensed",sans-serif;text-transform:uppercase;letter-spacing:.08em;font-size:11px;color:var(--muted);padding:3px 10px;justify-content:center}
.bm .mlbl.live{background:var(--good);color:#fff}.bm.live{border-color:var(--good)}.bm.mine{border-color:var(--sand);border-width:2px}
.toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);background:#0F1418;color:#fff;padding:12px 18px;border-radius:8px;max-width:90vw;box-shadow:0 8px 30px rgba(0,0,0,.3);z-index:9;font-weight:600}
.loading{color:var(--muted);padding:24px}
/* display (TV) mode */
body.display{font-size:22px;background:#0F1418;color:#F2F5F7}
body.display .top,body.display .foot{display:none}body.display main{max-width:none;padding:20px 28px}
body.display .display-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px}body.display h1{font-size:56px}
body.display .court .t{font-size:40px}body.display .court .s{font-size:52px}body.display .court{min-height:150px}
body.display .card{background:#1A222B;border-color:#2B3640;color:#F2F5F7}body.display td{border-color:#2B3640}body.display .bm{background:#12181F;border-color:#2B3640}
body.display .queue{font-size:26px}body.display details{display:none}
/* admin */
.grid2{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:12px}
.inline{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:8px 0}.inline input{width:auto}
.phases{display:flex;gap:6px;flex-wrap:wrap}.phases .cur{background:var(--accent);color:var(--accent-ink);border-color:var(--accent)}
.scorepad{display:grid;grid-template-columns:1fr auto 1fr;gap:10px;align-items:center;margin:10px 0}
.scorepad .team{font-family:"Barlow Condensed",sans-serif;font-size:20px;font-weight:600}
.scorepad .pts{font-size:44px;text-align:center}
.scorepad .btns{display:flex;gap:6px;justify-content:center}.scorepad .btns button{font-size:24px;padding:6px 18px}
.adm-table td form{display:inline}
.qr{text-align:center}.qr img{width:min(70vw,360px);image-rendering:pixelated;border:8px solid #fff;border-radius:6px}
@media (max-width:640px){h1{font-size:34px}.cols{columns:1}.court .t{font-size:20px}.court .s{font-size:24px}}
@media (prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}}
@media print{.top,.foot,button{display:none}body{background:#fff;color:#000}}
+138
View File
@@ -0,0 +1,138 @@
/* Courtside public board. Renders entirely from the state JSON embedded in the page,
then keeps it fresh over a WebSocket. No framework, no build step. */
(() => {
const app = document.getElementById('app');
let state = JSON.parse(document.getElementById('state').textContent);
const myTeam = app.dataset.team || null;
const display = app.dataset.display === '1';
const seenAlerts = new Set();
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const name = t => t ? esc(t.name) : '<span class="tbd">TBD</span>';
const isMine = m => myTeam && (m.a?.id === myTeam || m.b?.id === myTeam);
const phaseLabel = { checkin: 'Registration open', closed: 'Registration closed', live: 'Live', final: 'Final' };
const score = m => m.status === 'final' ? m.sets.map(s => `${s[0]}${s[1]}`).join(', ')
: m.status === 'forfeit' ? 'forfeit' : m.live ? `${m.live[0]}${m.live[1]}` : m.status === 'live' ? '00' : '';
const rulesLine = r => `${r.teamSize}s · to ${r.pointsTo}, win by ${r.winBy}${r.cap ? `, cap ${r.cap}` : ''}${r.bestOf > 1 ? `, best of ${r.bestOf}` : ''}`;
function render() {
const s = state;
const mine = myTeam ? [...s.teams, ...s.withdrawn].find(t => t.id === myTeam) : null;
const parts = [];
if (!display) parts.push(`<section class="hero compact"><div class="eyebrow">${esc(s.date ?? '')} · <span class="pill ${s.phase}">${phaseLabel[s.phase]}</span></div><h1>${esc(s.name)}</h1><p class="lede">${rulesLine(s.rules)}${s.avgMatchMin ? ` · matches running ~${s.avgMatchMin} min` : ''}</p></section>`);
else parts.push(`<div class="display-head"><h1>${esc(s.name)}</h1><span class="pill ${s.phase}">${phaseLabel[s.phase]}</span></div>`);
if (s.banner) parts.push(`<div class="banner">${esc(s.banner)}</div>`);
if (s.champion) parts.push(`<section class="champion"><div class="eyebrow">Champion</div><h2>${esc(s.champion)}</h2></section>`);
if (mine) parts.push(renderMyTeam(mine));
if (s.phase === 'live') { parts.push(renderCourts()); parts.push(renderUpNext()); }
if (s.phase === 'closed' && !s.pools.length) parts.push(`<section class="card"><h2>Schedule coming</h2><p>Registration is closed with ${s.teams.length} teams. The organizer is generating pools now; this page will update on its own.</p></section>`);
if (s.bracket.length) parts.push(renderBracket());
if (s.pools.length) parts.push(renderPools());
if (!s.pools.length && !s.bracket.length) parts.push(renderRoster());
if (s.withdrawn.length && !display) parts.push(`<p class="muted small">Withdrawn: ${s.withdrawn.map(t => esc(t.name)).join(', ')}</p>`);
app.innerHTML = parts.join('');
notifyMine();
}
function renderMyTeam(t) {
const s = state;
const my = [...s.pools.flatMap(p => p.matches), ...s.bracket].filter(isMine);
const live = my.find(m => m.status === 'live');
const next = s.upNext.find(isMine) ?? my.find(m => m.status === 'on_deck' || (m.status === 'pending' && m.a && m.b));
const played = my.filter(m => m.status === 'final' || m.status === 'forfeit');
const w = played.filter(m => m.winner === t.id).length;
let now;
if (t.status === 'withdrawn') now = `<p>Your team has withdrawn from the tournament.</p>`;
else if (live) now = `<p class="big">You're on <b>Court ${live.court}</b> now vs ${name(live.a?.id === t.id ? live.b : live.a)}</p>`;
else if (next) now = `<p class="big">Next: vs ${name(next.a?.id === t.id ? next.b : next.a)}${next.court ? ` on <b>Court ${next.court}</b>` : ''}${next.etaMin != null ? ` <span class="muted">in about ${next.etaMin} min</span>` : ''}</p>`;
else if (s.phase === 'final') now = `<p>Tournament over. Thanks for playing.</p>`;
else if (s.phase === 'live') now = `<p>No match scheduled yet. Waiting on results from other courts.</p>`;
else now = `<p>Waiting for play to start.</p>`;
return `<section class="card mine"><div class="eyebrow">Your team</div><h2>${esc(t.name)}</h2>${now}<p class="muted">${played.length ? `Record ${w}${played.length - w}` : 'No results yet'}${t.poolId ? ` · Pool ${esc(t.poolId)}` : ''}</p>
${my.length ? `<ul class="plain sched">${my.map(m => `<li class="${m.status}"><span>${m.stage === 'pool' ? `Pool ${m.round}` : (m.label ?? `Bracket R${m.round}`)}</span><span>vs ${name(m.a?.id === t.id ? m.b : m.a)}</span><span class="mono">${score(m) || (m.court ? `Court ${m.court}` : '')}${m.status === 'final' || m.status === 'forfeit' ? (m.winner === t.id ? ' W' : ' L') : ''}</span></li>`).join('')}</ul>` : ''}
${!display && 'Notification' in window && Notification.permission === 'default' ? `<button type="button" class="button" id="notify">Alert me when we're up</button>` : ''}
</section>`;
}
function renderCourts() {
return `<section class="courts">${state.courts.map(c => {
const m = c.match;
if (c.status === 'paused') return `<div class="court paused"><div class="lbl">Court ${c.court}</div><div class="teams"><div class="t">Paused</div></div></div>`;
if (!m) return `<div class="court idle"><div class="lbl">Court ${c.court}</div><div class="teams"><div class="t muted">Open</div></div></div>`;
return `<div class="court ${isMine(m) ? 'mine' : ''}"><div class="lbl">Court ${c.court} · ${m.stage === 'pool' ? `Pool ${esc(m.a && state.teams.find(t => t.id === m.a.id)?.poolId || '')} R${m.round}` : (m.label ?? `Bracket R${m.round}`)}</div>
<div class="teams"><div class="t">${name(m.a)}</div><div class="s mono">${score(m) || '00'}</div><div class="t">${name(m.b)}</div></div></div>`;
}).join('')}</section>`;
}
function renderUpNext() {
if (!state.upNext.length) return '';
return `<section class="queue"><div class="eyebrow">Up next</div>${state.upNext.map((u, i) => `<div class="qrow ${isMine(u) ? 'mine' : ''}"><span class="k">${i === 0 ? 'On deck' : 'Then'}</span><span>${name(u.a)} vs ${name(u.b)}</span><span class="eta mono">Court ${u.court} · ~${u.etaMin} min</span></div>`).join('')}</section>`;
}
function renderPools() {
return `<section class="pools">${state.pools.map(p => `<div class="card pool"><h2>Pool ${esc(p.id)}</h2>
<table><thead><tr><th>Team</th><th class="num">W</th><th class="num">L</th><th class="num">Pts</th><th class="num">+/</th></tr></thead><tbody>
${p.standings.map((r, i) => `<tr class="${r.status === 'withdrawn' ? 'withdrawn' : ''} ${r.teamId === myTeam ? 'mine' : ''}"><td>${i + 1}. ${esc(r.name)}${r.decidedBy ? `<span class="tb" title="Tie broken by ${r.decidedBy}">*</span>` : ''}</td><td class="num">${r.w}</td><td class="num">${r.l}</td><td class="num">${r.pf}${r.pa}</td><td class="num">${r.pointDiff > 0 ? '+' : ''}${r.pointDiff}</td></tr>`).join('')}
</tbody></table>
<details${display ? '' : ''}><summary>Matches</summary><ul class="plain matches">${p.matches.map(m => `<li class="${m.status} ${isMine(m) ? 'mine' : ''}"><span>R${m.round}</span><span class="${m.winner && m.winner === m.a?.id ? 'won' : ''}">${name(m.a)}</span><span class="mono">${score(m) || (m.court ? `C${m.court}` : '·')}</span><span class="${m.winner && m.winner === m.b?.id ? 'won' : ''}">${name(m.b)}</span></li>`).join('')}</ul></details>
</div>`).join('')}</section>`;
}
function renderBracket() {
const groups = [['bracket', 'Bracket'], ['losers', 'Losers bracket'], ['final', 'Finals']];
return `<section class="card bracketwrap"><h2>${state.bracket.some(m => m.stage === 'losers') ? 'Double elimination' : 'Bracket'}</h2>${groups.map(([stage, title]) => {
const ms = state.bracket.filter(m => m.stage === stage && !(m.conditional && m.status !== 'final' && !m.a));
if (!ms.length) return '';
const rounds = [...new Set(ms.map(m => m.round))].sort((a, b) => a - b);
return `<div class="bstage"><h3>${title}</h3><div class="bracket">${rounds.map(r => `<div class="round"><div class="rlbl">${roundName(stage, r, rounds.length, ms)}</div>${ms.filter(m => m.round === r).map(m => `<div class="bm ${m.status} ${isMine(m) ? 'mine' : ''}">${m.label ? `<div class="mlbl">${esc(m.label)}</div>` : ''}<div class="${m.winner && m.winner === m.a?.id ? 'won' : ''}">${name(m.a)}<span class="mono">${m.sets[0] ? m.sets.map(s => s[0]).join(' ') : ''}</span></div><div class="${m.winner && m.winner === m.b?.id ? 'won' : ''}">${name(m.b)}<span class="mono">${m.sets[0] ? m.sets.map(s => s[1]).join(' ') : ''}</span></div>${m.status === 'live' ? `<div class="mlbl live">Court ${m.court}${m.live ? ` · ${m.live[0]}${m.live[1]}` : ''}</div>` : m.status === 'forfeit' ? '<div class="mlbl">forfeit</div>' : ''}</div>`).join('')}</div>`).join('')}</div></div>`;
}).join('')}</section>`;
}
function roundName(stage, r, total, ms) {
if (stage === 'final') return r === 1 ? 'Grand final' : 'Reset';
if (stage === 'losers') return `LB round ${r}`;
const left = total - r;
const inRound = ms.filter(m => m.round === r && m.label !== '3rd place').length;
return left === 0 ? 'Final' : left === 1 ? 'Semifinals' : left === 2 ? 'Quarterfinals' : `Round of ${inRound * 2}`;
}
function renderRoster() {
return `<section class="card"><h2>Teams (${state.teams.length})</h2><ol class="plain cols">${state.teams.map(t => `<li class="${t.id === myTeam ? 'mine' : ''}">${esc(t.name)} <span class="muted">(${t.players})</span></li>`).join('')}</ol></section>`;
}
// ---- alerts for the team page ----
function notifyMine() {
if (!myTeam) return;
for (const a of state.recentAlerts) {
const key = `${a.kind}:${a.match ?? a.at}`;
if (seenAlerts.has(key)) continue;
seenAlerts.add(key);
if (!(a.kind === 'broadcast' || a.teams.includes(myTeam))) continue;
if (Date.now() - a.at > 10 * 60 * 1000) continue; // stale on first load
toast(a.text);
try { navigator.vibrate?.([200, 100, 200]); } catch {}
if ('Notification' in window && Notification.permission === 'granted') { try { new Notification('Courtside', { body: a.text, tag: key }); } catch {} }
}
}
function toast(text) {
const el = document.createElement('div'); el.className = 'toast'; el.textContent = text;
document.body.appendChild(el); setTimeout(() => el.remove(), 8000);
}
document.addEventListener('click', e => {
if (e.target.id === 'notify') Notification.requestPermission().then(render);
});
// first paint: skip toasts for alerts that already happened
for (const a of state.recentAlerts) seenAlerts.add(`${a.kind}:${a.match ?? a.at}`);
// ---- live updates ----
let retry = 1000;
function connect() {
const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/${state.slug}`);
ws.onmessage = ev => { state = JSON.parse(ev.data); retry = 1000; render(); };
ws.onclose = () => { setTimeout(connect, retry); retry = Math.min(retry * 2, 15000); };
ws.onerror = () => ws.close();
}
render();
connect();
// belt and braces: a full refresh every 2 minutes in case a proxy drops the socket silently
setInterval(() => fetch(`/t/${state.slug}/state.json`).then(r => r.json()).then(s => { state = s; render(); }).catch(() => {}), 120000);
if (display) setInterval(() => document.documentElement.scrollTo({ top: (Date.now() / 1000 | 0) % 2 ? 0 : document.body.scrollHeight, behavior: 'smooth' }), 15000);
})();
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="12" fill="#1A6FB5"/><circle cx="32" cy="32" r="20" fill="none" stroke="#fff" stroke-width="4"/><path d="M32 12v40M12 32h40" stroke="#fff" stroke-width="3" stroke-dasharray="4 4" fill="none"/></svg>

After

Width:  |  Height:  |  Size: 291 B

+1
View File
@@ -0,0 +1 @@
{ "name": "Courtside", "short_name": "Courtside", "start_url": "/", "display": "standalone", "background_color": "#0F1418", "theme_color": "#0F1418", "icons": [{ "src": "/static/favicon.svg", "sizes": "any", "type": "image/svg+xml" }] }
+160
View File
@@ -0,0 +1,160 @@
import QRCode from 'qrcode';
import { Router, HttpError, readBody, send, esc } from './http.js';
import { publicState } from './public-state.js';
import * as pub from './views/public.js';
import * as adm from './views/admin.js';
export function buildRouter({ registry, auth, store }) {
const r = new Router();
const origin = req => `${(req.headers['x-forwarded-proto'] ?? 'http').split(',')[0]}://${req.headers['x-forwarded-host'] ?? req.headers.host}`;
const item = slug => { const it = registry.get(slug); if (!it) throw new HttpError(404, 'No such tournament'); return it; };
const q = req => new URL(req.url, 'http://x').searchParams;
// ---------- public ----------
r.get('/', (req, res) => send.html(res, pub.homePage(registry.list())));
r.get('/healthz', (req, res) => send.json(res, { ok: true, tournaments: registry.items.size, uptime: process.uptime() }));
r.get('/t/:slug', (req, res, { slug }) => {
const it = item(slug);
const state = publicState(it);
if (state.phase === 'checkin') return send.html(res, pub.registerPage(state, { error: q(req).get('error') }));
send.html(res, pub.boardPage(state));
});
r.get('/t/:slug/state.json', (req, res, { slug }) => send.json(res, publicState(item(slug))));
r.get('/t/:slug/display', (req, res, { slug }) => send.html(res, pub.boardPage(publicState(item(slug)), { display: true })));
r.get('/t/:slug/team/:code', (req, res, { slug, code }) => {
const it = item(slug);
const team = it.t.teamByCode(code);
if (!team) throw new HttpError(404, 'No team with that code');
const state = publicState(it);
if (state.phase === 'checkin' && q(req).get('new')) return send.html(res, pub.registeredPage(state, team, origin(req)));
send.html(res, pub.boardPage(state, { teamId: team.id }));
});
r.post('/t/:slug/register', async (req, res, { slug }) => {
const it = item(slug);
const body = await readBody(req);
try {
const team = registry.mutate(slug, t => t.registerTeam({
name: body.name, captain: String(body.captain ?? '').trim().slice(0, 60), phone: String(body.phone ?? '').trim().slice(0, 30),
players: body.players, playerNames: String(body.playerNames ?? '').split('\n').map(s => s.trim()).filter(Boolean).slice(0, 20),
}));
send.redirect(res, `/t/${slug}/team/${team.code}?new=1`);
} catch (e) {
send.html(res, pub.registerPage(publicState(it), { error: e.message, values: body }), 400);
}
});
// ---------- organizer ----------
const requireAdmin = (req, res) => {
const who = auth.identify(req);
if (!who) { send.redirect(res, `/admin/login?next=${encodeURIComponent(req.url)}`); return null; }
return who;
};
r.get('/admin/login', (req, res) => send.html(res, adm.loginPage(q(req).get('error'), q(req).get('next') ?? '/admin')));
r.post('/admin/login', async (req, res) => {
const body = await readBody(req);
if (!auth.checkPassword(body.password)) return send.redirect(res, '/admin/login?error=' + encodeURIComponent('Wrong password'));
const secure = (req.headers['x-forwarded-proto'] ?? '').includes('https');
send.redirect(res, safeNext(body.next), { 'set-cookie': auth.cookie('organizer', secure) });
});
r.get('/admin/logout', (req, res) => send.redirect(res, '/', { 'set-cookie': auth.clearCookie() }));
r.get('/admin', (req, res) => { const who = requireAdmin(req, res); if (who) send.html(res, adm.adminHome(registry.list(), who, q(req).get('msg'))); });
r.post('/admin/new', async (req, res) => {
const who = requireAdmin(req, res); if (!who) return;
const b = await readBody(req);
const rules = { teamSize: +b.teamSize || 2, pointsTo: +b.pointsTo || 21, winBy: +b.winBy || 2, cap: +b.cap || 0, bestOf: +b.bestOf || 1 };
const it = registry.create({ name: b.name, date: b.date, courtCount: +b.courtCount || 2, rules, stages: String(b.stages ?? 'pool,bracket').split(','), notes: b.notes ?? '' });
send.redirect(res, `/admin/t/${it.t.slug}?msg=${encodeURIComponent('Created. Print the QR code and open registration when ready.')}`);
});
r.get('/admin/t/:slug', (req, res, { slug }) => {
const who = requireAdmin(req, res); if (!who) return;
send.html(res, adm.adminTournament(item(slug), who, origin(req), q(req).get('msg'), q(req).get('error'), store.events(slug, 40)));
});
r.get('/admin/t/:slug/qr', async (req, res, { slug }) => {
const who = requireAdmin(req, res); if (!who) return;
const it = item(slug);
const dataUrl = await QRCode.toDataURL(`${origin(req)}/t/${slug}`, { width: 720, margin: 2, errorCorrectionLevel: 'M' });
send.html(res, adm.qrPage(it.t, origin(req), dataUrl, who));
});
r.get('/admin/t/:slug/qr.png', async (req, res, { slug }) => {
const who = requireAdmin(req, res); if (!who) return;
item(slug);
const buf = await QRCode.toBuffer(`${origin(req)}/t/${slug}`, { width: 1200, margin: 2 });
res.writeHead(200, { 'content-type': 'image/png' }); res.end(buf);
});
// Every organizer action: POST, mutate, redirect back with a message (or JSON for fetch callers).
const action = (path, fn) => r.post(`/admin/t/:slug${path}`, async (req, res, params) => {
const who = requireAdmin(req, res); if (!who) return;
const body = await readBody(req);
const wantsJson = (req.headers['content-type'] ?? '').includes('json');
try {
const msg = registry.mutate(params.slug, (t, engine) => fn({ t, engine, body, who, params })) ?? 'Done';
if (wantsJson) send.json(res, { ok: true, msg }); else send.redirect(res, `/admin/t/${params.slug}?msg=${encodeURIComponent(msg)}`);
} catch (e) {
if (e instanceof HttpError) throw e;
if (wantsJson) send.json(res, { ok: false, error: e.message }, 400); else send.redirect(res, `/admin/t/${params.slug}?error=${encodeURIComponent(e.message)}`);
}
});
action('/phase', ({ t, engine, body }) => {
const p = body.phase;
if (p === 'checkin') { t.reopenRegistration(); return 'Registration reopened'; }
if (p === 'closed') { if (t.phase === 'live') throw new Error('Already live; use Final or reopen is not possible'); t.closeRegistration(); return 'Registration closed'; }
if (p === 'live') { if (!t.matches.length) throw new Error('Generate pools or a bracket first'); engine.start(); return 'Live. Courts assigned.'; }
if (p === 'final') { t.phase = 'final'; t.emit({ type: 'phase', phase: 'final', champion: t.champion() }); return 'Marked final'; }
throw new Error('Unknown phase');
});
action('/banner', ({ t, body }) => { t.banner = String(body.banner ?? '').trim().slice(0, 200) || null; t.emit({ type: 'banner', banner: t.banner }); return t.banner ? 'Banner set' : 'Banner cleared'; });
action('/broadcast', ({ t, engine, body }) => { const text = String(body.text ?? '').trim().slice(0, 300); if (!text) throw new Error('Nothing to send'); engine.broadcast(text); return 'Broadcast sent'; });
action('/generate', ({ t, body }) => {
if (body.what === 'pools') { t.generatePools(+body.poolSize || 4); return `Pools generated: ${t.pools.map(p => `${p.id} (${p.teams.length})`).join(', ')}`; }
const seeded = t.pools.length ? t.advanceFromPools({ perPool: +body.perPool || 2, wildcards: +body.wildcards || 0 }) : t.activeTeams().map(x => x.id);
if (seeded.length < 2) throw new Error('Need at least two teams');
const res = t.generateBracket(seeded, { type: body.type === 'double' ? 'double' : 'single', thirdPlace: !!body.thirdPlace });
return `${body.type === 'double' ? 'Double' : 'Single'} elimination bracket of ${res.size} generated with ${seeded.length} teams`;
});
action('/regenerate', ({ t }) => { if (t.matches.some(m => ['final', 'forfeit'].includes(m.status))) throw new Error('Scores already entered'); t.matches = []; t.pools = []; for (const x of t.teams.values()) x.poolId = null; if (t.phase === 'live') t.phase = 'closed'; t.emit({ type: 'play_cleared' }); return 'Generated play cleared'; });
action('/score', ({ t, engine, body, who }) => {
const sets = parseSets(body.sets);
const m = t.match(body.match); if (!m) throw new Error('No such match');
const wasLive = m.status === 'live';
if (wasLive) engine.recordResult(m.id, sets, { actor: who });
else { t.recordResult(m.id, sets, { actor: who }); m.court = null; }
delete m.live;
return `Saved ${t.teamName(m.teamA)} vs ${t.teamName(m.teamB)}: ${sets.map(s => s.join('-')).join(', ')}`;
});
action('/live', ({ t, body }) => { const m = t.match(body.match); if (!m || m.status !== 'live') throw new Error('Match is not live'); m.live = [Math.max(0, +body.a | 0), Math.max(0, +body.b | 0)]; return 'ok'; });
action('/forfeit', ({ t, engine, body, who }) => { const m = t.match(body.match); if (!m) throw new Error('No such match'); if (m.status === 'live') engine.recordResult(m.id, [], { actor: who, forfeit: body.team }); else t.recordResult(m.id, [], { actor: who, forfeit: body.team }); return `${t.teamName(body.team)} forfeits`; });
action('/swap', ({ engine, body }) => { engine.swapToCourt(body.match, +body.court); return `Moved to court ${body.court}`; });
action('/pushback', ({ engine, body }) => { engine.pushBack(body.match, 2); return 'Pushed back'; });
action('/court', ({ engine, body }) => { if (body.op === 'pause') engine.pauseCourt(+body.court, String(body.reason ?? '').slice(0, 60)); else engine.resumeCourt(+body.court); return `Court ${body.court} ${body.op}d`; });
action('/team', ({ t, body }) => {
if (body.op === 'add') { const team = t.registerTeam({ name: body.name, captain: body.captain, players: body.players }, { force: true }); return `Added ${team.name} (code ${team.code})`; }
if (body.op === 'remove') { t.removeTeam(body.id); return 'Team removed'; }
if (body.op === 'seed') { const list = t.activeTeams().filter(x => x.id !== body.id); list.splice(Math.max(0, +body.seed - 1), 0, t.teams.get(body.id)); t.setSeeds(list.map(x => x.id)); return 'Seeds updated'; }
t.updateTeam(body.id, { name: body.name, captain: body.captain, players: body.players }); return 'Team saved';
});
action('/withdraw', ({ t, engine, body, who }) => { const team = t.teams.get(body.id); if (!team) throw new Error('No such team'); if (t.phase === 'live') engine.withdrawTeam(body.id, { mode: body.mode, actor: who }); else t.withdrawTeam(body.id, { mode: body.mode, actor: who }); return `${team.name} withdrawn (${body.mode})`; });
r.post('/admin/t/:slug/delete', async (req, res, { slug }) => {
const who = requireAdmin(req, res); if (!who) return;
const body = await readBody(req);
if (body.confirm !== 'yes') return send.redirect(res, `/admin/t/${slug}?error=Not+confirmed`);
item(slug); registry.remove(slug);
send.redirect(res, '/admin?msg=' + encodeURIComponent('Tournament deleted'));
});
return r;
}
function parseSets(str) {
const sets = String(str ?? '').split(/[,;]/).map(s => s.trim()).filter(Boolean).map(s => {
const m = s.match(/^(\d+)\s*[-:]\s*(\d+)$/); if (!m) throw new Error(`Can't read set "${s}" — use 21-18`);
return [+m[1], +m[2]];
});
if (!sets.length) throw new Error('Enter at least one set, like 21-18');
return sets;
}
const safeNext = n => (typeof n === 'string' && n.startsWith('/') && !n.startsWith('//')) ? n : '/admin';
+73
View File
@@ -0,0 +1,73 @@
// In-memory registry of tournaments. Every mutation goes through `mutate()`, which
// persists the new state and notifies subscribers (the WebSocket layer) with the public view.
import { Tournament } from '../engine/tournament.js';
import { CourtEngine } from '../engine/courts.js';
import { publicState } from './public-state.js';
const slugify = s => String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'tournament';
export class Registry {
constructor(store) {
this.store = store;
this.items = new Map(); // slug -> { t, engine }
this.subscribers = new Map(); // slug -> Set<fn(state)>
for (const row of store.loadAll()) {
const t = Tournament.fromJSON(row.tournament);
t.slug = row.slug;
const engine = row.engine ? CourtEngine.fromJSON(t, row.engine) : new CourtEngine(t, { courtCount: t.courtCount });
this._attach(row.slug, t, engine);
}
}
list() { return [...this.items.entries()].map(([slug, { t }]) => ({ slug, name: t.name, date: t.date, phase: t.phase, teams: t.activeTeams().length, createdAt: t.createdAt })); }
get(slug) { return this.items.get(slug) ?? null; }
create({ name, date, courtCount = 2, rules = {}, stages = ['pool', 'bracket'], notes = '', slug: wanted = null }) {
const base = slugify(wanted ?? name);
let slug = base, n = 2;
while (this.items.has(slug)) slug = `${base}-${n++}`;
const t = new Tournament({ name, courtCount: Number(courtCount), rules, stages });
t.slug = slug; t.date = date || null; t.notes = notes; t.createdAt = Date.now();
const engine = new CourtEngine(t, { courtCount: t.courtCount });
this._attach(slug, t, engine);
this.persist(slug);
return this.items.get(slug);
}
remove(slug) { this.items.delete(slug); this.subscribers.delete(slug); this.store.remove(slug); }
_attach(slug, t, engine) {
this.items.set(slug, { t, engine });
t.on(evt => { try { this.store.logEvent(slug, evt); } catch { /* audit log is best-effort */ } });
}
/** Run fn against a tournament, then persist and broadcast. Throws propagate to the caller. */
mutate(slug, fn) {
const item = this.get(slug);
if (!item) throw new Error('No such tournament');
const result = fn(item.t, item.engine);
// keep the queue/on-deck fresh after any change during live play
if (item.t.phase === 'live') item.engine.tick();
this.persist(slug);
this.notify(slug);
return result;
}
persist(slug) {
const { t, engine } = this.get(slug);
this.store.save(slug, t.toJSON(), engine.toJSON());
}
subscribe(slug, fn) {
if (!this.subscribers.has(slug)) this.subscribers.set(slug, new Set());
this.subscribers.get(slug).add(fn);
return () => this.subscribers.get(slug)?.delete(fn);
}
notify(slug) {
const subs = this.subscribers.get(slug);
if (!subs?.size) return;
const state = publicState(this.get(slug));
for (const fn of subs) { try { fn(state); } catch { /* a dead socket must not break the others */ } }
}
}
+58
View File
@@ -0,0 +1,58 @@
// SQLite persistence using Node's built-in node:sqlite (Node 22.13+). No native modules.
// Each tournament is stored as two JSON documents (tournament state + court engine state),
// rewritten on every mutation. Data is tiny (a few hundred KB for a big day), so this is
// simpler and more robust than a normalized schema, and a backup is one file copy.
import { DatabaseSync } from 'node:sqlite';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
export class Store {
constructor(path) {
if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true });
this.db = new DatabaseSync(path);
this.db.exec(`
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS tournaments (
slug TEXT PRIMARY KEY,
tournament_json TEXT NOT NULL,
engine_json TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL,
ts INTEGER NOT NULL,
actor TEXT,
type TEXT NOT NULL,
json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS events_slug ON events(slug, id);
`);
this.stmts = {
all: this.db.prepare('SELECT slug, tournament_json, engine_json FROM tournaments ORDER BY created_at DESC'),
upsert: this.db.prepare(`INSERT INTO tournaments (slug, tournament_json, engine_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(slug) DO UPDATE SET tournament_json = excluded.tournament_json, engine_json = excluded.engine_json, updated_at = excluded.updated_at`),
del: this.db.prepare('DELETE FROM tournaments WHERE slug = ?'),
event: this.db.prepare('INSERT INTO events (slug, ts, actor, type, json) VALUES (?, ?, ?, ?, ?)'),
events: this.db.prepare('SELECT ts, actor, type, json FROM events WHERE slug = ? ORDER BY id DESC LIMIT ?'),
};
}
loadAll() {
return this.stmts.all.all().map(r => ({ slug: r.slug, tournament: JSON.parse(r.tournament_json), engine: r.engine_json ? JSON.parse(r.engine_json) : null }));
}
save(slug, tournamentJson, engineJson) {
const now = Date.now();
this.stmts.upsert.run(slug, JSON.stringify(tournamentJson), engineJson ? JSON.stringify(engineJson) : null, tournamentJson.createdAt ?? now, now);
}
remove(slug) { this.stmts.del.run(slug); }
logEvent(slug, evt) { this.stmts.event.run(slug, Date.now(), evt.actor ?? null, evt.type, JSON.stringify(evt)); }
events(slug, limit = 200) { return this.stmts.events.all(slug, limit).map(r => ({ ts: r.ts, ...JSON.parse(r.json) })); }
close() { this.db.close(); }
}
+161
View File
@@ -0,0 +1,161 @@
import { esc } from '../http.js';
import { layout, flash } from './layout.js';
import { phaseLabel } from './public.js';
export function loginPage(error = null, next = '/admin') {
return layout({ title: 'Organizer sign in', body: `
<section class="card form" style="max-width:420px;margin:40px auto">
<h1>Organizer desk</h1>${flash(error, 'error')}
<form method="post" action="/admin/login"><input type="hidden" name="next" value="${esc(next)}">
<label>Password <input type="password" name="password" required autofocus></label>
<button class="primary" type="submit">Sign in</button></form>
</section>` });
}
export function adminHome(list, who, msg) {
return layout({ title: 'Desk', admin: who, body: `
<h1>Organizer desk</h1>${flash(msg)}
<div class="grid2">
<section class="card"><h2>Tournaments</h2>${list.length ? `<ul class="plain">${list.map(x => `<li><a href="/admin/t/${esc(x.slug)}"><b>${esc(x.name)}</b></a> <span class="pill ${esc(x.phase)}">${esc(phaseLabel(x.phase))}</span> <span class="muted small">${esc(x.date ?? '')} · ${x.teams} teams</span></li>`).join('')}</ul>` : '<p class="muted">None yet.</p>'}</section>
<section class="card form"><h2>New tournament</h2>
<form method="post" action="/admin/new">
<label>Name <input name="name" required maxlength="60" placeholder="Labor Day 2s"></label>
<label>Date <input name="date" type="date"></label>
<div class="inline"><label>Courts <select name="courtCount"><option>1</option><option selected>2</option><option>3</option><option>4</option></select></label>
<label>Team size <select name="teamSize"><option value="2">2s</option><option value="3">3s</option><option value="4">4s</option><option value="6">6s</option></select></label></div>
<div class="inline"><label>Points to <input name="pointsTo" type="number" value="21" min="5" max="50"></label>
<label>Win by <input name="winBy" type="number" value="2" min="1" max="5"></label>
<label>Cap <input name="cap" type="number" value="25" min="0" max="60" title="0 = no cap"></label>
<label>Best of <select name="bestOf"><option>1</option><option>3</option></select></label></div>
<label>Stages <select name="stages"><option value="pool,bracket">Pools, then bracket</option><option value="pool">Pools only</option><option value="bracket">Bracket only</option></select></label>
<label>Notes for players <textarea name="notes" rows="2" placeholder="Bring water. Rec division starts at 10."></textarea></label>
<button class="primary" type="submit">Create</button>
</form></section>
</div>` });
}
export function adminTournament({ t, engine }, who, origin, msg, err, events) {
const s = t.phase;
const teams = [...t.teams.values()].sort((a, b) => a.seed - b.seed);
const live = engine.courts.map(c => ({ c, m: c.matchId ? t.match(c.matchId) : null }));
const queue = s === 'live' ? engine.queue().slice(0, 8) : [];
const generated = t.matches.length > 0;
const poolsDone = t.pools.length && t.matches.filter(m => m.stage === 'pool').every(m => ['final', 'forfeit', 'void'].includes(m.status));
const hasBracket = t.matches.some(m => m.stage !== 'pool');
const act = (path, label, cls = '', extra = '') => `<form method="post" action="/admin/t/${esc(t.slug)}/${path}">${extra}<button class="small ${cls}" type="submit">${label}</button></form>`;
const body = `
<div class="eyebrow"><a href="/admin">Desk</a> · ${esc(t.date ?? '')}</div>
<h1>${esc(t.name)} <span class="pill ${esc(s)}">${esc(phaseLabel(s))}</span></h1>
<p class="muted">${t.rules.teamSize}s · to ${t.rules.pointsTo}, win by ${t.rules.winBy}${t.rules.cap ? `, cap ${t.rules.cap}` : ''}, best of ${t.rules.bestOf} · ${t.courtCount} court${t.courtCount > 1 ? 's' : ''} · <a href="/t/${esc(t.slug)}" target="_blank">public page</a> · <a href="/t/${esc(t.slug)}/display" target="_blank">TV display</a> · <a href="/admin/t/${esc(t.slug)}/qr">QR code</a></p>
${flash(msg)}${flash(err, 'error')}
<section class="card"><h2>Phase</h2>
<div class="phases">
${act('phase', 'Check-in (registration open)', s === 'checkin' ? 'cur' : '', '<input type="hidden" name="phase" value="checkin">')}
${act('phase', 'Close registration', s === 'closed' ? 'cur' : '', '<input type="hidden" name="phase" value="closed">')}
${act('phase', 'Go live', s === 'live' ? 'cur' : '', '<input type="hidden" name="phase" value="live">')}
${act('phase', 'Final', s === 'final' ? 'cur' : '', '<input type="hidden" name="phase" value="final">')}
</div>
<p class="muted small">The public QR page follows this. Going live starts assigning courts immediately.</p>
<form method="post" action="/admin/t/${esc(t.slug)}/banner" class="inline"><input name="banner" placeholder="Banner on every public page (leave empty to clear)" value="${esc(t.banner ?? '')}" style="flex:1;min-width:240px"><button class="small" type="submit">Set banner</button></form>
<form method="post" action="/admin/t/${esc(t.slug)}/broadcast" class="inline"><input name="text" placeholder="One-time announcement to every team page" style="flex:1;min-width:240px"><button class="small" type="submit">Broadcast</button></form>
</section>
${s === 'live' ? `
<section class="card"><h2>Courts</h2>
<div class="grid2">${live.map(({ c, m }) => `
<div class="card" style="margin:0">
<div class="inline"><b>Court ${c.number}</b> <span class="pill">${esc(c.status)}${c.pauseReason ? ` · ${esc(c.pauseReason)}` : ''}</span>
${c.status === 'open' ? act('court', 'Pause', '', `<input type="hidden" name="court" value="${c.number}"><input type="hidden" name="op" value="pause"><input name="reason" placeholder="reason" style="width:110px">`) : act('court', 'Resume', 'primary', `<input type="hidden" name="court" value="${c.number}"><input type="hidden" name="op" value="resume">`)}
</div>
${m ? scorePad(t, m) : '<p class="muted">No match assigned.</p>'}
</div>`).join('')}</div>
</section>
<section class="card"><h2>Queue</h2>
${queue.length ? `<table class="adm-table"><tr><th>#</th><th>Match</th><th>Predicted</th><th></th></tr>${queue.map((q, i) => `<tr><td>${i + 1}</td><td>${esc(t.teamName(q.match.teamA))} vs ${esc(t.teamName(q.match.teamB))} <span class="muted small">${q.match.stage === 'pool' ? `Pool ${esc(q.match.poolId)} R${q.match.round}` : esc(q.match.label ?? `Bracket R${q.match.round}`)}</span></td><td class="mono">C${q.court} ~${Math.round(q.eta / 60000)}m</td><td>${act('pushback', 'Push back', '', `<input type="hidden" name="match" value="${esc(q.match.id)}">`)}</td></tr>`).join('')}</table>` : '<p class="muted">Nothing waiting.</p>'}
</section>` : ''}
<section class="card"><h2>Play</h2>
${!generated ? `
<form method="post" action="/admin/t/${esc(t.slug)}/generate" class="inline">
${t.stages.includes('pool') ? `<label>Pool size <select name="poolSize"><option>3</option><option selected>4</option><option>5</option><option>6</option></select></label><button class="primary small" type="submit" name="what" value="pools">Generate pools</button>` : ''}
${!t.stages.includes('pool') ? `<label>Bracket <select name="type"><option value="single">Single elimination</option><option value="double">Double elimination</option></select></label><label><input type="checkbox" name="thirdPlace" value="1"> 3rd place</label><button class="primary small" type="submit" name="what" value="bracket">Generate bracket</button>` : ''}
</form>
<p class="muted small">Uses current seeds (${t.activeTeams().length} active teams). Regenerating is allowed until the first score is entered.</p>` : ''}
${t.pools.length ? `<p>Pools: ${t.pools.map(p => `<b>${esc(p.id)}</b> (${p.teams.map(id => esc(t.teamName(id))).join(', ')})`).join(' · ')}</p>` : ''}
${generated && !t.matches.some(m => ['final', 'forfeit'].includes(m.status)) ? act('regenerate', 'Clear generated play', 'danger') : ''}
${t.pools.length && !hasBracket && t.stages.includes('bracket') ? `
<form method="post" action="/admin/t/${esc(t.slug)}/generate" class="inline">
<label>Advance per pool <input name="perPool" type="number" value="2" min="1" max="6" style="width:60px"></label>
<label>Wildcards <input name="wildcards" type="number" value="${Math.max(0, (1 << Math.ceil(Math.log2(Math.max(2, t.pools.length * 2)))) - t.pools.length * 2)}" min="0" max="8" style="width:60px"></label>
<label>Bracket <select name="type"><option value="single">Single elimination</option><option value="double">Double elimination</option></select></label>
<label><input type="checkbox" name="thirdPlace" value="1" checked> 3rd place</label>
<button class="primary small" type="submit" name="what" value="bracket">Generate bracket${poolsDone ? '' : ' (pools not finished)'}</button>
</form>` : ''}
${generated ? `<details><summary>All matches (${t.matches.length}) — enter or correct any score</summary><table class="adm-table"><tr><th>ID</th><th>Stage</th><th>Match</th><th>Status</th><th>Score</th><th></th></tr>
${t.matches.filter(m => !m.conditional || m.teamA).map(m => `<tr><td class="mono">${esc(m.id)}</td><td>${m.stage === 'pool' ? `Pool ${esc(m.poolId)} R${m.round}` : esc(m.label ?? `${m.stage} R${m.round}`)}</td><td>${esc(t.teamName(m.teamA))} vs ${esc(t.teamName(m.teamB))}</td><td>${esc(m.status)}${m.court ? ` C${m.court}` : ''}</td><td class="mono">${m.sets.map(x => x.join('-')).join(', ')}</td>
<td>${m.teamA && m.teamB && !['void', 'bye'].includes(m.status) ? `<form method="post" action="/admin/t/${esc(t.slug)}/score" class="inline"><input type="hidden" name="match" value="${esc(m.id)}"><input name="sets" placeholder="21-18" value="${esc(m.sets.map(x => x.join('-')).join(', '))}" style="width:110px"><button class="small" type="submit">Save</button></form>` : ''}</td></tr>`).join('')}</table></details>` : ''}
</section>
<section class="card"><h2>Teams (${t.activeTeams().length} active)</h2>
<table class="adm-table"><tr><th>Seed</th><th>Team</th><th>Captain</th><th>Players</th><th>Code</th><th></th></tr>
${teams.map(x => `<tr class="${x.status === 'withdrawn' ? 'withdrawn' : ''}">
<td><form method="post" action="/admin/t/${esc(t.slug)}/team" class="inline"><input type="hidden" name="id" value="${esc(x.id)}"><input type="hidden" name="op" value="seed"><input name="seed" type="number" value="${x.seed}" min="1" style="width:56px" ${generated ? 'disabled' : ''}>${generated ? '' : '<button class="small" type="submit">Set</button>'}</form></td>
<td><form method="post" action="/admin/t/${esc(t.slug)}/team" class="inline"><input type="hidden" name="id" value="${esc(x.id)}"><input type="hidden" name="op" value="edit"><input name="name" value="${esc(x.name)}" maxlength="40" style="width:150px"></td>
<td><input name="captain" value="${esc(x.captain ?? '')}" style="width:120px"> <span class="muted small">${esc(x.phone ?? '')}</span></td>
<td><input name="players" type="number" value="${x.players}" min="1" style="width:56px"> <button class="small" type="submit">Save</button></form></td>
<td class="mono"><a href="/t/${esc(t.slug)}/team/${esc(x.code)}" target="_blank">${esc(x.code)}</a></td>
<td>${x.status === 'withdrawn' ? `withdrawn (${esc(x.withdrawalMode)})` : generated
? `<form method="post" action="/admin/t/${esc(t.slug)}/withdraw" class="inline"><input type="hidden" name="id" value="${esc(x.id)}"><select name="mode"><option value="forfeit">forfeit remaining</option><option value="void">void all games</option></select><button class="small danger" type="submit">Withdraw</button></form>`
: act('team', 'Remove', 'danger', `<input type="hidden" name="id" value="${esc(x.id)}"><input type="hidden" name="op" value="remove">`)}</td>
</tr>`).join('')}</table>
${!generated ? `<form method="post" action="/admin/t/${esc(t.slug)}/team" class="inline"><input type="hidden" name="op" value="add"><input name="name" placeholder="Walk-up team name" required maxlength="40"><input name="captain" placeholder="captain"><input name="players" type="number" value="${t.rules.teamSize}" min="1" style="width:64px"><button class="small primary" type="submit">Add team</button></form>` : '<p class="muted small">Play is generated: use Withdraw for teams that leave. To add a team now, clear generated play first.</p>'}
</section>
<section class="card"><h2>Recent activity</h2><ul class="plain small">${events.slice(0, 40).map(e => `<li><span class="mono muted">${new Date(e.ts).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}</span> ${esc(describe(e, t))}</li>`).join('')}</ul></section>
<section class="card"><h2>Danger zone</h2>${act('delete', 'Delete this tournament', 'danger', `<label class="small"><input type="checkbox" name="confirm" value="yes" required> I understand this cannot be undone</label> `)}</section>`;
return layout({ title: `${t.name} desk`, admin: who, body, script: '/static/admin.js' });
}
function scorePad(t, m) {
const live = m.live ?? [0, 0];
return `<div class="scorepad" data-match="${esc(m.id)}">
<div class="team">${esc(t.teamName(m.teamA))}</div><div class="muted small">${m.stage === 'pool' ? `Pool ${esc(m.poolId)} R${m.round}` : esc(m.label ?? `Bracket R${m.round}`)}</div><div class="team" style="text-align:right">${esc(t.teamName(m.teamB))}</div>
<div class="pts mono" data-side="a">${live[0]}</div><div class="muted small" style="text-align:center">to ${t.rules.pointsTo}</div><div class="pts mono" data-side="b">${live[1]}</div>
<div class="btns"><button type="button" data-op="a-"></button><button type="button" data-op="a+">+</button></div><div></div><div class="btns"><button type="button" data-op="b-"></button><button type="button" data-op="b+">+</button></div>
</div>
<div class="inline"><button type="button" class="primary" data-final>Mark final</button>
<form method="post" action="/admin/t/${esc(t.slug)}/score" class="inline"><input type="hidden" name="match" value="${esc(m.id)}"><input name="sets" placeholder="21-18, 19-21, 15-9" style="width:150px"><button class="small" type="submit">Save sets</button></form>
<form method="post" action="/admin/t/${esc(t.slug)}/forfeit" class="inline"><input type="hidden" name="match" value="${esc(m.id)}"><select name="team"><option value="${esc(m.teamA)}">${esc(t.teamName(m.teamA))}</option><option value="${esc(m.teamB)}">${esc(t.teamName(m.teamB))}</option></select><button class="small danger" type="submit">Forfeits</button></form>
${t.courtCount > 1 ? `<form method="post" action="/admin/t/${esc(t.slug)}/swap" class="inline"><input type="hidden" name="match" value="${esc(m.id)}"><select name="court">${Array.from({ length: t.courtCount }, (_, i) => i + 1).filter(n => n !== m.court).map(n => `<option>${n}</option>`).join('')}</select><button class="small" type="submit">Move to court</button></form>` : ''}
</div>`;
}
function describe(e, t) {
const n = id => t.teamName(id);
switch (e.type) {
case 'team_registered': return `Registered: ${e.name}`;
case 'team_updated': return `Edited team: ${e.name}`;
case 'team_removed': return `Removed a team`;
case 'team_withdrawn': return `Withdrawn: ${e.name} (${e.mode})`;
case 'phase': return `Phase → ${e.phase}${e.champion ? ` · champion ${e.champion}` : ''}`;
case 'pools_generated': return `Pools generated: ${e.pools.map(p => `${p.id}(${p.teams.length})`).join(' ')}`;
case 'bracket_generated': return `${e.bracket} bracket of ${e.size} generated`;
case 'match_started': return `Court ${e.court}: ${e.a} vs ${e.b}`;
case 'result': { const m = t.match(e.match); return `${m ? `${n(m.teamA)} vs ${n(m.teamB)}` : e.match}${n(e.winner)} ${e.score}${e.actor ? ` (${e.actor})` : ''}`; }
case 'alert': return `Alert: ${e.text}`;
case 'court_paused': return `Court ${e.court} paused${e.reason ? `: ${e.reason}` : ''}`;
case 'court_resumed': return `Court ${e.court} resumed`;
case 'court_swapped': return `Match moved from court ${e.from} to ${e.to}`;
case 'match_pushed_back': return `Match pushed back`;
default: return e.type;
}
}
export function qrPage(t, origin, dataUrl, who) {
const url = `${origin}/t/${t.slug}`;
return layout({ title: `${t.name} QR`, admin: who, body: `
<section class="card qr"><h1>${esc(t.name)}</h1><p class="lede">Scan to register, then to follow the tournament live.</p><img src="${dataUrl}" alt="QR code for ${esc(url)}"><p class="mono">${esc(url)}</p>
<p><button onclick="print()">Print</button> <a class="button" href="/admin/t/${esc(t.slug)}">Back to desk</a></p></section>` });
}
+32
View File
@@ -0,0 +1,32 @@
import { esc } from '../http.js';
export function layout({ title, body, state = null, script = null, bodyClass = '', admin = null, nav = '' }) {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0F1418">
<title>${esc(title)} · Courtside</title>
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
<link rel="manifest" href="/static/manifest.webmanifest">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@500;600;700&family=Source+Sans+3:wght@400;600&family=JetBrains+Mono:wght@500&display=swap">
<link rel="stylesheet" href="/static/app.css">
</head>
<body class="${esc(bodyClass)}">
<header class="top">
<a class="brand" href="/">Courtside</a>
<nav>${nav}${admin ? `<a href="/admin">Desk</a><a href="/admin/logout">Sign out</a>` : ''}</nav>
</header>
<main>
${body}
</main>
<footer class="foot">Courtside · self-hosted tournament desk · <a href="https://gitea.cloudfreeiot.com/bkvargyas/courtside">source</a></footer>
${state ? `<script id="state" type="application/json">${JSON.stringify(state).replace(/</g, '\\u003c')}</script>` : ''}
${script ? `<script src="${esc(script)}" defer></script>` : ''}
</body>
</html>`;
}
export const flash = (msg, kind = 'ok') => msg ? `<div class="flash ${kind}">${esc(msg)}</div>` : '';
+61
View File
@@ -0,0 +1,61 @@
import { esc } from '../http.js';
import { layout, flash } from './layout.js';
export function homePage(list) {
const rows = list.length
? list.map(x => `<a class="row" href="/t/${esc(x.slug)}"><span class="pill ${esc(x.phase)}">${esc(phaseLabel(x.phase))}</span><b>${esc(x.name)}</b><span class="muted">${esc(x.date ?? '')} · ${x.teams} teams</span></a>`).join('')
: `<p class="muted">No tournaments yet. An organizer creates one from the <a href="/admin">desk</a>.</p>`;
return layout({ title: 'Tournaments', body: `<h1>Tournaments</h1><div class="list">${rows}</div>` });
}
export const phaseLabel = p => ({ checkin: 'Registration open', closed: 'Registration closed', live: 'Live', final: 'Final' }[p] ?? p);
/** Registration form, shown while phase = checkin. */
export function registerPage(state, { error = null, values = {} } = {}) {
const r = state.rules;
const body = `
<section class="hero">
<div class="eyebrow">${esc(state.date ?? '')}</div>
<h1>${esc(state.name)}</h1>
<p class="lede">${esc(r.teamSize)}s · ${esc(r.pointsTo)} points, win by ${esc(r.winBy)}${r.cap ? `, cap ${esc(r.cap)}` : ''} · ${state.teams.length} team${state.teams.length === 1 ? '' : 's'} registered</p>
${state.notes ? `<p class="notes">${esc(state.notes)}</p>` : ''}
</section>
${flash(error, 'error')}
<form class="card form" method="post" action="/t/${esc(state.slug)}/register">
<h2>Register your team</h2>
<label>Team name <input name="name" required maxlength="40" autocomplete="off" value="${esc(values.name ?? '')}" placeholder="Net Gains"></label>
<label>Captain's name <input name="captain" required maxlength="60" value="${esc(values.captain ?? '')}"></label>
<label>Captain's mobile <input name="phone" type="tel" inputmode="tel" maxlength="30" value="${esc(values.phone ?? '')}" placeholder="optional, for organizer contact"></label>
<label>Number of players <input name="players" type="number" inputmode="numeric" min="${esc(r.teamSize)}" max="20" required value="${esc(values.players ?? r.teamSize)}"></label>
<label>Player names <textarea name="playerNames" rows="3" placeholder="one per line (optional)">${esc(values.playerNames ?? '')}</textarea></label>
<button class="primary" type="submit">Register</button>
<p class="muted small">After you register you'll get a private team link. Keep this page's QR code handy: once play starts it becomes the live board.</p>
</form>
<section class="card">
<h2>Registered so far</h2>
${state.teams.length ? `<ol class="plain">${state.teams.map(t => `<li>${esc(t.name)} <span class="muted">(${t.players})</span></li>`).join('')}</ol>` : '<p class="muted">Be the first.</p>'}
</section>`;
return layout({ title: state.name, body });
}
export function registeredPage(state, team, origin) {
const link = `${origin}/t/${state.slug}/team/${team.code}`;
const body = `
<section class="hero"><div class="eyebrow">You're in</div><h1>${esc(team.name)}</h1><p class="lede">${team.players} players · captain ${esc(team.captain ?? '')}</p></section>
<section class="card">
<h2>Your team link</h2>
<p>This page will show your next match, which court, and your record. Bookmark it or add it to your home screen.</p>
<p class="linkbox"><a href="${esc(link)}">${esc(link)}</a></p>
<p><button class="primary" type="button" data-copy="${esc(link)}">Copy link</button> <a class="button" href="sms:?&body=${encodeURIComponent(`${team.name} team page: ${link}`)}">Text it to myself</a></p>
<p class="muted small">Team code: <b class="mono">${esc(team.code)}</b>. Anyone with the code can see your team page; nobody can change scores from it.</p>
</section>
<p><a href="/t/${esc(state.slug)}">Back to the tournament</a></p>
<script>document.querySelector('[data-copy]')?.addEventListener('click',e=>{navigator.clipboard?.writeText(e.target.dataset.copy).then(()=>{e.target.textContent='Copied'})});</script>`;
return layout({ title: `${team.name} registered`, body });
}
/** Everything after check-in: closed, live and final are all rendered client-side from the state JSON. */
export function boardPage(state, { teamId = null, display = false } = {}) {
const body = `<div id="app" class="app" data-team="${esc(teamId ?? '')}" data-display="${display ? '1' : ''}"><noscript>This page needs JavaScript to show live scores.</noscript><div class="loading">Loading ${esc(state.name)}…</div></div>`;
return layout({ title: state.name, body, state, script: '/static/board.js', bodyClass: display ? 'display' : '' });
}