// Main SPA entry point with routing // Code splitting: all pages are loaded on-demand via dynamic import() to keep // the initial bundle small. The app entry chunk contains only the router, // navigation, lazy-loading wrappers — no page renderers. // §16.14: preload-on-hover, skeleton screens, instant back-cache import { router } from './router'; import type { RouteHandler } from './router'; import { initPerformanceFeatures, savePageCache, restorePageFromCache, hasPageCache, fadeInContent, } from './lib/preload'; import { initAmbient, startAmbientPolling, applyCurrentSeasonTheme, } from './lib/ambient'; import { skeletonLeaderboard, skeletonBotProfile, skeletonReplay, skeletonPlaylists, skeletonMatches, skeletonEvolution, skeletonBlog, skeletonSeasons, skeletonGeneric, } from './components/skeleton'; // ─── Skeleton route mapping ────────────────────────────────────────────────── // Returns skeleton HTML for a given path so the user sees layout immediately. function getSkeletonHtml(path: string): string { if (path === '/leaderboard' || path === '/bots') return skeletonLeaderboard(); if (path.startsWith('/bot/') || path.startsWith('/compete/bot/')) return skeletonBotProfile(); if (path.startsWith('/watch/replay') || path.startsWith('/replay/') || path.startsWith('/embed/')) return skeletonReplay(); if (path.startsWith('/watch/playlists')) return skeletonPlaylists(); if (path === '/watch/replays' || path === '/matches') return skeletonMatches(); if (path === '/evolution') return skeletonEvolution(); if (path.startsWith('/blog')) return skeletonBlog(); if (path === '/seasons' || path.startsWith('/season/')) return skeletonSeasons(); if (path === '/rivalries' || path.startsWith('/rivalry/')) return skeletonGeneric('Rivalries'); if (path === '/watch/predictions' || path === '/predictions') return skeletonGeneric('Predictions'); if (path === '/watch') return skeletonGeneric('Watch'); if (path === '/') return ''; // Home page has its own rich skeleton built in return skeletonGeneric('Loading'); } // ─── Lazy loaders for code splitting ───────────────────────────────────────────── // Each loader creates its own chunk, loaded only when the route is visited // Core pages - loaded frequently const loadHomePage = () => import('./pages/home').then(m => m.renderHomePage); const loadLeaderboardPage = () => import('./pages/leaderboard').then(m => m.renderLeaderboardPage); // Watch section - replay viewer and related pages const loadMatchesPage = () => import('./pages/matches').then(m => m.renderMatchesPage); const loadPlaylistsPage = () => import('./pages/playlists').then(m => m.renderPlaylistsPage); const loadSeriesPage = () => import('./pages/series').then(m => m.renderSeriesPage); const loadPredictionsPage = () => import('./pages/predictions').then(m => m.renderPredictionsPage); const loadReplayPage = () => import('./pages/replay').then(m => m.renderReplayPage); const loadWatchHubPage = () => import('./pages/watch-hub').then(m => m.renderWatchHubPage); // Compete section - sandbox, register, docs const loadSandboxPage = () => import('./pages/sandbox').then(m => m.renderSandboxPage); const loadRegisterPage = () => import('./pages/register').then(m => m.renderRegisterPage); const loadCompeteHubPage = () => import('./pages/compete-hub').then(m => m.renderCompeteHubPage); const loadDocsPage = () => import('./pages/docs').then(m => m.renderDocsPage); // Bot-related pages const loadBotProfilePage = () => import('./pages/bot-profile').then(m => m.renderBotProfilePage); const loadEvolutionPage = () => import('./pages/evolution').then(m => m.renderEvolutionPage); // Blog & seasons const loadBlogPages = () => import('./pages/blog').then(m => ({ renderBlogPage: m.renderBlogPage, renderBlogPostPage: m.renderBlogPostPage })); const loadSeasonsPage = () => import('./pages/seasons').then(m => m.renderSeasonsPage); const loadSeasonDetailPage = () => import('./pages/season-detail').then(m => m.renderSeasonDetailPage); // Feedback & docs (separate chunk - includes replay viewer for feedback page) // Feedback page lazy-loads with agentation (loaded on /#/feedback or explicit enable) // Agentation is NOT imported here — only loaded when feedback page is visited const loadFeedbackPage = () => import('./pages/feedback').then(async m => { const { initAgentation } = await import('./agentation-overlay'); initAgentation(); return m.renderFeedbackPage; }); // Docs API page (separate chunk from compete docs) const loadDocsApiPage = () => import('./pages/docs-api').then(m => m.renderDocsApiPage); // Rivalries page (pre-computed from index builder §13.5) const loadRivalriesPage = () => import('./pages/rivalries').then(m => m.renderRivalriesPage); // Individual rivalry page (§13.5) const loadRivalryPage = () => import('./pages/rivalry').then(m => m.renderRivalryPage); // Embed page (minimal replay viewer for iframe embedding §13.4) const loadEmbedPage = () => import('./pages/embed').then(m => m.renderEmbedPage); // 404 const loadNotFoundPage = () => import('./pages/not-found').then(m => m.renderNotFoundPage); // ─── Helper: wrap async page loader in sync RouteHandler ──────────────────────── // Shows skeleton immediately, then loads the real page async with fade-in. function lazyRoute(loader: () => Promise<(params: Record) => void>): RouteHandler { return (params: Record) => { const targetPath = router.getCurrentPath(); // Check back-cache for instant restore if (hasPageCache(targetPath)) { restorePageFromCache(targetPath); return; } // Show skeleton immediately while loading the chunk const skeleton = getSkeletonHtml(targetPath); if (skeleton) { const app = document.getElementById('app'); if (app) app.innerHTML = skeleton; } loader().then(handler => { handler(params); // Fade in real content over the skeleton const app = document.getElementById('app'); if (app && skeleton) fadeInContent(app); }); }; } // ─── Backwards compatibility redirects ──────────────────────────────────────────── function redirect(to: string): RouteHandler { return (params: Record) => { const fullPath = Object.entries(params).reduce( (path, [key, value]) => path.replace(`:${key}`, encodeURIComponent(value)), to ); router.navigate(fullPath); }; } // ─── Navigation & UI ─────────────────────────────────────────────────────────────── function updateActiveNavLink(): void { const currentPath = router.getCurrentPath(); document.querySelectorAll('.nav-link').forEach(link => { link.classList.remove('active'); }); document.querySelectorAll('.nav-link').forEach(link => { const href = link.getAttribute('href'); if (href) { const linkPath = href.slice(2); if (currentPath === linkPath || (linkPath !== '' && currentPath.startsWith(linkPath)) || (linkPath === '/watch' && currentPath.startsWith('/watch')) || (linkPath === '/compete' && currentPath.startsWith('/compete'))) { link.classList.add('active'); } } }); } function initMobileMenu(): void { const toggle = document.getElementById('mobile-menu-toggle'); const menu = document.getElementById('mobile-menu'); if (!toggle || !menu) return; toggle.addEventListener('click', () => { menu.classList.toggle('open'); }); document.addEventListener('click', (e) => { if (!menu.contains(e.target as Node) && !toggle.contains(e.target as Node)) { menu.classList.remove('open'); } }); const originalNavigate = router.navigate.bind(router); router.navigate = (path: string) => { originalNavigate(path); menu.classList.remove('open'); }; } initMobileMenu(); const originalNavigate = router.navigate.bind(router); router.navigate = (path: string) => { originalNavigate(path); updateActiveNavLink(); }; // ─── Back-cache: save current page before navigating away ────────────────────── router.beforeNavigate((from: string, to: string) => { // Only cache pages that have rendered content (not initial load) if (from && from !== '/') { savePageCache(from); } // §16.13: PIP replay — if leaving a replay page with active playback, // activate the mini-player instead of destroying the viewer. const leavingReplay = from.match(/^\/watch\/replay/) || from.match(/^\/replay\//); const goingToReplay = to.match(/^\/watch\/replay/) || to.match(/^\/replay\//); if (leavingReplay && !goingToReplay) { import('./components/pip-registry').then(({ getActiveReplay }) => { import('./components/pip').then(({ activatePip, isPipActive }) => { const replay = getActiveReplay(); if (replay && replay.canvas && replay.canvasWrapper && !isPipActive()) { activatePip({ matchId: replay.matchId, canvas: replay.canvas, originalParent: replay.canvasWrapper, getScoreText: replay.getScoreText, getTurn: replay.getTurn, getTotalTurns: replay.getTotalTurns, getIsPlaying: replay.getIsPlaying, togglePlay: replay.togglePlay, onReturn: () => { router.navigate(from); }, onClose: () => { replay.pause(); import('./components/pip').then(({ closePip }) => closePip()); import('./components/pip-registry').then(({ setActiveReplay }) => setActiveReplay(null)); }, }); } }); }); } // Cleanup VirtualList instances to prevent leaked ResizeObservers const app = document.getElementById('app'); if (app) { app.querySelectorAll('[data-virtual-list]').forEach(el => { const vl = (el as any)._virtualList; if (vl && typeof vl.destroy === 'function') vl.destroy(); }); } }); // ─── Route definitions ───────────────────────────────────────────────────────────── router // Main routes .on('/', lazyRoute(loadHomePage)) .on('/watch', lazyRoute(loadWatchHubPage)) .on('/watch/replays', lazyRoute(loadMatchesPage)) .on('/watch/playlists', lazyRoute(loadPlaylistsPage)) .on('/watch/playlists/:slug', lazyRoute(loadPlaylistsPage)) .on('/watch/replay', lazyRoute(loadReplayPage)) .on('/watch/replay/:id', lazyRoute(loadReplayPage)) .on('/watch/series/:id', lazyRoute(loadSeriesPage)) .on('/watch/predictions', lazyRoute(loadPredictionsPage)) .on('/watch/series', lazyRoute(loadSeriesPage)) .on('/compete', lazyRoute(loadCompeteHubPage)) .on('/compete/sandbox', lazyRoute(loadSandboxPage)) .on('/compete/register', lazyRoute(loadRegisterPage)) .on('/compete/bot/:id', lazyRoute(loadBotProfilePage)) .on('/compete/docs', lazyRoute(loadDocsPage)) .on('/leaderboard', lazyRoute(loadLeaderboardPage)) .on('/evolution', lazyRoute(loadEvolutionPage)) .on('/blog', lazyRoute(async () => (await loadBlogPages()).renderBlogPage)) .on('/blog/:slug', lazyRoute(async () => (await loadBlogPages()).renderBlogPostPage)) .on('/season/:id', lazyRoute(loadSeasonDetailPage)) .on('/seasons', lazyRoute(loadSeasonsPage)) .on('/bot/:id', lazyRoute(loadBotProfilePage)) // Backwards compatibility redirects .on('/matches', redirect('/watch/replays')) .on('/playlists', redirect('/watch/playlists')) .on('/replay', redirect('/watch/replay')) .on('/predictions', redirect('/watch/predictions')) .on('/series', redirect('/watch/series')) .on('/sandbox', redirect('/compete/sandbox')) .on('/register', redirect('/compete/register')) .on('/bots', redirect('/leaderboard')) .on('/docs/api', lazyRoute(loadDocsApiPage)) .on('/docs', redirect('/compete/docs')) .on('/clip-maker', redirect('/watch/replays')) .on('/rivalries', lazyRoute(loadRivalriesPage)) .on('/feedback', lazyRoute(loadFeedbackPage)) .on('/compete/feedback', lazyRoute(loadFeedbackPage)) .on('/compete/docs/api', lazyRoute(loadDocsApiPage)) .on('/rivalries', lazyRoute(loadRivalriesPage)) .on('/rivalry/:bot_a/:bot_b', lazyRoute(loadRivalryPage)) .on('/embed/:id', lazyRoute(loadEmbedPage)) .notFound(lazyRoute(loadNotFoundPage)); // ─── Initialization ──────────────────────────────────────────────────────────────── document.addEventListener('DOMContentLoaded', () => { updateActiveNavLink(); router.start(); // §16.14: activate hover preloading initPerformanceFeatures(); // §16.18: ambient activity awareness (favicon badges, tab title, seasonal theme) initAmbient(); applyCurrentSeasonTheme(); }); window.addEventListener('load', () => { updateActiveNavLink(); // §16.18: start polling for ambient activity after page fully loaded startAmbientPolling(); });