init
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
// 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();
|
||||
Reference in New Issue
Block a user