Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe4f2a8f55 | ||
|
|
1956d25061 | ||
|
|
62efa4d127 | ||
|
|
3d241949f0 | ||
|
|
1bf2298f3f |
@@ -9,6 +9,37 @@ so you can always tell what a deployment is actually serving.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.3.1] - 2026-09-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Every team name on the public board (courts, up-next, bracket tree and list, pool
|
||||||
|
standings, team schedules) links to that team's page at `/t/<slug>/teams/<id>`, which
|
||||||
|
shows their next match, court, record and schedule. Team pages by private code still work.
|
||||||
|
- Court cards show the running average game time and minutes played, and a "Next up" line
|
||||||
|
with the match predicted for that court and the clock time it should be ready.
|
||||||
|
- Up-next queue shows the expected clock time as well as minutes.
|
||||||
|
- Footer links to the organizer desk.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- A team's schedule on its page shows scores from that team's point of view.
|
||||||
|
|
||||||
|
## [0.3.0] - 2026-09-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Bracket is drawn as a real tree: matches positioned per round with connector lines
|
||||||
|
between feeder and next match, winners in bold, live matches tagged with their court,
|
||||||
|
empty slots labelled with what fills them ("W R1.4", "L R2.1"), and the viewer's own
|
||||||
|
team highlighted along its path. Double elimination shows winners bracket, losers
|
||||||
|
bracket and finals as separate trees.
|
||||||
|
- Three bracket views: full tree with sideways scrolling, "Fit to screen" (scaled to the
|
||||||
|
phone's width, the default on narrow screens), and a round-by-round list. The choice is
|
||||||
|
remembered per device.
|
||||||
|
- `scripts/seed-demo.js --stage=bracket|double` seeds the demo mid-bracket.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Public state now includes `loserFeeds`/`loserFeedsSide` on bracket matches so the
|
||||||
|
page can label upcoming slots.
|
||||||
|
|
||||||
## [0.2.0] - 2026-09-03
|
## [0.2.0] - 2026-09-03
|
||||||
|
|
||||||
First deployed release. Courtside now runs as a managed stack rather than a
|
First deployed release. Courtside now runs as a managed stack rather than a
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ All pages are server-rendered HTML except where noted. `<slug>` is the URL-safe
|
|||||||
| GET | `/healthz` | `{ ok, tournaments, uptime }` JSON. Used by the Docker healthcheck. |
|
| GET | `/healthz` | `{ ok, tournaments, uptime }` JSON. Used by the Docker healthcheck. |
|
||||||
| GET | `/t/<slug>` | The QR landing page. Registration form while phase is `checkin`; otherwise the live board / results (client-rendered from state JSON). |
|
| GET | `/t/<slug>` | The QR landing page. Registration form while phase is `checkin`; otherwise the live board / results (client-rendered from state JSON). |
|
||||||
| POST | `/t/<slug>/register` | Form fields `name`, `captain`, `phone`, `players`, `playerNames` (newline-separated). 303 to the team page on success, 400 with the form re-rendered on error. |
|
| POST | `/t/<slug>/register` | Form fields `name`, `captain`, `phone`, `players`, `playerNames` (newline-separated). 303 to the team page on success, 400 with the form re-rendered on error. |
|
||||||
|
| GET | `/t/<slug>/teams/<id>` | The same personalized board addressed by public team id; this is what team-name links use. |
|
||||||
| GET | `/t/<slug>/team/<code>` | Personalized board for the team with that 4-character code. `?new=1` shows the post-registration confirmation instead. |
|
| GET | `/t/<slug>/team/<code>` | Personalized board for the team with that 4-character code. `?new=1` shows the post-registration confirmation instead. |
|
||||||
| GET | `/t/<slug>/display` | TV display mode of the board. |
|
| GET | `/t/<slug>/display` | TV display mode of the board. |
|
||||||
| GET | `/t/<slug>/state.json` | The public state document (below). |
|
| GET | `/t/<slug>/state.json` | The public state document (below). |
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
{"name":"courtside","type":"module","version":"0.2.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.3.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"}}
|
||||||
+25
-2
@@ -1,12 +1,16 @@
|
|||||||
// Creates a demo tournament in the database so a fresh install has something to look at.
|
// Creates a demo tournament in the database so a fresh install has something to look at.
|
||||||
// Run with the server STOPPED (the server keeps state in memory and writes it back):
|
// Run with the server STOPPED (the server keeps state in memory and writes it back):
|
||||||
// DB_PATH=./data/courtside.sqlite node scripts/seed-demo.js
|
// DB_PATH=./data/courtside.sqlite node scripts/seed-demo.js
|
||||||
|
// Options: --stage=pool (default, mid pool play) | --stage=bracket (single elim in progress)
|
||||||
|
// | --stage=double (double elim in progress)
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { Store } from '../server/store.js';
|
import { Store } from '../server/store.js';
|
||||||
import { Registry } from '../server/state.js';
|
import { Registry } from '../server/state.js';
|
||||||
|
|
||||||
const here = dirname(fileURLToPath(import.meta.url));
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const args = Object.fromEntries(process.argv.slice(2).map(a => a.replace(/^--/, '').split('=')));
|
||||||
|
const STAGE = args.stage ?? 'pool';
|
||||||
const DB_PATH = process.env.DB_PATH ?? join(here, '..', 'data', 'courtside.sqlite');
|
const DB_PATH = process.env.DB_PATH ?? join(here, '..', 'data', 'courtside.sqlite');
|
||||||
const store = new Store(DB_PATH);
|
const store = new Store(DB_PATH);
|
||||||
const registry = new Registry(store);
|
const registry = new Registry(store);
|
||||||
@@ -31,9 +35,28 @@ for (let i = 0; i < 7; i++) {
|
|||||||
const hi = 21, lo = Math.floor(8 + rnd() * 12);
|
const hi = 21, lo = Math.floor(8 + rnd() * 12);
|
||||||
engine.recordResult(c.matchId, [rnd() < 0.5 ? [hi, lo] : [lo, hi]]);
|
engine.recordResult(c.matchId, [rnd() < 0.5 ? [hi, lo] : [lo, hi]]);
|
||||||
}
|
}
|
||||||
|
if (STAGE !== 'pool') {
|
||||||
|
// finish pool play, build the bracket, decide a few bracket matches
|
||||||
|
while (engine.courts.some(c => c.matchId) && t.matches.filter(m => m.stage === 'pool' && m.status === 'pending').length + engine.courts.filter(c => c.matchId).length > 0) {
|
||||||
|
const c = engine.courts.find(c => c.matchId); if (!c) break;
|
||||||
|
clock += (12 + rnd() * 6) * 60_000;
|
||||||
|
const lo = Math.floor(8 + rnd() * 12);
|
||||||
|
engine.recordResult(c.matchId, [rnd() < 0.5 ? [21, lo] : [lo, 21]]);
|
||||||
|
}
|
||||||
|
const seeded = t.advanceFromPools({ perPool: 2, wildcards: 2 });
|
||||||
|
t.generateBracket(seeded, { type: STAGE === 'double' ? 'double' : 'single', thirdPlace: STAGE !== 'double' });
|
||||||
|
engine.tick();
|
||||||
|
for (let i = 0; i < (STAGE === 'double' ? 5 : 3); i++) {
|
||||||
|
const c = engine.courts.find(c => c.matchId); if (!c) break;
|
||||||
|
clock += (12 + rnd() * 6) * 60_000;
|
||||||
|
const lo = Math.floor(8 + rnd() * 12);
|
||||||
|
engine.recordResult(c.matchId, [rnd() < 0.5 ? [21, lo] : [lo, 21]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
const live = engine.courts.filter(c => c.matchId).map(c => t.match(c.matchId));
|
const live = engine.courts.filter(c => c.matchId).map(c => t.match(c.matchId));
|
||||||
if (live[0]) live[0].live = [14, 11];
|
if (live[0]) { live[0].live = [14, 11]; live[0].startedAt = Date.now() - 9 * 60_000; }
|
||||||
if (live[1]) live[1].live = [7, 9];
|
if (live[1]) { live[1].live = [7, 9]; live[1].startedAt = Date.now() - 5 * 60_000; }
|
||||||
|
for (const c of engine.courts) if (c.matchId) c.startedAt = t.match(c.matchId).startedAt;
|
||||||
t.banner = 'Demo tournament — scores are simulated';
|
t.banner = 'Demo tournament — scores are simulated';
|
||||||
engine.now = () => Date.now(); t.now = engine.now;
|
engine.now = () => Date.now(); t.now = engine.now;
|
||||||
registry.persist(t.slug);
|
registry.persist(t.slug);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export function publicState({ t, engine }) {
|
|||||||
id: m.id, stage: m.stage, round: m.round, slot: m.slot, label: m.label ?? null, status: m.status, court: m.court,
|
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,
|
a: m.teamA ? { id: m.teamA, name: name(m.teamA) } : null,
|
||||||
b: m.teamB ? { id: m.teamB, name: name(m.teamB) } : 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,
|
winner: m.winner, sets: m.sets, live: m.live ?? null, startedAt: m.startedAt ?? null, feeds: m.feeds, feedsSide: m.feedsSide, loserFeeds: m.loserFeeds ?? null, loserFeedsSide: m.loserFeedsSide ?? null, conditional: !!m.conditional,
|
||||||
});
|
});
|
||||||
const board = t.phase === 'live' ? engine.board() : null;
|
const board = t.phase === 'live' ? engine.board() : null;
|
||||||
const pools = t.pools.map(p => ({
|
const pools = t.pools.map(p => ({
|
||||||
|
|||||||
+26
-9
@@ -50,6 +50,11 @@ button:focus-visible,a:focus-visible,input:focus-visible{outline:2px solid var(-
|
|||||||
.queue .eyebrow{grid-column:1/-1;color:#8E9BA6}
|
.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{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}
|
.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}
|
||||||
|
a.tl{color:inherit;text-decoration:none;border-bottom:1px dotted currentColor}a.tl:hover{border-bottom-style:solid}
|
||||||
|
.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}
|
||||||
/* pools */
|
/* pools */
|
||||||
.pools{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:12px}
|
.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)}
|
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)}
|
||||||
@@ -63,14 +68,26 @@ ul.plain,ol.plain{list-style:none;padding:0;margin:8px 0}ol.plain{counter-reset:
|
|||||||
.matches li.live,.sched li.live{background:var(--accent-soft)}.won{font-weight:600}.matches li.mine{outline:1px solid var(--sand)}
|
.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}
|
.mine.card{border-color:var(--sand);border-width:2px}.big{font-size:20px}
|
||||||
/* bracket */
|
/* bracket */
|
||||||
.bracketwrap{overflow-x:auto}.bstage{margin-top:12px}
|
.bracketwrap .bhead{display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px}
|
||||||
.bracket{display:flex;gap:16px;align-items:stretch;min-width:max-content;padding-bottom:6px}
|
.bviews{display:flex;gap:4px}.bviews .cur{background:var(--accent);color:var(--accent-ink);border-color:var(--accent)}
|
||||||
.round{display:flex;flex-direction:column;justify-content:space-around;gap:10px;min-width:190px}
|
.bstage{margin-top:14px}.bhint{margin:8px 0 0}
|
||||||
.rlbl{font-family:"Barlow Condensed",sans-serif;text-transform:uppercase;letter-spacing:.1em;font-size:12px;color:var(--muted);text-align:center}
|
.btree-scroll{overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;padding-bottom:6px}
|
||||||
.bm{border:1px solid var(--line);border-radius:6px;background:var(--bg);font-size:15px}
|
.btree{position:relative}.rlbls{position:absolute;top:0;left:0;right:0;height:22px}
|
||||||
.bm>div{display:flex;justify-content:space-between;padding:5px 10px;gap:8px}.bm>div+div{border-top:1px solid var(--line)}
|
.rlbl{position:absolute;font-family:"Barlow Condensed",sans-serif;text-transform:uppercase;letter-spacing:.1em;font-size:12px;color:var(--muted);text-align:center;white-space:nowrap}
|
||||||
.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}
|
.bcanvas{position:absolute;left:0}.bcanvas svg{position:absolute;left:0;top:0;overflow:visible}
|
||||||
.bm .mlbl.live{background:var(--good);color:#fff}.bm.live{border-color:var(--good)}.bm.mine{border-color:var(--sand);border-width:2px}
|
.bcanvas path{fill:none;stroke:var(--line);stroke-width:2}.bcanvas path.decided{stroke:var(--muted)}.bcanvas path.mine{stroke:var(--sand);stroke-width:3}
|
||||||
|
.bm{position:absolute;border:1px solid var(--line);border-radius:6px;background:var(--surface);font-size:14px;display:flex;flex-direction:column;justify-content:center;overflow:visible}
|
||||||
|
.bm.list{position:static;width:auto;height:auto;margin:8px 0}
|
||||||
|
.brow{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:4px 9px;line-height:1.25;min-height:50%}
|
||||||
|
.brow+.brow{border-top:1px solid var(--line)}
|
||||||
|
.brow .bn{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.brow .bs{flex:none;min-width:1.5em;text-align:right;color:var(--muted)}
|
||||||
|
.brow.won .bn{font-weight:600}.brow.won .bs{color:var(--ink);font-weight:600}.brow.mine{background:var(--sand-soft)}
|
||||||
|
.bm.live{border-color:var(--good);box-shadow:0 0 0 1px var(--good)}.bm.on_deck{border-style:dashed}.bm.mine{border-color:var(--sand);border-width:2px}
|
||||||
|
.btag{position:absolute;left:50%;transform:translateX(-50%);bottom:-9px;white-space:nowrap;font-family:"Barlow Condensed",sans-serif;text-transform:uppercase;letter-spacing:.08em;font-size:10px;padding:0 6px;border-radius:3px;background:var(--bg);border:1px solid var(--line);color:var(--muted);line-height:1.5}
|
||||||
|
.btag.live{background:var(--good);color:#fff;border-color:var(--good)}
|
||||||
|
.bm.list .btag{position:static;transform:none;display:inline-block;align-self:flex-start;margin:4px 9px 6px}
|
||||||
|
.blist{margin-top:10px}.blist .rlbl{position:static;text-align:left}
|
||||||
|
.bracketwrap.fit .btree-scroll{overflow:hidden}
|
||||||
.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}
|
.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}
|
.loading{color:var(--muted);padding:24px}
|
||||||
/* display (TV) mode */
|
/* display (TV) mode */
|
||||||
@@ -78,7 +95,7 @@ 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 .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 .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 .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 .card{background:#1A222B;border-color:#2B3640;color:#F2F5F7}body.display td{border-color:#2B3640}body.display .bm{background:#12181F;border-color:#2B3640}body.display .brow+.brow{border-color:#2B3640}body.display .bcanvas path{stroke:#3A4652}body.display .btag{background:#1A222B;border-color:#2B3640}
|
||||||
body.display .queue{font-size:26px}body.display details{display:none}
|
body.display .queue{font-size:26px}body.display details{display:none}
|
||||||
/* admin */
|
/* admin */
|
||||||
.grid2{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:12px}
|
.grid2{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:12px}
|
||||||
|
|||||||
+129
-12
@@ -7,11 +7,23 @@
|
|||||||
const display = app.dataset.display === '1';
|
const display = app.dataset.display === '1';
|
||||||
const seenAlerts = new Set();
|
const seenAlerts = new Set();
|
||||||
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||||
const name = t => t ? esc(t.name) : '<span class="tbd">TBD</span>';
|
// 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 clock = ms => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||||
const isMine = m => myTeam && (m.a?.id === myTeam || m.b?.id === myTeam);
|
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 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(', ')
|
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' : '';
|
: m.status === 'forfeit' ? 'forfeit' : m.live ? `${m.live[0]}–${m.live[1]}` : m.status === 'live' ? '0–0' : '';
|
||||||
|
// score from one team's point of view (their points first)
|
||||||
|
const scoreFor = (m, teamId) => {
|
||||||
|
const flip = m.b?.id === teamId;
|
||||||
|
const pair = ([a, b]) => flip ? `${b}–${a}` : `${a}–${b}`;
|
||||||
|
if (m.status === 'final') return m.sets.map(pair).join(', ');
|
||||||
|
if (m.status === 'forfeit') return 'forfeit';
|
||||||
|
if (m.live) return pair(m.live);
|
||||||
|
return 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}` : ''}`;
|
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() {
|
function render() {
|
||||||
@@ -30,6 +42,7 @@
|
|||||||
if (!s.pools.length && !s.bracket.length) parts.push(renderRoster());
|
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>`);
|
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('');
|
app.innerHTML = parts.join('');
|
||||||
|
fitBracket();
|
||||||
notifyMine();
|
notifyMine();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +61,7 @@
|
|||||||
else if (s.phase === 'live') now = `<p>No match scheduled yet. Waiting on results from other courts.</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>`;
|
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>
|
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>` : ''}
|
${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">${scoreFor(m, t.id) || (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>` : ''}
|
${!display && 'Notification' in window && Notification.permission === 'default' ? `<button type="button" class="button" id="notify">Alert me when we're up</button>` : ''}
|
||||||
</section>`;
|
</section>`;
|
||||||
}
|
}
|
||||||
@@ -58,36 +71,140 @@
|
|||||||
const m = c.match;
|
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 (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></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;
|
||||||
|
const timing = avg != null ? `avg game ${avg} min${elapsed != null ? ` · ${elapsed} min played${left ? `, ~${left} left` : ''}` : ''}` : '';
|
||||||
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>
|
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>`;
|
<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>
|
||||||
|
${timing ? `<div class="timing">${timing}</div>` : ''}
|
||||||
|
${nextForCourt(c.court)}</div>`;
|
||||||
}).join('')}</section>`;
|
}).join('')}</section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// the first queued match predicted for this court, with the clock time it should be ready
|
||||||
|
function nextForCourt(courtNo) {
|
||||||
|
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>`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderUpNext() {
|
function renderUpNext() {
|
||||||
if (!state.upNext.length) return '';
|
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>`;
|
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>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPools() {
|
function renderPools() {
|
||||||
return `<section class="pools">${state.pools.map(p => `<div class="card pool"><h2>Pool ${esc(p.id)}</h2>
|
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>
|
<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('')}
|
${p.standings.map((r, i) => `<tr class="${r.status === 'withdrawn' ? 'withdrawn' : ''} ${r.teamId === myTeam ? 'mine' : ''}"><td>${i + 1}. ${name({ id: r.teamId, name: 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>
|
</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>
|
<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>`;
|
</div>`).join('')}</section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- bracket: a real tree with connector lines, plus a list view for small screens ----
|
||||||
|
// default: full tree on wide screens, scaled-to-fit on phones; the viewer's choice is remembered
|
||||||
|
let bracketView = window.innerWidth < 700 ? 'fit' : 'tree';
|
||||||
|
try { bracketView = localStorage.getItem('courtside.bracketView') || bracketView; } catch {}
|
||||||
|
const BOX_W = 176, BOX_H = 58, COL_GAP = 44, ROW_GAP = 14;
|
||||||
|
|
||||||
function renderBracket() {
|
function renderBracket() {
|
||||||
const groups = [['bracket', 'Bracket'], ['losers', 'Losers bracket'], ['final', 'Finals']];
|
const isDouble = state.bracket.some(m => m.stage === 'losers');
|
||||||
return `<section class="card bracketwrap"><h2>${state.bracket.some(m => m.stage === 'losers') ? 'Double elimination' : 'Bracket'}</h2>${groups.map(([stage, title]) => {
|
const toggle = display ? '' : `<div class="bviews" role="tablist">${[['tree', 'Bracket'], ['fit', 'Fit to screen'], ['list', 'List']].map(([v, l]) => `<button type="button" class="small ${bracketView === v ? 'cur' : ''}" data-bview="${v}">${l}</button>`).join('')}</div>`;
|
||||||
const ms = state.bracket.filter(m => m.stage === stage && !(m.conditional && m.status !== 'final' && !m.a));
|
const stages = [['bracket', isDouble ? 'Winners bracket' : 'Bracket'], ['losers', 'Losers bracket'], ['final', 'Finals']];
|
||||||
|
const body = stages.map(([stage, title]) => {
|
||||||
|
const ms = state.bracket.filter(m => m.stage === stage && !(m.conditional && !m.a));
|
||||||
if (!ms.length) return '';
|
if (!ms.length) return '';
|
||||||
const rounds = [...new Set(ms.map(m => m.round))].sort((a, b) => a - b);
|
return `<div class="bstage">${isDouble ? `<h3>${title}</h3>` : ''}${bracketView === 'list' ? bracketList(stage, ms) : bracketTree(stage, ms)}</div>`;
|
||||||
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('');
|
||||||
}).join('')}</section>`;
|
return `<section class="card bracketwrap ${bracketView === 'fit' ? 'fit' : ''}"><div class="bhead"><h2>${isDouble ? 'Double elimination' : 'Bracket'}</h2>${toggle}</div>${body}${bracketView === 'tree' && !display ? '<p class="muted small bhint">Swipe sideways to see later rounds.</p>' : ''}</section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bracketTree(stage, ms) {
|
||||||
|
const third = ms.find(m => m.label === '3rd place');
|
||||||
|
const tree = ms.filter(m => m !== third);
|
||||||
|
const rounds = [...new Set(tree.map(m => m.round))].sort((a, b) => a - b);
|
||||||
|
const pos = new Map();
|
||||||
|
const feedersOf = m => tree.filter(f => f.feeds === m.id);
|
||||||
|
rounds.forEach((r, ri) => {
|
||||||
|
const inRound = tree.filter(m => m.round === r).sort((a, b) => (a.slot ?? 0) - (b.slot ?? 0));
|
||||||
|
const x = ri * (BOX_W + COL_GAP);
|
||||||
|
let cursor = 0;
|
||||||
|
inRound.forEach(m => {
|
||||||
|
const fs = feedersOf(m).map(f => pos.get(f.id)).filter(Boolean);
|
||||||
|
let y;
|
||||||
|
if (fs.length) y = fs.reduce((a, p) => a + p.y, 0) / fs.length;
|
||||||
|
else y = cursor;
|
||||||
|
y = Math.max(y, cursor);
|
||||||
|
pos.set(m.id, { x, y });
|
||||||
|
cursor = y + BOX_H + ROW_GAP;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
let height = Math.max(...[...pos.values()].map(p => p.y)) + BOX_H;
|
||||||
|
let width = rounds.length * (BOX_W + COL_GAP) - COL_GAP;
|
||||||
|
if (third) { const f = pos.get(tree.find(m => m.round === rounds.at(-1))?.id) ?? { x: 0, y: 0 }; pos.set(third.id, { x: f.x, y: height + ROW_GAP * 2 }); height += ROW_GAP * 2 + BOX_H; }
|
||||||
|
const paths = tree.filter(m => m.feeds && pos.has(m.feeds)).map(m => {
|
||||||
|
const a = pos.get(m.id), b = pos.get(m.feeds);
|
||||||
|
const x1 = a.x + BOX_W, y1 = a.y + BOX_H / 2, x2 = b.x, y2 = b.y + BOX_H / 2, xm = x1 + COL_GAP / 2;
|
||||||
|
const hot = m.winner && isMineId(m.winner);
|
||||||
|
return `<path d="M${x1} ${y1} H${xm} V${y2} H${x2}" class="${m.winner ? 'decided' : ''} ${hot ? 'mine' : ''}"/>`;
|
||||||
|
}).join('');
|
||||||
|
const boxes = [...pos.entries()].map(([id, p]) => matchBox(ms.find(m => m.id === id), p)).join('');
|
||||||
|
const labels = rounds.map((r, ri) => `<div class="rlbl" style="left:${ri * (BOX_W + COL_GAP)}px;width:${BOX_W}px">${roundName(stage, r, rounds.length, tree)}</div>`).join('');
|
||||||
|
return `<div class="btree-scroll"><div class="btree" style="width:${width}px;height:${height + 22}px"><div class="rlbls">${labels}</div><div class="bcanvas" style="top:22px"><svg width="${width}" height="${height}" aria-hidden="true">${paths}</svg>${boxes}</div></div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchBox(m, p) {
|
||||||
|
const row = (t, side) => {
|
||||||
|
const won = m.winner && t && m.winner === t.id;
|
||||||
|
const pts = m.sets[0] ? m.sets.map(s => s[side]).join(' ') : (m.live && m.status === 'live' ? m.live[side] : '');
|
||||||
|
return `<div class="brow ${won ? 'won' : ''} ${t && isMineId(t.id) ? 'mine' : ''}"><span class="bn">${t ? name(t) : `<span class="tbd">${feedLabel(m, side)}</span>`}</span><span class="bs mono">${pts}</span></div>`;
|
||||||
|
};
|
||||||
|
const tag = m.status === 'live' ? `<span class="btag live">Court ${m.court}</span>` : m.status === 'on_deck' ? `<span class="btag">On deck${m.court ? ` · C${m.court}` : ''}</span>` : m.status === 'forfeit' ? '<span class="btag">Forfeit</span>' : m.label ? `<span class="btag">${esc(m.label)}</span>` : '';
|
||||||
|
return `<div class="bm ${m.status} ${isMine(m) ? 'mine' : ''}" style="left:${p.x}px;top:${p.y}px;width:${BOX_W}px;height:${BOX_H}px">${row(m.a, 0)}${row(m.b, 1)}${tag}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function feedLabel(m, side) {
|
||||||
|
// what fills this slot later: winner/loser of which match
|
||||||
|
const wantSide = side === 0 ? 'A' : 'B';
|
||||||
|
const w = state.bracket.find(f => f.feeds === m.id && f.feedsSide === wantSide);
|
||||||
|
if (w) return `W ${shortRef(w)}`;
|
||||||
|
const l = state.bracket.find(f => f.loserFeeds === m.id && f.loserFeedsSide === wantSide);
|
||||||
|
if (l) return `L ${shortRef(l)}`;
|
||||||
|
return 'TBD';
|
||||||
|
}
|
||||||
|
const shortRef = m => m.stage === 'losers' ? `LB${m.round}.${m.slot}` : m.stage === 'final' ? 'final' : `R${m.round}.${m.slot}`;
|
||||||
|
|
||||||
|
function bracketList(stage, ms) {
|
||||||
|
const rounds = [...new Set(ms.map(m => m.round))].sort((a, b) => a - b);
|
||||||
|
return rounds.map(r => `<div class="blist"><div class="rlbl">${roundName(stage, r, rounds.length, ms)}</div>${ms.filter(m => m.round === r).map(m => `<div class="bm list ${m.status} ${isMine(m) ? 'mine' : ''}">${['a', 'b'].map((k, i) => { const t = m[k]; const won = m.winner && t && m.winner === t.id; return `<div class="brow ${won ? 'won' : ''} ${t && isMineId(t.id) ? 'mine' : ''}"><span class="bn">${t ? name(t) : `<span class="tbd">${feedLabel(m, i)}</span>`}</span><span class="bs mono">${m.sets[0] ? m.sets.map(s => s[i]).join(' ') : (m.live && m.status === 'live' ? m.live[i] : '')}</span></div>`; }).join('')}${m.status === 'live' ? `<span class="btag live">Court ${m.court}</span>` : m.status === 'on_deck' ? `<span class="btag">On deck${m.court ? ` · C${m.court}` : ''}</span>` : m.status === 'forfeit' ? '<span class="btag">Forfeit</span>' : m.label ? `<span class="btag">${esc(m.label)}</span>` : ''}</div>`).join('')}</div>`).join('');
|
||||||
|
}
|
||||||
|
const isMineId = id => myTeam && id === myTeam;
|
||||||
|
|
||||||
|
function fitBracket() {
|
||||||
|
if (bracketView !== 'fit') return;
|
||||||
|
document.querySelectorAll('.btree-scroll').forEach(sc => {
|
||||||
|
const tree = sc.firstElementChild;
|
||||||
|
const w = parseFloat(tree.style.width), h = parseFloat(tree.style.height);
|
||||||
|
const scale = Math.min(1, (sc.clientWidth - 2) / w);
|
||||||
|
tree.style.transform = `scale(${scale})`;
|
||||||
|
tree.style.transformOrigin = '0 0';
|
||||||
|
sc.style.height = `${h * scale}px`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
const b = e.target.closest('[data-bview]');
|
||||||
|
if (!b) return;
|
||||||
|
bracketView = b.dataset.bview;
|
||||||
|
try { localStorage.setItem('courtside.bracketView', bracketView); } catch {}
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
window.addEventListener('resize', fitBracket);
|
||||||
|
|
||||||
function roundName(stage, r, total, ms) {
|
function roundName(stage, r, total, ms) {
|
||||||
if (stage === 'final') return r === 1 ? 'Grand final' : 'Reset';
|
if (stage === 'final') return r === 1 ? 'Grand final' : 'Bracket reset';
|
||||||
if (stage === 'losers') return `LB round ${r}`;
|
if (stage === 'losers') return `LB round ${r}`;
|
||||||
const left = total - r;
|
const left = total - r;
|
||||||
const inRound = ms.filter(m => m.round === r && m.label !== '3rd place').length;
|
const inRound = ms.filter(m => m.round === r && m.label !== '3rd place').length;
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ export function buildRouter({ registry, auth, store }) {
|
|||||||
if (state.phase === 'checkin' && q(req).get('new')) return send.html(res, pub.registeredPage(state, team, origin(req)));
|
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 }));
|
send.html(res, pub.boardPage(state, { teamId: team.id }));
|
||||||
});
|
});
|
||||||
|
r.get('/t/:slug/teams/:id', (req, res, { slug, id }) => {
|
||||||
|
const it = item(slug);
|
||||||
|
if (!it.t.teams.has(id)) throw new HttpError(404, 'No such team');
|
||||||
|
send.html(res, pub.boardPage(publicState(it), { teamId: id }));
|
||||||
|
});
|
||||||
r.post('/t/:slug/register', async (req, res, { slug }) => {
|
r.post('/t/:slug/register', async (req, res, { slug }) => {
|
||||||
const it = item(slug);
|
const it = item(slug);
|
||||||
const body = await readBody(req);
|
const body = await readBody(req);
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function layout({ title, body, state = null, script = null, bodyClass = '
|
|||||||
<main>
|
<main>
|
||||||
${body}
|
${body}
|
||||||
</main>
|
</main>
|
||||||
<footer class="foot">Courtside v${VERSION} · self-hosted tournament desk · <a href="https://gitea.cloudfreeiot.com/bkvargyas/courtside">source</a></footer>
|
<footer class="foot">Courtside v${VERSION} · self-hosted tournament desk · <a href="https://gitea.cloudfreeiot.com/bkvargyas/courtside">source</a> · <a href="/admin">organizer desk</a></footer>
|
||||||
${state ? `<script id="state" type="application/json">${JSON.stringify(state).replace(/</g, '\\u003c')}</script>` : ''}
|
${state ? `<script id="state" type="application/json">${JSON.stringify(state).replace(/</g, '\\u003c')}</script>` : ''}
|
||||||
${script ? `<script src="${esc(script)}" defer></script>` : ''}
|
${script ? `<script src="${esc(script)}" defer></script>` : ''}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user