// Halcyon Flight Control: application shell and routing. import { api, keyStore, onUnauthorized } from './api.js'; import { h, clear, toast } from './dom.js'; import { startStarfield } from './starfield.js'; import { statusOf } from './status.js'; import { authView } from './view-auth.js'; import { fleetView } from './view-fleet.js'; import { codeView } from './view-code.js'; import { uplinkView } from './view-uplink.js'; import { downlinkView } from './view-downlink.js'; const root = document.getElementById('app'); const TABS = [ ['fleet', 'FLEET'], ['code', 'CODE'], ['uplink', 'UPLINK'], ['downlink', 'DOWNLINK'], ]; const VIEWS = { fleet: fleetView, code: codeView, uplink: uplinkView, downlink: downlinkView }; const ORE_TAGS = { iron: 'FE', nickel: 'NI', ice: 'H2O', platinum: 'PT' }; const state = { info: null, me: null, market: null, ships: [], selected: null, tab: 'fleet' }; let shell = null; // { top, nav, select, main } const tabFromHash = () => { const t = location.hash.replace(/^#\/?/, ''); return VIEWS[t] ? t : 'fleet'; }; const ctx = { state, get ship() { return state.ships.find((s) => s.id === state.selected) ?? null; }, go(tab) { if (location.hash === `#/${tab}`) { state.tab = tab; renderMain(); } else location.hash = `#/${tab}`; }, selectShip(id, tab) { state.selected = id; try { localStorage.setItem('wh.ship', String(id)); } catch { /* ignore */ } renderNav(); if (tab) ctx.go(tab); else renderMain(); }, async refresh() { await loadAccount(); renderTop(); renderNav(); renderMain(); }, }; async function loadAccount() { const [me, ships, market, info] = await Promise.all([api.me(), api.ships(), api.market().catch(() => null), api.info().catch(() => state.info)]); Object.assign(state, { me, ships, market, info }); const remembered = Number(safeGet('wh.ship')); if (!ships.some((s) => s.id === state.selected)) { state.selected = ships.some((s) => s.id === remembered) ? remembered : (ships[0]?.id ?? null); } } function safeGet(k) { try { return localStorage.getItem(k); } catch { return null; } } function logout(message) { keyStore.clear(); Object.assign(state, { me: null, ships: [], selected: null }); shell = null; if (message) toast(message, 'err'); showAuth(); } function showAuth() { clear(root).append(authView({ onLogin: enter })); } async function enter() { try { await loadAccount(); } catch (e) { if (e.status !== 401) toast(`ERROR: ${e.message}`, 'err'); return logout(); } buildShell(); state.tab = tabFromHash(); renderTop(); renderNav(); renderMain(); } // ---- shell ------------------------------------------------------------------ function buildShell() { shell = { top: h('header', { class: 'topbar' }), nav: h('nav', { class: 'navbar', 'aria-label': 'Sections' }), main: h('main', { id: 'main', class: 'main', tabindex: '-1' }), }; clear(root).append( h('a', { class: 'skip', href: '#main' }, 'SKIP TO CONTENT'), shell.top, shell.nav, shell.main, h('footer', { class: 'foot' }, 'HALCYON INSTRUMENT & CONTROL // WORMHOLE LINK 1 KB/DAY // THE BELT IS SIMULATED ONCE A DAY'), ); } function renderTop() { if (!shell) return; const { me, info, market } = state; const prices = market && info?.ores ? info.ores.map((o, i) => h('span', { class: 'chip ore', title: `${o} price per kg` }, h('b', null, ORE_TAGS[o] ?? o.toUpperCase()), ` ${market[i]}`)) : [h('span', { class: 'chip dim', title: 'Prices appear after the first daily run' }, 'MARKET: NO DATA YET')]; clear(shell.top).append( h('div', { class: 'top-brand' }, h('span', { class: 'logo sm' }, 'HALCYON'), h('span', { class: 'dim' }, 'FLIGHT CONTROL')), h('div', { class: 'top-stats' }, h('span', { class: 'chip' }, 'DAY ', h('b', null, String(info?.day ?? me?.day ?? 0))), h('span', { class: 'chip' }, 'CREDITS ', h('b', null, (me?.credits ?? 0).toLocaleString('en-US'))), ...prices, ), h('div', { class: 'top-user' }, h('span', { class: 'callsign' }, me?.name?.toUpperCase() ?? ''), h('button', { class: 'btn small', type: 'button', onclick: async () => { try { await ctx.refresh(); toast('DATA REFRESHED'); } catch (e) { toast(e.message, 'err'); } } }, 'REFRESH'), h('button', { class: 'btn small', type: 'button', onclick: () => logout() }, 'LOGOUT'), ), ); } function renderNav() { if (!shell) return; const select = h('select', { id: 'ship-select', 'aria-label': 'Active ship', disabled: state.ships.length === 0, onchange: (e) => ctx.selectShip(Number(e.target.value)), }, state.ships.length ? state.ships.map((s) => h('option', { value: s.id, selected: s.id === state.selected }, `#${s.id} ${statusOf(s.status).label}`)) : [h('option', null, 'NO SHIPS')]); clear(shell.nav).append( h('div', { class: 'tabs' }, TABS.map(([id, label]) => h('a', { class: `tab ${state.tab === id ? 'on' : ''}`, href: `#/${id}`, 'aria-current': state.tab === id ? 'page' : null }, label)), h('a', { class: 'tab', href: '/manual.txt', target: '_blank', rel: 'noopener' }, 'MANUAL'), ), h('label', { class: 'shipsel' }, 'ACTIVE SHIP', select), ); } function renderMain() { if (!shell) return; clear(shell.main).append(VIEWS[state.tab](ctx)); } window.addEventListener('hashchange', () => { if (!shell) return; state.tab = tabFromHash(); renderNav(); renderMain(); shell.main.focus({ preventScroll: true }); }); // A refresh when the tab regains focus, since the daily run happens while the page sits open. document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && shell && state.tab === 'fleet') ctx.refresh().catch(() => {}); }); // ---- boot ------------------------------------------------------------------- startStarfield(document.getElementById('stars')); onUnauthorized(() => logout('SESSION REJECTED: ACCESS KEY NOT RECOGNISED')); api.info().then((i) => { state.info = i; }).catch(() => {}); if (keyStore.get()) enter(); else showAuth();