import { escapeHtml } from '../util.js'; import { logoLockup } from '../logo.js'; import { renderCornerRail } from '../cornerRail.js'; import { attachTooltips } from '../tooltip.js'; import { renderTopRail } from '../topRail.js'; import { api, ApiError } from '../api.js'; import { FLAIR_ICONS, CLOSE_ICON } from './icons.js'; const WORD_COUNTS = [10, 25, 50, 100]; const TIME_DURATIONS = [15, 30, 60, 120]; // Scoped to Words/Time only, not the open-ended quote categories - those // grow with the text pack and don't make a stable, comparable leaderboard // mode the way a fixed word count or time duration does. const MODES = [ ...TIME_DURATIONS.map(d => ({ key: `time-${d}`, label: `Time ${d}s` })), ...WORD_COUNTS.map(w => ({ key: `words-${w}`, label: `Words ${w}` })), ]; function formatDate(iso) { const t = Date.parse(iso); return Number.isNaN(t) ? '' : new Date(t).toLocaleDateString(); } export function renderLeaderboardScreen(root, { onBack, onShowPublicProfile, onShowStats, onShowPlaceholder, onShowAccount, onShowFriends, onShowMultiplayer, onShowStore }) { let selectedMode = MODES[1].key; // time-30, matches the app's own default mode let desktopOnly = false; function rowsMarkup(state) { if (state.status === 'loading') return `
Loading...
`; if (state.status === 'error') return `
Could not load the leaderboard - ${escapeHtml(state.message)}
`; if (state.entries.length === 0) return `
No results yet for this mode - be the first.
`; return `
${state.entries.map((e, i) => `
#${i + 1}
${e.is_bot ? `bot` : ''} ${e.device_type === 'mobile' ? `mobile` : ''}
${Math.round(e.wpm)} wpm
${Math.round(e.accuracy)}%
${formatDate(e.date)}
`).join('')}
`; } root.innerHTML = `
${logoLockup()}

Leaderboard

${MODES.map(m => ``).join('')}
`; root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onBack)); attachTooltips(root); const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends }); const resultsEl = root.querySelector('.leaderboard-results'); let requestToken = 0; async function load() { const token = ++requestToken; resultsEl.innerHTML = rowsMarkup({ status: 'loading' }); try { const params = new URLSearchParams({ mode: selectedMode }); if (desktopOnly) params.set('device', 'desktop'); const entries = await api.get(`/api/leaderboard?${params.toString()}`); if (token !== requestToken) return; // a newer mode pick already superseded this request resultsEl.innerHTML = rowsMarkup({ status: 'ok', entries }); // Scoped to resultsEl, not the whole root - attachTooltips // already ran once for the rest of the screen at render time, // and re-running it there would double-bind those elements' // listeners on every reload. attachTooltips(resultsEl); } catch (err) { if (token !== requestToken) return; const message = err instanceof ApiError ? err.message : 'network error'; resultsEl.innerHTML = rowsMarkup({ status: 'error', message }); } } root.querySelectorAll('.leaderboard-modes [data-mode]').forEach(btn => { btn.addEventListener('click', () => { selectedMode = btn.dataset.mode; root.querySelectorAll('.leaderboard-modes [data-mode]').forEach(b => b.classList.toggle('active', b === btn)); load(); }); }); const deviceFilterBtn = root.querySelector('[data-action="toggle-device-filter"]'); deviceFilterBtn.addEventListener('click', () => { desktopOnly = !desktopOnly; deviceFilterBtn.textContent = `Devices: ${desktopOnly ? 'Desktop only' : 'All'}`; // A filter that is doing something should look like it is. deviceFilterBtn.classList.toggle('active', desktopOnly); load(); }); // Delegated rather than attached per-row - resultsEl's rows get // replaced wholesale on every load(), so a listener attached to a // specific row would be gone the next time the mode changes. resultsEl.addEventListener('click', e => { const btn = e.target.closest('[data-username]'); if (btn) onShowPublicProfile(btn.dataset.username); }); load(); const cleanupRail = renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowAccount, onShowFriends, onShowMultiplayer, onShowStore }); return () => { cleanupTheme(); cleanupRail(); }; }