Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae44014e22 |
@@ -9,6 +9,18 @@ so you can always tell what a deployment is actually serving.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.4.1] - 2026-09-03
|
||||
|
||||
### Added
|
||||
- Bracket matches that are waiting on a live match now appear in the queue and under each
|
||||
court as "Net Gains vs winner on Court 2 · ready ~2:33", so double elimination and late
|
||||
single elimination rounds still show a next-up time. These are tentative (no on-deck
|
||||
alert until both teams are known).
|
||||
- Score keeper access is a QR code per court, shown on each court card of the desk, plus a
|
||||
printable page with all courts' codes (`/admin/t/<slug>/keeper-qr`). The copy-link button
|
||||
is gone; "Open on this device" remains for the organizer's own phone.
|
||||
- Idle courts show their next-up match too.
|
||||
|
||||
## [0.4.0] - 2026-09-03
|
||||
|
||||
### Added
|
||||
|
||||
@@ -74,6 +74,7 @@ All organizer routes require the session cookie set by `/admin/login` (or Cloudf
|
||||
| POST | `/admin/new` | `name`, `date`, `courtCount`, `teamSize`, `pointsTo`, `winBy`, `cap` (0 = none), `bestOf`, `stages` (`pool,bracket` / `pool` / `bracket`), `notes` | Creates a tournament. |
|
||||
| GET | `/admin/t/<slug>` | | Tournament desk. |
|
||||
| GET | `/admin/t/<slug>/qr` | | Printable QR page. `qr.png` returns the PNG. |
|
||||
| GET | `/admin/t/<slug>/keeper-qr` | | Printable page with one score keeper QR code per court. |
|
||||
| POST | `/admin/t/<slug>/phase` | `phase` = `checkin` / `closed` / `live` / `final` | Change phase. `live` requires generated play and starts court assignment. |
|
||||
| POST | `/admin/t/<slug>/banner` | `banner` | Set (or clear with empty) the persistent banner. |
|
||||
| POST | `/admin/t/<slug>/broadcast` | `text` | One-time alert to all team pages. |
|
||||
|
||||
@@ -34,7 +34,7 @@ For best-of-3, or if you'd rather type, use "Save sets" with `21-18, 19-21, 15-9
|
||||
|
||||
## Score keepers (Courtside Manager)
|
||||
|
||||
You don't have to score every court yourself. Each court card on the desk has a **Score keeper** link (and a Copy link button). Hand that link to whoever is sitting at that court; it opens Courtside Manager, a full-screen page with two big + buttons, a − for a mis-tap, Undo, and a Finalize button that appears once a side has won the set under your rules. The page always shows whatever match is on that court and switches to the next one by itself when the previous is finalized, so one link lasts the whole day.
|
||||
You don't have to score every court yourself. Each court card on the desk shows a **score keeper QR code**; "Score keeper QR codes" at the top of the desk prints one large code per court to tape at the scorer's table. Whoever is sitting at that court scans it and gets Courtside Manager, a full-screen page with two big + buttons, a − for a mis-tap, Undo, and a Finalize button that appears once a side has won the set under your rules. The page always shows whatever match is on that court and switches to the next one by itself when the previous is finalized, so one link lasts the whole day.
|
||||
|
||||
A keeper link can only do two things: update the running score of the live match on its court, and finalize it. It cannot change a finished score, forfeit, or touch anything else; corrections are made from the desk ("All matches"). Links are signed with the server's `SESSION_SECRET`, so if that changes the old links stop working and you copy new ones.
|
||||
|
||||
|
||||
+18
-2
@@ -102,7 +102,7 @@ export class CourtEngine {
|
||||
const m = c.matchId ? this.t.match(c.matchId) : null;
|
||||
return { court: c.number, status: c.status, match: m ? this._matchView(m) : null };
|
||||
}),
|
||||
upNext: q.slice(0, 3).map(x => ({ ...this._matchView(x.match), court: x.court, etaMin: Math.round(x.eta / MIN) })),
|
||||
upNext: q.slice(0, 3).map(x => ({ ...this._matchView(x.match), court: x.court, etaMin: Math.round(x.eta / MIN), tentative: !!x.tentative, aFrom: x.aFrom ?? null, bFrom: x.bFrom ?? null })),
|
||||
avgMatchMin: Math.round(this.avgMatchMs() / MIN),
|
||||
};
|
||||
}
|
||||
@@ -121,6 +121,22 @@ export class CourtEngine {
|
||||
sims[0].freeAt = start + this.avgMatchMs();
|
||||
teamFree.set(m.teamA, sims[0].freeAt); teamFree.set(m.teamB, sims[0].freeAt);
|
||||
}
|
||||
// Bracket matches still waiting on a live match (one or both sides TBD) get a tentative slot too,
|
||||
// so late in a bracket the board still says "Net Gains vs winner on Court 2 · ready ~2:10".
|
||||
const decided = x => ['final', 'forfeit', 'bye', 'void'].includes(x.status);
|
||||
const liveCourt = x => this.courts.find(c => c.matchId === x.id)?.number ?? null;
|
||||
for (const m of this.t.matches) {
|
||||
if (m.status !== 'pending' || m.conditional || m.stage === 'pool' || (m.teamA && m.teamB)) continue;
|
||||
const feeders = this.t.matches.filter(x => x.feeds === m.id || x.loserFeeds === m.id);
|
||||
if (!feeders.length || !feeders.every(x => decided(x) || x.status === 'live') || !feeders.some(x => x.status === 'live')) continue;
|
||||
const side = (which) => { const f = feeders.find(x => (x.feeds === m.id && x.feedsSide === which) || (x.loserFeeds === m.id && x.loserFeedsSide === which)); return f && f.status === 'live' ? { court: liveCourt(f), loser: f.loserFeeds === m.id } : null; };
|
||||
const waitOn = feeders.filter(x => x.status === 'live').map(x => this._expectedFinish(this.courts.find(c => c.matchId === x.id)));
|
||||
sims.sort((a, b) => a.freeAt - b.freeAt);
|
||||
const ready = Math.max(sims[0].freeAt, ...waitOn, m.teamA ? teamFree.get(m.teamA) ?? 0 : 0, m.teamB ? teamFree.get(m.teamB) ?? 0 : 0);
|
||||
const start = ready + this.changeoverMs;
|
||||
out.push({ match: m, court: sims[0].number, eta: start - this.now(), tentative: true, aFrom: m.teamA ? null : side('A'), bFrom: m.teamB ? null : side('B') });
|
||||
sims[0].freeAt = start + this.avgMatchMs();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -173,7 +189,7 @@ export class CourtEngine {
|
||||
_freeCourt(c) { c.matchId = null; c.startedAt = null; c.freeAt = null; }
|
||||
|
||||
_refreshOnDeck() {
|
||||
const q = this.queue();
|
||||
const q = this.queue().filter(x => !x.tentative);
|
||||
const deck = q.slice(0, this.courts.filter(c => c.status === 'open').length);
|
||||
const deckIds = new Set(deck.map(x => x.match.id));
|
||||
for (const m of this.t.matches) if (m.status === 'on_deck' && !deckIds.has(m.id)) { m.status = 'pending'; m.court = null; }
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"name":"courtside","type":"module","version":"0.4.0","scripts":{"test":"node --test test/*.test.js","sim":"node simulate.js","start":"node --disable-warning=ExperimentalWarning server/index.js","dev":"node --disable-warning=ExperimentalWarning --watch server/index.js","demo":"node --disable-warning=ExperimentalWarning scripts/seed-demo.js","smoke":"bash test/smoke.sh"},"description":"Self-hosted volleyball tournament desk: one QR code from check-in to live scores","main":"server/index.js","directories":{"test":"test"},"keywords":[],"author":"","license":"MIT","dependencies":{"qrcode":"^1.5.4","ws":"^8.21.3"},"engines":{"node":">=22.13"},"repository":{"type":"git","url":"https://gitea.cloudfreeiot.com/bkvargyas/courtside.git"}}
|
||||
{"name":"courtside","type":"module","version":"0.4.1","scripts":{"test":"node --test test/*.test.js","sim":"node simulate.js","start":"node --disable-warning=ExperimentalWarning server/index.js","dev":"node --disable-warning=ExperimentalWarning --watch server/index.js","demo":"node --disable-warning=ExperimentalWarning scripts/seed-demo.js","smoke":"bash test/smoke.sh"},"description":"Self-hosted volleyball tournament desk: one QR code from check-in to live scores","main":"server/index.js","directories":{"test":"test"},"keywords":[],"author":"","license":"MIT","dependencies":{"qrcode":"^1.5.4","ws":"^8.21.3"},"engines":{"node":">=22.13"},"repository":{"type":"git","url":"https://gitea.cloudfreeiot.com/bkvargyas/courtside.git"}}
|
||||
@@ -21,7 +21,7 @@ export function publicState({ t, engine }) {
|
||||
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 })) : [],
|
||||
upNext: board ? board.upNext.map(u => ({ ...view(t.match(u.id)), court: u.court, etaMin: u.etaMin, tentative: u.tentative, aFrom: u.aFrom, bFrom: u.bFrom })) : [],
|
||||
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 })),
|
||||
|
||||
@@ -54,7 +54,7 @@ a.tl{color:inherit;text-decoration:none;border-bottom:1px dotted currentColor}a.
|
||||
.court .timing{position:relative;font-family:"Barlow Condensed",sans-serif;font-size:12px;letter-spacing:.06em;text-transform:uppercase;opacity:.85;margin-top:6px}
|
||||
.court .nextup{position:relative;margin-top:8px;padding-top:8px;border-top:1px solid rgba(255,255,255,.3);font-size:15px}
|
||||
.court .nextup .k{font-family:"Barlow Condensed",sans-serif;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:#F0C46B;margin-right:4px}
|
||||
.court .nextup .eta{font-size:13px;opacity:.85;margin-left:4px}.court .nextup.mine{font-weight:600}
|
||||
.court .nextup .eta{font-size:13px;opacity:.85;margin-left:4px}.court .nextup .tbd{color:inherit;opacity:.85}.court.idle .nextup{border-top-color:var(--line);color:var(--ink)}.court.idle .nextup .k{color:var(--sand)}.court .nextup.mine{font-weight:600}
|
||||
/* 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)}
|
||||
@@ -110,6 +110,11 @@ body.display .queue{font-size:26px}body.display details{display:none}
|
||||
@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}}
|
||||
.keeperbox{display:flex;gap:12px;align-items:flex-start;margin:6px 0 10px;padding:10px;border:1px dashed var(--line);border-radius:8px}
|
||||
.keeperbox img{flex:none;border-radius:4px;background:#fff;image-rendering:pixelated}
|
||||
.keeperbox .button{margin-top:6px}
|
||||
.qrgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px}.qrgrid .qr img{width:min(80vw,300px)}
|
||||
@media print{.qrgrid{grid-template-columns:1fr 1fr}.qrgrid .card{break-inside:avoid;border:1px solid #000}}
|
||||
/* score keeper (Courtside Manager) */
|
||||
body.keeper-body{background:#0F1418;color:#F2F5F7}body.keeper-body .top,body.keeper-body .foot{display:none}
|
||||
body.keeper-body main{max-width:720px;padding:12px 12px 40px}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
// every team name on the board links to that team's page (schedule, record, next match)
|
||||
const teamHref = t => `/t/${state.slug}/teams/${encodeURIComponent(t.id)}`;
|
||||
const name = t => t ? `<a class="tl" href="${teamHref(t)}">${esc(t.name)}</a>` : '<span class="tbd">TBD</span>';
|
||||
const fromName = (t, from) => t ? name(t) : from ? `<span class="tbd">${from.loser ? 'loser' : 'winner'} on Court ${from.court}</span>` : '<span class="tbd">TBD</span>';
|
||||
const clock = ms => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||
const isMine = m => myTeam && (m.a?.id === myTeam || m.b?.id === myTeam);
|
||||
const phaseLabel = { checkin: 'Registration open', closed: 'Registration closed', live: 'Live', final: 'Final' };
|
||||
@@ -70,7 +71,7 @@
|
||||
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>`;
|
||||
if (!m) return `<div class="court idle"><div class="lbl">Court ${c.court}</div><div class="teams"><div class="t muted">Open</div></div>${nextForCourt(c.court)}</div>`;
|
||||
const avg = state.avgMatchMin;
|
||||
const elapsed = m.startedAt ? Math.max(0, Math.round((Date.now() - m.startedAt) / 60000)) : null;
|
||||
const left = avg != null && elapsed != null ? Math.max(0, avg - elapsed) : null;
|
||||
@@ -88,12 +89,12 @@
|
||||
const u = state.upNext.find(x => x.court === courtNo);
|
||||
if (!u) return '';
|
||||
const ready = clock(state.generatedAt + u.etaMin * 60000);
|
||||
return `<div class="nextup ${isMine(u) ? 'mine' : ''}"><span class="k">Next up</span> ${name(u.a)} vs ${name(u.b)} <span class="eta mono">ready ~${ready}</span></div>`;
|
||||
return `<div class="nextup ${isMine(u) ? 'mine' : ''}"><span class="k">Next up</span> ${fromName(u.a, u.aFrom)} vs ${fromName(u.b, u.bFrom)} <span class="eta mono">ready ~${ready}</span></div>`;
|
||||
}
|
||||
|
||||
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} · ~${clock(state.generatedAt + u.etaMin * 60000)} (${u.etaMin} min)</span></div>`).join('')}</section>`;
|
||||
return `<section class="queue"><div class="eyebrow">Up next</div>${state.upNext.map((u, i) => `<div class="qrow ${isMine(u) ? 'mine' : ''}"><span class="k">${u.tentative ? 'Pending' : i === 0 ? 'On deck' : 'Then'}</span><span>${fromName(u.a, u.aFrom)} vs ${fromName(u.b, u.bFrom)}</span><span class="eta mono">Court ${u.court} · ~${clock(state.generatedAt + u.etaMin * 60000)} (${u.etaMin} min)</span></div>`).join('')}</section>`;
|
||||
}
|
||||
|
||||
function renderPools() {
|
||||
|
||||
+12
-2
@@ -117,9 +117,19 @@ export function buildRouter({ registry, auth, store }) {
|
||||
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 }) => {
|
||||
r.get('/admin/t/:slug', async (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), (t, c) => keeperUrl(req, t.slug, c.number)));
|
||||
const it = item(slug);
|
||||
const keeper = { url: c => keeperUrl(req, slug, c.number), qr: {} };
|
||||
if (it.t.phase === 'live') for (const c of it.engine.courts) keeper.qr[c.number] = await QRCode.toDataURL(keeperUrl(req, slug, c.number), { width: 220, margin: 1 });
|
||||
send.html(res, adm.adminTournament(it, who, origin(req), q(req).get('msg'), q(req).get('error'), store.events(slug, 40), keeper));
|
||||
});
|
||||
r.get('/admin/t/:slug/keeper-qr', async (req, res, { slug }) => {
|
||||
const who = requireAdmin(req, res); if (!who) return;
|
||||
const it = item(slug);
|
||||
const cards = [];
|
||||
for (const c of it.engine.courts) cards.push({ court: c.number, url: keeperUrl(req, slug, c.number), qr: await QRCode.toDataURL(keeperUrl(req, slug, c.number), { width: 640, margin: 2 }) });
|
||||
send.html(res, adm.keeperQrPage(it.t, cards, who));
|
||||
});
|
||||
r.get('/admin/t/:slug/qr', async (req, res, { slug }) => {
|
||||
const who = requireAdmin(req, res); if (!who) return;
|
||||
|
||||
+13
-4
@@ -34,7 +34,7 @@ export function adminHome(list, who, msg) {
|
||||
</div>` });
|
||||
}
|
||||
|
||||
export function adminTournament({ t, engine }, who, origin, msg, err, events, keeperUrl = () => '#') {
|
||||
export function adminTournament({ t, engine }, who, origin, msg, err, events, keeper = { url: () => '#', qr: {} }) {
|
||||
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 }));
|
||||
@@ -47,7 +47,7 @@ export function adminTournament({ t, engine }, who, origin, msg, err, events, ke
|
||||
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>
|
||||
<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>${s === 'live' ? ` · <a href="/admin/t/${esc(t.slug)}/keeper-qr">Score keeper QR codes</a>` : ''}</p>
|
||||
${flash(msg)}${flash(err, 'error')}
|
||||
|
||||
|
||||
@@ -58,12 +58,12 @@ export function adminTournament({ t, engine }, who, origin, msg, err, events, ke
|
||||
<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>
|
||||
<p class="small"><a class="button small" href="${esc(keeperUrl(t, c))}" target="_blank">Score keeper</a> <button type="button" class="small" data-copy="${esc(keeperUrl(t, c))}">Copy link</button> <span class="muted">Hand this to whoever is scoring Court ${c.number}: it scores whatever match is on this court and finalizes it. Corrections stay here on the desk.</span></p>
|
||||
<div class="keeperbox">${keeper.qr[c.number] ? `<img src="${keeper.qr[c.number]}" alt="Score keeper QR for court ${c.number}" width="110" height="110">` : ''}<div><b>Score keeper for Court ${c.number}</b><br><span class="muted small">Whoever is scoring this court scans this. The page follows whatever match is on the court and can finalize it; corrections stay here on the desk.</span><br><a class="button small" href="${esc(keeper.url(c))}" target="_blank">Open on this device</a></div></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>'}
|
||||
${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>${q.tentative ? '<span class="pill">pending</span> ' : ''}${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>${q.tentative ? '' : 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>Phase</h2>
|
||||
@@ -167,3 +167,12 @@ export function keeperPage(t, court, k, state) {
|
||||
const body = `<div id="keeper" class="keeper" data-court="${court}" data-k="${esc(k)}"><div class="loading">Loading Court ${court}…</div></div>`;
|
||||
return layout({ title: `Court ${court} score keeper`, body, state, script: '/static/keeper.js', bodyClass: 'keeper-body' });
|
||||
}
|
||||
|
||||
/** Printable page: one score keeper QR per court, to tape to the scorer's table. */
|
||||
export function keeperQrPage(t, cards, who) {
|
||||
return layout({ title: `${t.name} score keeper codes`, admin: who, body: `
|
||||
<h1>${esc(t.name)} · score keeper codes</h1>
|
||||
<p class="muted">Print this and tape each code at its court. Scanning opens Courtside Manager for that court only. Codes stop working if the server's SESSION_SECRET changes.</p>
|
||||
<div class="qrgrid">${cards.map(c => `<section class="card qr"><h2>Court ${c.court}</h2><img src="${c.qr}" alt="Score keeper QR for court ${c.court}"><p class="small muted">Score keeper · ${esc(t.name)}</p></section>`).join('')}</div>
|
||||
<p><button onclick="print()">Print</button> <a class="button" href="/admin/t/${esc(t.slug)}">Back to desk</a></p>` });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user