Something went wrong. Try again.
Web frontend and supporting services for lance.blue
Something went wrong. Try again.
15 kB · 402 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403/** * The top-level destinations, in the order the masthead lists them. * * Two things need this list and they run at different times: the layout, which * renders the masthead into every page's HTML, and chrome.ts, which marks the * current one once the app is running. Keeping it here means a destination is * added once. * * Its own module rather than chrome.ts's, and one that imports nothing, * because the layout reads it at build time: importing chrome.ts starts an API * request, which is right in a browser and wrong in a build. * * Every href is absolute, so a page that is not the app carries the same * masthead without its links resolving against itself. */export type Destination = { /** Names the link for markCurrent, and is the app's screen where it has one. */ readonly id: string; /** * Where the link goes. Absent exactly when `soon` is set: a destination * nothing has been built behind has nowhere honest to point, and an href to * an address that answers with the not-found screen is worse than no link at * all. */ readonly href?: string; readonly label: string; /** * On the bar, and not a link: under construction. * * The bar is where a player finds out what this is going to be, so a section * that is coming is named rather than hidden until the day it works. It is * rendered as text, not as a disabled anchor — there is no address behind it * to disable, nothing to focus and nothing to click — and it says so to a * screen reader as well as to the eye. */ readonly soon?: boolean; /** * A destination the router will not put up without a session. * * This closes the address, and only the address. Whether the link is on the * bar for a stranger is `hiddenSignedOut`, which is a separate question: * Play is closed and still shown, because a stranger who clicks it should be * asked to sign in rather than be left wondering where the game is. */ readonly signedIn?: boolean; /** * A link that means nothing to a stranger, so it is not shown to one. * * The layout renders it hidden, because the masthead is in the HTML before * anything knows who is here, and account.ts reveals it once the session has * been read. Hiding is never the guard — `signedIn` is, and the router is * what enforces it; the address can always be typed. */ readonly hiddenSignedOut?: boolean; /** * The one destination that starts a game, styled as a filled button in the * masthead rather than one more plain-text link. * * A rendering flag, not a routing one, unlike `signedIn`: it changes the * layout's markup for this link and nothing about who may reach it. */ readonly prominent?: boolean; /** * Drawn joined to the destination before it, as the second half of one * split control rather than as a link of its own. * * A rendering flag like `prominent`, and it only means anything directly * after one: the pair is a filled button with a smaller segment on its * end, which is why the group it sits in is the only one the bar draws * without a gap between its links. */ readonly attached?: boolean;};
/** * A run of destinations that belong together, and how it is set off from the * run before it. * * The bar reads as four things rather than a row of equal links: the way into * a game, the things a player manages between games, what everybody else is * doing, and the site's own pages. `space` is a plain gap; `rule` is the thin * vertical line. */export type NavGroup = { readonly id: string; readonly separator?: "space" | "rule"; readonly destinations: readonly Destination[];};
/** * The two labels the prominent link can carry. * * "Play" is what the layout writes into the HTML, because that is the only * one a build can know: one file is behind every address, and whether the * reader has a match still going is a question about the reader. account.ts * swaps in "Resume" once it has read the session and asked. * * Both spellings live here rather than in whichever file wrote one first, so * the label the layout renders and the label the flip restores cannot drift. */export const PLAY_LABEL = "Play";export const RESUME_LABEL = "Resume";
export const NAV_GROUPS: readonly NavGroup[] = [ { id: "play", destinations: [ { id: "matches", href: "/#matches", label: PLAY_LABEL, signedIn: true, prominent: true, }, // The day's fight, on the end of the button that starts a game. It was // an address with no link on the bar - reached only from the card on // the front page - which put the one thing here a player can do without // an opponent behind having scrolled to find it. Open signed out, like // the card: pressing Play on a challenge is what asks for an account. { id: "daily", href: "/#daily", label: "Daily", attached: true, }, ], }, { id: "game", separator: "space", destinations: [ { id: "camo", href: "/#camo", label: "Camo" }, { id: "hangar", href: "/#hangar", label: "Hangar" }, ], }, { id: "community", separator: "space", destinations: [ { id: "leaderboards", label: "Leaderboards", soon: true }, { id: "observe", label: "Observe", soon: true }, ], }, { id: "site", separator: "rule", destinations: [ { id: "about", href: "/#about", label: "About" }, { id: "blog", href: "/blog", label: "Updates" }, ], },];
/** Every destination, in bar order, for anything that does not care about the * grouping. */export const DESTINATIONS: readonly Destination[] = NAV_GROUPS.flatMap( (group) => group.destinations,);
/** * The pages under About. * * About is a hub: a short description of what this is, and a way into each of * these. They are addresses of their own — `#about/faq` — so a question can be * linked to, and the masthead still marks About while the reader is in one. * * The copy for each is in content/about.json, keyed by these ids; render.ts is * what ties the two together, and misses a key at build time. */export const ABOUT_PAGES = [ { id: "faq", label: "FAQ" }, { id: "goals", label: "Development goals" }, { id: "credits", label: "Credits" },] as const;
export type AboutPageId = (typeof ABOUT_PAGES)[number]["id"];
/** The address of one About page. */export function aboutHref(id: AboutPageId): string { return `/#about/${id}`;}
/** The route ids the About hub owns, hub included. */const ABOUT_ROUTES = new Set<string>([ "about", ...ABOUT_PAGES.map((page) => `about/${page.id}`),]);
/** * The destination a route belongs to. * * A route is either a destination's own id or a page under one — `about/faq` * belongs to About — and everything that reasons about the masthead or about * who may be here works on the destination. */export function destinationForRoute(route: string): string { return route.split("/")[0] ?? route;}
/** * Whether a route may be put up for whoever is here. * * The one decision, made off the one list, so marking a destination * `signedIn` closes the address as well as hiding the link. The router calls * it against a session it has read; nothing else may decide this, and hiding a * link is not deciding it. * * A route that is not a destination — the router has none today, but the camo * editor's pending-camo return is close to one — is allowed. This list says * what is closed, not what exists. */export function isAllowed(route: string, signedIn: boolean): boolean { if (signedIn) return true; const id = destinationForRoute(route); return !DESTINATIONS.find((d) => d.id === id)?.signedIn;}
/** * The destinations that are a hash on the app's own page, and the pages under * About. Blog is not one: those are pages the build wrote, and the router * never runs on them. */const HASH_ROUTES = new Set<string>([ ...DESTINATIONS.filter((d) => d.href?.startsWith("/#")).map((d) => d.id), ...ABOUT_ROUTES,]);
/** The addresses the app itself is served at. Everything else is a page. */const APP_PATHS = ["/", "/index.html"];
/** * Whether this address is the app's own page rather than one the build wrote. * * account.ts asks before it spends a request on anything: it mounts on every * page of the site, the blog's included, and there every link is a fresh page * load. The blog is not where a player goes back into a match, so it does not * pay for the masthead to know. */export function isAppPage(pathname: string): boolean { return APP_PATHS.includes(pathname);}
/** A leading slash is tolerated, so "#/camo" reads the same as "#camo". */function hashId(hash: string): string { return hash.replace(/^#\/?/, "");}
/** * Addresses that used to be a destination of their own, and where they went. * * The FAQ was a top-level tab before About became a hub, so its address is out * there — in the blog, in whatever anyone has linked. The router sends these on * rather than calling them dead links. */const MOVED: Readonly<Record<string, string>> = { faq: "about/faq" };
/** Where a moved address went, or null when the address never moved. */export function movedRouteForHash(hash: string): string | null { return MOVED[hashId(hash)] ?? null;}
/** * The destination a hash names, or null when it names none. * * Null is not Home. The router has its own reasons to end up on Home from an * address with no route of its own, and keeping the two apart is what lets the * masthead say that none of its links is the current page. */export function routeForHash(hash: string): string | null { const id = hashId(hash); return HASH_ROUTES.has(id) ? id : null;}
/** * "#lobby/<id>", the one parameterized address the app has. * * Not one more entry in DESTINATIONS: a lobby is a live, ephemeral, * per-visit thing opened from the Duel and Scenario cards or followed from * a share link, never a fixed place the masthead should list, and every id * is different. It is matched by shape instead of by membership — the same * move HASH_ROUTES/ABOUT_ROUTES and MOVED both make for their own kind of * address, none of them a list this has to be kept in sync with. */const LOBBY_HASH = /^lobby\/(.+)$/;
/** * The lobby id a hash names, or null when the hash does not name one. * * `id` is taken from `hashId()` first, the same leading-slash tolerance * every other hash here gets, then percent-decoded — defensive rather than * exercised, since a matchId minted by `openLobby` is an opaque token with * no "/" or "#" in it to encode in the first place. */export function lobbyIdFromHash(hash: string): string | null { const match = LOBBY_HASH.exec(hashId(hash)); return match ? decodeURIComponent(match[1]!) : null;}
/** * A player's page, the site's second parameterized address. * * A path rather than a hash, unlike every destination above: a profile is * what people link to about each other, and an unfurler has to be able to be * answered per player - which needs an address the distribution can route on * its own, without running the page. See web/src/profile-page.ts. * * The segment is a handle or a fully qualified DID, and both name the same * page; identity.ts resolves whichever arrives. What the site *writes* is the * handle, with its @: `/profile/@alice.example` is an address somebody can * read out, and `/profile/did:plc:h7k...` is one nobody can. The DID form * stays valid for exactly the case the handle cannot cover - an account whose * handle nothing can confirm - and for a link written before a handle moved. * * Neither is percent-encoded. An @ and a colon are both legal in a path * segment, browsers send them as typed, and an address is written to be read. */const PROFILE_PREFIX = "/profile/";
/** * A player's matches, the site's third parameterized address. * * The same rule as the profile's, one prefix along: a match is a thing two * accounts did together and the record of it is public, so what somebody has * played is an address anybody can be handed. `/matches/@alice.example` is * hers whoever is reading, and your own is the one the account menu leads to * rather than a screen that only exists while you are signed in. */const MATCHES_PREFIX = "/matches/";
/** * The page's address for an account. * * Handed the handle where there is one and the DID where there is not, which * is the same rule the page and the card follow for what to call somebody. */export function profileHref(actor: { did: string; handle: string | null;}): string { return `${PROFILE_PREFIX}${segmentFor(actor)}`;}
/** Their matches, at the address written the same way. */export function matchesHref(actor: { did: string; handle: string | null;}): string { return `${MATCHES_PREFIX}${segmentFor(actor)}`;}
function segmentFor(actor: { did: string; handle: string | null }): string { return actor.handle ? `@${actor.handle}` : actor.did;}
/** * The handle or DID a profile address names, or null where it names nobody. * * A leading @ is part of the handle's address and is left on: identity.ts * takes it off before resolving, the way it takes one off anything typed. * Anything after a second slash is ignored rather than refused: * `/profile/@alice.example/` is the same page, and a trailing slash is not * worth a dead end. */export function actorFromPath(pathname: string): string | null { return actorUnder(PROFILE_PREFIX, pathname);}
/** The same, for a matches address. */export function actorFromMatchesPath(pathname: string): string | null { return actorUnder(MATCHES_PREFIX, pathname);}
function actorUnder(prefix: string, pathname: string): string | null { if (!pathname.startsWith(prefix)) return null; const segment = pathname.slice(prefix.length).split("/")[0] ?? ""; if (segment === "") return null; try { // Nothing here writes an encoded address any more, and one can still // arrive: a link that went through something which encoded it on the way, // or an address typed into a client that tidied it up. return decodeURIComponent(segment); } catch { // A malformed escape is not an address, and decodeURIComponent throws on // one rather than answering. return null; }}
/** * Whether the site has this address at all. * * Decided in the browser because it has to be: CloudFront maps both 403 and * 404 to /index.html with a 200, so a request for a path that never existed * arrives here as an ordinary page load and nothing else will ever tell the * player their link is dead. */export function isKnownAddress(pathname: string, hash: string): boolean { if (!isAppPage(pathname)) return false; if (hash === "" || hash === "#") return true; if (movedRouteForHash(hash)) return true; return routeForHash(hash) !== null || lobbyIdFromHash(hash) !== null;}