Files
2026-09-19 20:21:47 +02:00

167 lines
6.0 KiB
JavaScript

// 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();