clone

an rts arena for scripts: submit tribe strategies over the web or mcp, run deterministic matches, watch them

main
tribes / web / src / main.ts
main.ts 4.0K
1/** App entry: shell + History-based router (Connect / Create / Browse / Simulate tabs, '/s/:id' public viewer). */2import '@fontsource/vt323'; // pixel/terminal display face for the whole HUD3import './style.css';4import type { UserInfo } from '@tribes/protocol';5import { h, clear } from './dom';6import { resolveUser, signOut } from './auth';7import { renderConnect } from './pages/connect';8import { renderCreate } from './pages/create';9import { renderBrowse } from './pages/browse';10import { renderSimulate } from './pages/simulate';11import { renderViewer } from './pages/viewer';1213export interface AppCtx {14 user: UserInfo | null;15 setUser(user: UserInfo | null): void;16 navigate(path: string): void;17}1819const app = document.getElementById('app')!;20let currentUser: UserInfo | null = null;21let viewerCleanup: (() => void) | null = null;222324function navigate(path: string) {25 if (location.pathname + location.search !== path) history.pushState(null, '', path);26 render();27}2829const ctx: AppCtx = {30 get user() {31 return currentUser;32 },33 set user(_u) {34 /* use setUser */35 },36 setUser(user) {37 currentUser = user;38 render();39 },40 navigate,41} as AppCtx;4243const TABS: Array<{ label: string; path: string }> = [44 { label: 'Connect', path: '/connect' },45 { label: 'Create', path: '/create' },46 { label: 'Browse', path: '/browse' },47 { label: 'Simulate', path: '/simulate' },48];4950function tabNav(): HTMLElement {51 const path = location.pathname;52 return h(53 'nav',54 { class: 'mode-toggle tab-nav' },55 TABS.map((t) =>56 h(57 'button',58 {59 class: `mode-btn${path === t.path || path.startsWith(`${t.path}/`) ? ' active' : ''}`,60 onclick: () => navigate(t.path),61 },62 t.label,63 ),64 ),65 );66}6768function topbar(): HTMLElement {69 return h(70 'div',71 { class: 'topbar' },72 h('a', { class: 'brand', href: '/browse', onclick: (e: Event) => { e.preventDefault(); navigate('/browse'); } }, 'TRIBES'),73 h('span', { class: 'muted' }, '- strategy-scripting RTS -'),74 tabNav(),75 h('span', { class: 'spacer' }),76 currentUser77 ? h(78 'span',79 { class: 'row' },80 h('span', { class: 'who' }, currentUser.name ?? currentUser.externalId),81 h(82 'button',83 {84 class: 'small',85 onclick: async () => {86 await signOut();87 ctx.setUser(null);88 },89 },90 'Sign out',91 ),92 )93 : h('span', { class: 'who' }, 'not signed in'),94 );95}9697// Art credit lives in the repository README (tileset is CC0; credit optional).98function footer(): HTMLElement {99 return h(100 'footer',101 { class: 'credits' },102 h('span', { class: 'muted' }, 'Sims & scripts expire after 14 days unless starred.'),103 );104}105106function render() {107 // let the previous viewer tear down its worker108 viewerCleanup?.();109 viewerCleanup = null;110111 // '/' is not a page — land on Browse (preserving any ?return=... etc.)112 if (location.pathname === '/') history.replaceState(null, '', `/browse${location.search}`);113114 clear(app as HTMLElement);115 const path = location.pathname;116 const main = h('main', { class: 'page' });117 app.append(topbar(), main, footer());118119 const simMatch = /^\/s\/([^/]+)$/.exec(path);120 const browseMatch = /^\/browse(?:\/([^/]+))?$/.exec(path);121 if (simMatch) {122 main.classList.add('wide');123 viewerCleanup = renderViewer(main, simMatch[1]!, ctx);124 } else if (path === '/connect') {125 renderConnect(main, ctx);126 } else if (path === '/create') {127 main.classList.add('wide');128 renderCreate(main, ctx);129 } else if (browseMatch) {130 renderBrowse(main, ctx, browseMatch[1]);131 } else if (path === '/simulate') {132 renderSimulate(main, ctx);133 } else {134 renderBrowse(main, ctx);135 }136}137138window.addEventListener('popstate', render);139140async function boot() {141 render(); // paint the shell immediately142 try {143 currentUser = await resolveUser();144 } catch {145 currentUser = null;146 }147 render();148}149150void boot();