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
62 lines
4.5 KiB
JavaScript
62 lines
4.5 KiB
JavaScript
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' : '' });
|
|
}
|