1/* Section router for the signed-in shell. 2 * 3 * Owns #app-content. Sections register themselves on window.TDTT.appSections 4 * as { mount(el), unmount() } and never touch the container outside their 5 * own mount. (title/pillar are NOT part of this contract -- this file reads 6 * those from routes.SECTION_META, not off the section module.) 7 * 8 * Two obligations on every section, both load-bearing: 9 * - unmount() is MANDATORY, not optional. mount() runs again every time the 10 * router returns to this section, and the router only empties 11 * #app-content's innerHTML -- it has no way to know about any 12 * document-level listener (keydown, timers, etc.) a section attached 13 * outside the container, so unmount() is the section's only chance to 14 * remove one. 15 * - a section that fetches must sequence-guard the response: capture 16 * `var mine = ++seq` before the request, early-return in BOTH `.then` 17 * and `.catch` when `seq` has since moved on, and bump `seq` again in 18 * unmount(). This is not defensive boilerplate -- a real stale-response 19 * bug was found and fixed here: navigating away and back fast enough let 20 * an older in-flight response overwrite the fresh remount's data. 21 * 22 * Every routing DECISION (which section a URL means) lives in the generated 23 * /shared/app-routes.js, which is unit-tested; this file is the DOM and 24 * History plumbing around it: resolving the URL, mounting/unmounting 25 * sections, and pushState/popstate. It does NOT wire the rail's clicks or 26 * light its active item -- app-shell.js renders the rail from the shared 27 * nav table, so app-shell.js also owns that rail's behaviour end to end. */ 28(function () { 29 'use strict'; 30 31 var routes = window.TDTT && window.TDTT.appRoutes; 32 if (!routes) throw new Error('router.js requires /shared/app-routes.js to be loaded first'); 33 34 var contentEl = null; 35 var current = null; // the mounted section id, or null 36 37 // Duplicated one-liner, same convention as profile-render.js's copy of 38 // leaderboard.js's helpers -- not worth a shared module for this. 39 function esc(s) { 40 return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { 41 return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; 42 }); 43 } 44 45 function sections() { 46 return (window.TDTT && window.TDTT.appSections) || {}; 47 } 48 49 function setPillar(name) { 50 document.body.style.setProperty('--sec-accent', 'var(--p-' + name + ')'); 51 } 52 53 function setHeader(meta) { 54 var t = document.getElementById('app-title'); 55 var e = document.getElementById('app-eyebrow'); 56 if (t) t.textContent = meta.title; 57 if (e) e.textContent = meta.pillar.charAt(0).toUpperCase() + meta.pillar.slice(1); 58 setDocumentTitle(meta.title); 59 } 60 61 // Per-section tab/history title. Never on the domain root: '/' is the public 62 // landing URL and keeps its server-injected title for crawlers that render JS. 63 function setDocumentTitle(title) { 64 var p = window.location.pathname; 65 if (!p || p === '/') return; 66 document.title = title + ' · Top Down Tabletop'; 67 } 68 69 function unmountCurrent() { 70 // The typeof check is a defensive safety net against a malformed section 71 // module, not permission to skip unmount() -- every real section MUST 72 // define one (see the header comment above). 73 if (current && sections()[current] && typeof sections()[current].unmount === 'function') { 74 try { 75 sections()[current].unmount(); 76 } catch (e) { 77 /* a section that throws on the way out must not strand the shell */ 78 } 79 } 80 current = null; 81 if (contentEl) contentEl.innerHTML = ''; 82 } 83 84 function renderMissing(section) { 85 contentEl.innerHTML = 86 '<div class="lb-status lb-empty"><p class="lb-empty-title">Coming soon</p>' + 87 '<p class="lb-empty-sub">The ' + routes.SECTION_META[section].title + 88 ' section is not built yet.</p></div>'; 89 } 90 91 function mount(section) { 92 var mod = sections()[section]; 93 if (!mod) return renderMissing(section); 94 try { 95 mod.mount(contentEl); 96 current = section; 97 } catch (e) { 98 // Leave `current` unset (not the throwing section) on this path: render() 99 // no-ops when `section === current`, so setting it here would make 100 // re-clicking the same link after a mount failure do nothing -- the only 101 // way to retry would be navigating away and back. Leaving it null means 102 // the next render() (e.g. clicking the same nav link again) treats this 103 // as a fresh mount and actually retries. 104 current = null; 105 contentEl.innerHTML = 106 '<div class="lb-status lb-error"><p class="lb-error-title">Something went wrong</p>' + 107 '<p class="lb-error-detail">' + esc((e && e.message) || e) + '</p></div>'; 108 } 109 } 110 111 function signInHtml() { 112 return ( 113 '<div class="app-signin">' + 114 '<h2 class="app-signin-title">Sign in</h2>' + 115 '<p class="app-signin-sub">This is your signed-in dashboard. Sign in to see your ' + 116 'profile, leaderboards, Houses and local shops.</p>' + 117 '<form id="app-signin-form" autocomplete="on">' + 118 '<label class="app-signin-label" for="app-signin-user">Email or username</label>' + 119 '<input class="app-signin-input" id="app-signin-user" name="username" autocomplete="username" required>' + 120 '<label class="app-signin-label" for="app-signin-pass">Password</label>' + 121 '<input class="app-signin-input" id="app-signin-pass" name="password" type="password" autocomplete="current-password" required>' + 122 '<p class="app-signin-err" id="app-signin-err" hidden></p>' + 123 '<button class="lb-btn app-signin-btn" type="submit" id="app-signin-btn">Sign in</button>' + 124 '</form>' + 125 '<p class="app-signin-alt">No account? <a href="/m/register.html">Create one</a>.</p>' + 126 '<div class="app-signin-extra">' +
127 '<a class="lb-btn app-signin-dl" href="/downloads/TopDownTabletop-Setup.exe" download>Download for Windows</a>' + 128 '<a class="app-signin-about" href="/about">About Top Down Tabletop</a>' + 129 '</div>' + 130 '</div>' 131 ); 132 } 133 134 /* Gated in place rather than redirected: the only sign-in UI that exists is 135 * the phone site's modal, and bouncing a desktop user to /m and back is a 136 * worse path than signing in where they already are. */ 137 function renderSignIn() { 138 unmountCurrent(); 139 contentEl.innerHTML = signInHtml(); 140 setHeader({ title: 'Sign in', pillar: 'colosseum' }); 141 142 var form = document.getElementById('app-signin-form'); 143 form.addEventListener('submit', function (ev) { 144 ev.preventDefault(); 145 var btn = document.getElementById('app-signin-btn'); 146 var err = document.getElementById('app-signin-err'); 147 var user = document.getElementById('app-signin-user').value.trim(); 148 var pass = document.getElementById('app-signin-pass').value; 149 err.hidden = true; 150 btn.disabled = true; 151 btn.textContent = 'Signing inâ¦'; 152 window.BFAuth.loginWithBoardForge(user, pass) 153 .then(function () { 154 window.location.reload(); // simplest correct refresh of chip + section 155 }) 156 .catch(function (e) { 157 btn.disabled = false; 158 btn.textContent = 'Sign in'; 159 err.hidden = false; 160 err.textContent = (e && e.message) || 'Sign in failed.'; 161 }); 162 }); 163 } 164 165 function isSignedIn() { 166 return !!(window.BFAuth && window.BFAuth.isLoggedIn && window.BFAuth.isLoggedIn()); 167 } 168 169 function render() { 170 var r = routes.resolveAppRoute(window.location.pathname, window.location.hash); 171 var section = r.section; 172 173 // A pre-2026-09-09 '#profile' bookmark: honour it, then rewrite the URL to 174 // the clean form so the address bar matches what is on screen. 175 if (r.legacyHash) { 176 window.history.replaceState({ section: section }, '', routes.sectionToPath(section)); 177 } else if (window.location.pathname === '/app' || window.location.pathname === '/app/') { 178 // Only bare /app is rewritten to its clean section URL. The domain root 179 // '/' renders home in place and keeps the URL as '/' (no /app/home push). 180 window.history.replaceState({ section: section }, '', routes.sectionToPath(section)); 181 } 182 183 if (!isSignedIn()) return renderSignIn(); 184 185 setPillar(routes.SECTION_META[section].pillar); 186 setHeader(routes.SECTION_META[section]); 187 // No setActive() call here: the rail is rendered by app-shell.js from the 188 // shared nav table, and app-shell.js lights its own active item on every 189 // navigation (it listens for the same route change this render() reacts 190 // to). This file only owns section resolution, mount/unmount and header. 191 if (section === current) return; // already mounted; nothing to swap 192 unmountCurrent(); 193 mount(section); 194 } 195 196 // `search` is an optional query string (e.g. '?game=MTG'), leading '?' 197 // included, appended verbatim after sectionToPath(section). Callers that 198 // omit it (every rail/nav link) get byte-identical behaviour to before â 199 // this only exists so a link like Home's Featured Games tiles 200 // (/app/codex?game=MTG) can survive the client-side intercept instead of 201 // being rebuilt from the bare section id and losing its query entirely. 202 // render() resolves the section from pathname+hash only, so appending a 203 // search string here does not change what mounts, only what the URL (and 204 // codex.js's own readGameFromUrl(), reading location.search) sees. 205 function go(section, search) { 206 if (!routes.isAppSection(section)) return; 207 // Signed-out sidebar/rail links must be inert: render() would just 208 // re-render the sign-in card again, discarding anything the visitor has 209 // already typed into the form. 210 if (!isSignedIn()) return; 211 var path = routes.sectionToPath(section) + (search || ''); 212 window.history.pushState({ section: section }, '', path); 213 render(); 214 } 215 216 contentEl = document.getElementById('app-content'); 217 if (contentEl) { 218 // No wireNav() call here: app-shell.js binds clicks on the rail it 219 // renders, calling window.TDTTRouter.go() below for the actual 220 // navigation. This file only needs its own popstate listener + the 221 // initial render. 222 window.addEventListener('popstate', render); 223 render(); 224 // T12 (2026-09-27 security hardening): the Terms re-acceptance gate, 225 // checked once at boot (signed-in only, no-op otherwise). It also opens 226 // itself on demand -- see terms-gate.js's own header comment -- so this 227 // is only the one-time "did the version change since last visit" check. 228 if (window.TDTTTermsGate && typeof window.TDTTTermsGate.checkAtBoot === 'function') { 229 window.TDTTTermsGate.checkAtBoot(); 230 } 231 } 232 233 window.TDTTRouter = { 234 go: go, 235 current: function () { return current; }, 236 refresh: render, 237 }; 238})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.