mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 18:06:44 +00:00
bcc453e8cf
## Description Adds a modal URL router so modals can be opened, deep-linked, and bookmarked via the hash. URLs of the form `#modal=<name>&tab=<key>&...` open the named modal and pass remaining keys as args to `onOpen`. The reverse direction also syncs: opening a modal via the UI updates the URL, closing it clears the hash, and switching tabs updates `&tab=`. Builds on the BaseModal refactor from #3923. ### What's new **`ModalRouter.ts`** — small registry + two-way sync helper. - `register(name, { tag, pageId? })` declares a modal as router-managed - `routeFromHash()` parses `#modal=...` and dispatches to `modal.open(args)` - `syncOpened/syncClosed/syncTab` push state back into the URL via `history.replaceState` (no history entries) - A `routingFromUrl` flag prevents URL→modal→URL feedback loops - Unknown modal names silently strip the hash **`BaseModal`** — opt-in URL sync via a `routerName` property. - When set, BaseModal calls into `modalRouter.syncOpened/syncClosed/syncTab` from `open` / `close` / `setActiveTab` - Modals that own their own URL state (lobby modals) just leave `routerName` undefined **`Main.ts`** — registers all routable modals and wires the router. - `handleUrl()`: adds a `modalRouter.routeFromHash()` branch after the path-based lobby join - `onHashUpdate`: when the hash is router-managed, routes via the router instead of tearing down lobby state ### Routable modals 13 inline modals: store, settings, leaderboard, clan, account, help, news, language, single-player, ranked, troubleshooting, territory-patterns, flag-input. Excluded by design: join-lobby, host-lobby (own URL state via `/game/<id>`), matchmaking (no URL state). ### Example uses - Deep link to store flags tab: `/#modal=store&tab=flags` - Settings keybinds tab: `/#modal=settings&tab=keybinds` - Cosmetics.ts now redirects to `#modal=store&tab=packs` when a hard-currency purchase fails for insufficient plutonium (after the alert), so users can top up directly ### URL behavior - `replaceState` everywhere — no history entries added when modals open / close / switch tabs - Browser back/forward still works for the existing path-based game flow - `hashchange` events are router-aware so external hash changes (back button, manual edit) correctly switch between routed modals without tearing down lobby state ## Please complete the following: - [x] I have added screenshots for all UI updates _(no visual changes; smoke-tested in dev)_ - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file _(no new user-visible strings)_ - [ ] I have added relevant tests to the test directory _(no test coverage; manually tested URL load, UI open, tab switch, close, hashchange, insufficient-plutonium redirect)_ - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: DISCORD_USERNAME
160 lines
5.3 KiB
TypeScript
160 lines
5.3 KiB
TypeScript
/**
|
|
* ModalRouter — two-way sync between `#modal=<name>&tab=<key>&...` and modals.
|
|
*
|
|
* URL → modal: parse hash, find registered modal, call `modal.open(args)`.
|
|
* Modal → URL: when a router-managed modal opens, closes, or switches tabs,
|
|
* update the URL via `history.replaceState` (no history entries).
|
|
*
|
|
* Lobby modals (join/host) and matchmaking are intentionally not registered:
|
|
* they have their own URL state (path-based) or none at all.
|
|
*/
|
|
|
|
interface RegistryEntry {
|
|
/** Custom element tag, e.g. "store-modal". */
|
|
tag: string;
|
|
/**
|
|
* Optional page-content element id (e.g. "page-item-store"). When set, the
|
|
* router calls `window.showPage(pageId)` for inline modals so the page-content
|
|
* container becomes visible. For popup-style modals, omit.
|
|
*/
|
|
pageId?: string;
|
|
}
|
|
|
|
/** Modals that the router can drive via the URL. */
|
|
interface RoutableModal extends HTMLElement {
|
|
open(args?: Record<string, unknown>): void;
|
|
close(args?: Record<string, unknown>): void;
|
|
}
|
|
|
|
class ModalRouter {
|
|
private registry = new Map<string, RegistryEntry>();
|
|
/** Name of the modal currently reflected in the URL, if any. */
|
|
private currentName: string | null = null;
|
|
/** True while we're routing from the URL (suppress modal→URL sync). */
|
|
private routingFromUrl = false;
|
|
|
|
register(name: string, entry: RegistryEntry): void {
|
|
this.registry.set(name, entry);
|
|
}
|
|
|
|
/**
|
|
* Parse `window.location.hash` for `#modal=<name>&...`. If present and
|
|
* registered, open the modal with the remaining keys as args. Returns true
|
|
* if the hash was a recognized modal route (the caller can skip other
|
|
* hash handlers). The open itself happens asynchronously after the custom
|
|
* element is upgraded.
|
|
*/
|
|
routeFromHash(): boolean {
|
|
const hash = window.location.hash;
|
|
if (!hash.startsWith("#")) return false;
|
|
const params = new URLSearchParams(hash.slice(1));
|
|
const name = params.get("modal");
|
|
if (!name) return false;
|
|
|
|
const entry = this.registry.get(name);
|
|
if (!entry) {
|
|
// Unknown modal — strip the hash silently.
|
|
this.replaceHash("");
|
|
return true;
|
|
}
|
|
|
|
params.delete("modal");
|
|
const args: Record<string, unknown> = {};
|
|
params.forEach((value, key) => {
|
|
args[key] = value;
|
|
});
|
|
|
|
void this.openRegistered(name, entry, args);
|
|
return true;
|
|
}
|
|
|
|
private async openRegistered(
|
|
name: string,
|
|
entry: RegistryEntry,
|
|
args: Record<string, unknown>,
|
|
): Promise<void> {
|
|
// The custom element may not be upgraded yet (e.g. routed on initial load
|
|
// before its module has finished evaluating). Wait so el.open is defined.
|
|
await customElements.whenDefined(entry.tag);
|
|
|
|
this.routingFromUrl = true;
|
|
try {
|
|
this.currentName = name;
|
|
if (entry.pageId) {
|
|
// Inline modal: showPage reveals the page-content container and calls
|
|
// .open() on the inline modal element automatically. We then call
|
|
// .open(args) so the args reach onOpen.
|
|
window.showPage?.(entry.pageId);
|
|
}
|
|
const el = document.querySelector(entry.tag) as RoutableModal | null;
|
|
el?.open(args);
|
|
} finally {
|
|
this.routingFromUrl = false;
|
|
}
|
|
}
|
|
|
|
/** Called by BaseModal.open() when a router-managed modal opens. */
|
|
syncOpened(name: string, args?: Record<string, unknown>): void {
|
|
if (this.routingFromUrl) return; // we're driving the modal from the URL; don't loop
|
|
if (!this.registry.has(name)) return;
|
|
this.currentName = name;
|
|
this.writeHash(name, args);
|
|
}
|
|
|
|
/** Called by BaseModal.close() when a router-managed modal closes. */
|
|
syncClosed(name: string): void {
|
|
if (this.routingFromUrl) return;
|
|
if (this.currentName !== name) return; // not the active routed modal
|
|
this.currentName = null;
|
|
this.replaceHash("");
|
|
}
|
|
|
|
/** Called by BaseModal.setActiveTab() when a router-managed modal switches tabs. */
|
|
syncTab(name: string, tab: string): void {
|
|
if (this.routingFromUrl) return;
|
|
if (this.currentName !== name) return;
|
|
const params = this.currentHashParams();
|
|
params.set("modal", name);
|
|
if (tab) {
|
|
params.set("tab", tab);
|
|
} else {
|
|
params.delete("tab");
|
|
}
|
|
this.replaceHash("#" + params.toString());
|
|
}
|
|
|
|
/** True if the current hash is `#modal=...`. */
|
|
isHashRouted(): boolean {
|
|
const hash = window.location.hash;
|
|
if (!hash.startsWith("#")) return false;
|
|
return new URLSearchParams(hash.slice(1)).has("modal");
|
|
}
|
|
|
|
private currentHashParams(): URLSearchParams {
|
|
const hash = window.location.hash;
|
|
if (!hash.startsWith("#")) return new URLSearchParams();
|
|
return new URLSearchParams(hash.slice(1));
|
|
}
|
|
|
|
private writeHash(name: string, args?: Record<string, unknown>): void {
|
|
const params = new URLSearchParams();
|
|
params.set("modal", name);
|
|
if (args) {
|
|
for (const [key, value] of Object.entries(args)) {
|
|
if (key === "modal") continue;
|
|
if (value === undefined || value === null) continue;
|
|
if (typeof value === "object") continue;
|
|
params.set(key, String(value));
|
|
}
|
|
}
|
|
this.replaceHash("#" + params.toString());
|
|
}
|
|
|
|
private replaceHash(hash: string): void {
|
|
const url = window.location.pathname + window.location.search + hash;
|
|
history.replaceState(history.state, "", url);
|
|
}
|
|
}
|
|
|
|
export const modalRouter = new ModalRouter();
|