56 lines
8.9 KiB
TypeScript
56 lines
8.9 KiB
TypeScript
import { api, query, user } from './api';
|
|
import { click, element, escape, field, go, submit } from './ui';
|
|
import type { Catalogs, League, LeagueView } from './types';
|
|
export async function leaguesPage(signal: AbortSignal) {
|
|
const leagues = await api<League[]>('leagues', undefined, signal);
|
|
if (signal.aborted)
|
|
return;
|
|
element('#page').innerHTML = `<div class="page-heading"><div><p class="eyebrow">2026 midterms</p><h1>My leagues</h1></div><div class="actions"><a class="button secondary" href="#join">Join a league</a><a class="button" href="#create">Create a league</a></div></div>${leagues.length ? `<div class="league-grid">${leagues.map(l => `<a class="panel league-card" href="#league/${l.id}"><span class="badge">${l.catalog_mode === 'demo' ? 'Practice · ' : ''}${escape(l.status)}</span><h2>${escape(l.name)}</h2><p>${escape(l.team_name)}</p><span class="muted">${l.members} / ${l.max_teams} teams</span><span class="arrow" aria-hidden="true">↗</span></a>`).join('')}</div>` : '<section class="empty"><h2>Start with your people.</h2><p>Create a private league, then invite your friends. You can get everyone together while election data is being prepared.</p></section>'}`;
|
|
}
|
|
export async function createPage(signal: AbortSignal) {
|
|
const catalogs = await api<Catalogs>('catalogs', undefined, signal);
|
|
if (signal.aborted)
|
|
return;
|
|
element('#page').innerHTML = `<a href="#leagues" class="back">← My leagues</a><h1>Create a league</h1><section class="panel narrow"><form id="create">${field('League name', 'name', '', 'maxlength="80"')}${field('Your team name', 'team_name', '', 'maxlength="60"')}<label>Maximum teams<select name="max_teams">${Array.from({ length: 11 }, (_, i) => `<option ${i === 6 ? 'selected' : ''}>${i + 2}</option>`).join('')}</select></label>${catalogs.allow_demo ? '<label>Candidate pool<select name="catalog_id"><option value="">Latest real candidates</option>' + catalogs.catalogs.filter(c => c.mode === 'demo').map(c => `<option value="${escape(c.id)}">Practice · ${escape(c.label)}</option>`).join('') + '</select></label>' : ''}<p class="muted">Private by default. Twelve picks per team. The draft opens when the candidate pool, ratings, and result sources are ready.</p><button type="submit">Create league</button></form></section>`;
|
|
submit('#create', async (data) => { const result = await api<{
|
|
league_id: string;
|
|
}>('league/create', { name: data.get('name'), team_name: data.get('team_name'), max_teams: Number(data.get('max_teams')), catalog_id: data.get('catalog_id') || null }); go(`league/${result.league_id}`); });
|
|
}
|
|
export function joinPage(invite = '') {
|
|
element('#page').innerHTML = `<a href="#leagues" class="back">← My leagues</a><h1>Join a league</h1><section class="panel narrow"><form id="join">${field('Invitation link or code', 'invite', invite, 'autocomplete="off"')}${field('Your team name', 'team_name', '', 'maxlength="60"')}<button type="submit">Join league</button></form></section>`;
|
|
submit('#join', async (data) => { const code = String(data.get('invite')).split('#join/').pop()!.trim(); const result = await api<{
|
|
league_id: string;
|
|
}>('league/join', { invite: code, team_name: data.get('team_name') }); go(`league/${result.league_id}`); });
|
|
}
|
|
export async function leaguePage(id: string, signal: AbortSignal) {
|
|
const view = await api<LeagueView>(query('league', { league_id: id }), undefined, signal);
|
|
if (signal.aborted)
|
|
return;
|
|
const l = view.league, commissioner = l.commissioner_id === user?.id;
|
|
element('#page').innerHTML = `<a href="#leagues" class="back">← My leagues</a><div class="page-heading"><div><p class="eyebrow">${l.catalog_mode === 'demo' ? 'Practice · fictional candidates · ' : ''}${escape(l.status)}</p><h1>${escape(l.name)}</h1></div><a class="button" href="#draft/${id}">${l.status === 'forming' ? 'Draft room' : l.status === 'drafted' ? 'View draft' : 'Enter draft'}</a></div><nav class="subnav"><a href="#roster/${id}">My roster</a><a href="#standings/${id}">Standings</a><a href="#results/${id}">Race results</a></nav>${l.status === 'forming' && !view.readiness.ready ? `<aside class="notice"><strong>Getting ready to draft</strong><ul>${view.readiness.problems.map(p => `<li>${escape(p)}</li>`).join('')}</ul></aside>` : ''}<section class="panel"><div class="page-heading"><h2>Teams <span class="muted">${view.teams.length} / ${l.max_teams}</span></h2>${commissioner && l.status === 'forming' ? '<button id="invite">Invite friends</button>' : ''}</div><div id="invite-link"></div><ul class="team-list">${view.teams.map(t => `<li><span><strong>${escape(t.name)}</strong><small>${escape(t.username)}${t.user_id === l.commissioner_id ? ' · commissioner' : ''}</small></span>${commissioner && t.user_id !== user?.id && l.status === 'forming' ? `<button class="quiet remove" data-team="${t.id}">Remove</button>` : ''}</li>`).join('')}</ul></section><details class="panel"><summary>Team settings</summary><form id="rename">${field('Team name', 'name', view.my_team.name, 'maxlength="60"')}<button type="submit">Save team name</button></form></details>${commissioner ? `<details class="panel"><summary>League name</summary><form id="league-name">${field('League name','name',l.name,'maxlength="80"')}<button type="submit">Save league name</button></form></details>` : ''}${commissioner && l.status === 'forming' ? '<details class="panel" id="commissioner"><summary>Commissioner settings</summary><div id="settings">Loading settings…</div><form id="ratings-import"><label>Import sourced ratings (JSON)<input name="ratings" type="file" accept=".json,application/json" required></label><p class="muted">Use the ratings schema in the data contract. Include a source URL, retrieval date, and a new snapshot ID.</p><button type="submit">Import ratings</button></form></details>' : ''}`;
|
|
const reload = () => leaguePage(id, signal);
|
|
if (commissioner) submit('#league-name', async data => { await api('league/name', {league_id:id, name:data.get('name')}); await reload(); });
|
|
submit('#rename', async (data) => { await api('team/name', { league_id: id, name: data.get('name') }); await reload(); });
|
|
click('#invite', async () => { const result = await api<{
|
|
invite: string;
|
|
}>('league/invite', { league_id: id }); const url = `${location.origin}${location.pathname}#join/${result.invite}`; element('#invite-link').innerHTML = `<label>Share this private link · expires in 7 days<input readonly value="${escape(url)}" aria-label="Invitation link"></label><p class="muted">Creating another invitation replaces this one.</p>`; });
|
|
document.querySelectorAll<HTMLButtonElement>('.remove').forEach(button => click(`[data-team="${button.dataset.team}"]`, async () => { await api('league/remove', { league_id: id, team_id: button.dataset.team }); await reload(); }));
|
|
if (commissioner && l.status === 'forming') {
|
|
submit('#ratings-import', async (form) => { const file = form.get('ratings'); if (!(file instanceof File) || file.size > 60000)
|
|
throw new Error('Choose a ratings JSON file under 60 KB.'); const document = JSON.parse(await file.text()); await api('league/ratings', { league_id: id, document }); await reload(); });
|
|
const data = await api<Catalogs>('catalogs', undefined, signal);
|
|
if (signal.aborted)
|
|
return;
|
|
element('#settings').innerHTML = `<form id="settings-form">${field('League name', 'name', l.name, 'maxlength="80"')}<label>Candidate catalog<select name="catalog_id" required>${data.catalogs.map(c => `<option value="${escape(c.id)}" ${c.id === l.catalog_id ? 'selected' : ''}>${escape(c.label)}</option>`).join('')}</select></label><label>Race ratings<select name="ratings_id"><option value="">Awaiting sourced ratings</option>${data.ratings.map(r => `<option value="${escape(r.id)}" data-catalog="${escape(r.catalog_id)}" ${r.id === l.ratings_id ? 'selected' : ''}>${escape(r.label)}</option>`).join('')}</select></label><p class="muted">Catalog and ratings freeze when the draft starts. Changing these settings clears player queues.</p><button type="submit">Save league settings</button></form><button class="quiet" id="revoke">Revoke invitations</button>`;
|
|
const filterRatings = () => { const catalog = element<HTMLSelectElement>('[name=catalog_id]').value; const ratings = element<HTMLSelectElement>('[name=ratings_id]'); for (const option of ratings.options) {
|
|
option.hidden = !!option.dataset.catalog && option.dataset.catalog !== catalog;
|
|
if (option.selected && option.hidden)
|
|
ratings.value = '';
|
|
} };
|
|
element('[name=catalog_id]').addEventListener('change', filterRatings);
|
|
filterRatings();
|
|
submit('#settings-form', async (form) => { await api('league/settings', { league_id: id, ...Object.fromEntries(form), ratings_id: form.get('ratings_id') || null }); await reload(); });
|
|
click('#revoke', async () => { await api('league/revoke-invites', { league_id: id }); element('#invite-link').textContent = 'Invitations revoked.'; });
|
|
}
|
|
}
|