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('leagues', undefined, signal); if (signal.aborted) return; element('#page').innerHTML = `

2026 midterms

My leagues

${leagues.length ? `
${leagues.map(l => `${l.catalog_mode === 'demo' ? 'Practice · ' : ''}${escape(l.status)}

${escape(l.name)}

${escape(l.team_name)}

${l.members} / ${l.max_teams} teams
`).join('')}
` : '

Start with your people.

Create a private league, then invite your friends. You can get everyone together while election data is being prepared.

'}`; } export async function createPage(signal: AbortSignal) { const catalogs = await api('catalogs', undefined, signal); if (signal.aborted) return; element('#page').innerHTML = `← My leagues

Create a league

${field('League name', 'name', '', 'maxlength="80"')}${field('Your team name', 'team_name', '', 'maxlength="60"')}${catalogs.allow_demo ? '' : ''}

Private by default. Twelve picks per team. The draft opens when the candidate pool, ratings, and result sources are ready.

`; 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 = `← My leagues

Join a league

${field('Invitation link or code', 'invite', invite, 'autocomplete="off"')}${field('Your team name', 'team_name', '', 'maxlength="60"')}
`; 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(query('league', { league_id: id }), undefined, signal); if (signal.aborted) return; const l = view.league, commissioner = l.commissioner_id === user?.id; element('#page').innerHTML = `← My leagues

${l.catalog_mode === 'demo' ? 'Practice · fictional candidates · ' : ''}${escape(l.status)}

${escape(l.name)}

${l.status === 'forming' ? 'Draft room' : l.status === 'drafted' ? 'View draft' : 'Enter draft'}
${l.status === 'forming' && !view.readiness.ready ? `` : ''}

Teams ${view.teams.length} / ${l.max_teams}

${commissioner && l.status === 'forming' ? '' : ''}
    ${view.teams.map(t => `
  • ${escape(t.name)}${escape(t.username)}${t.user_id === l.commissioner_id ? ' · commissioner' : ''}${commissioner && t.user_id !== user?.id && l.status === 'forming' ? `` : ''}
  • `).join('')}
Team settings
${field('Team name', 'name', view.my_team.name, 'maxlength="60"')}
${commissioner ? `
League name
${field('League name','name',l.name,'maxlength="80"')}
` : ''}${commissioner && l.status === 'forming' ? '
Commissioner settings
Loading settings…

Use the ratings schema in the data contract. Include a source URL, retrieval date, and a new snapshot ID.

' : ''}`; 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 = `

Creating another invitation replaces this one.

`; }); document.querySelectorAll('.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', undefined, signal); if (signal.aborted) return; element('#settings').innerHTML = `
${field('League name', 'name', l.name, 'maxlength="80"')}

Catalog and ratings freeze when the draft starts. Changing these settings clears player queues.

`; const filterRatings = () => { const catalog = element('[name=catalog_id]').value; const ratings = element('[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.'; }); } }