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:
@@ -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`; }
|
||||
}
|
||||
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
@@ -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 => ({ '&': '&', '<': '<', '>': '>' }[c])); }
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
})();
|
||||
@@ -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}}
|
||||
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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' ? '0–0' : '';
|
||||
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) || '0–0'}</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);
|
||||
})();
|
||||
@@ -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 |
@@ -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" }] }
|
||||
@@ -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';
|
||||
@@ -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 */ } }
|
||||
}
|
||||
}
|
||||
@@ -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(); }
|
||||
}
|
||||
@@ -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>` });
|
||||
}
|
||||
@@ -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>` : '';
|
||||
@@ -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' : '' });
|
||||
}
|
||||
Reference in New Issue
Block a user