PageSourceSearch

https://topdowntabletop.com/app/router.js

js topdowntabletop.com collected 2026-10-08 09:16:45 UTC 10,861 bytes, 238 lines download raw bytes

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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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.