mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:20:47 +00:00
Add modal URL router (#modal=name&tab=key) (#3924)
## 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
This commit is contained in:
@@ -25,6 +25,8 @@ import { translateText } from "./Utils";
|
||||
|
||||
@customElement("account-modal")
|
||||
export class AccountModal extends BaseModal {
|
||||
protected routerName = "account";
|
||||
|
||||
@state() private email: string = "";
|
||||
@state() private isLoadingUser: boolean = false;
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ type View =
|
||||
|
||||
@customElement("clan-modal")
|
||||
export class ClanModal extends BaseModal {
|
||||
protected routerName = "clan";
|
||||
|
||||
@state() private view: View = "list";
|
||||
@state() private loading = false;
|
||||
|
||||
|
||||
@@ -89,6 +89,10 @@ export async function purchaseCosmetic(
|
||||
: (userMe.player.currency?.soft ?? 0);
|
||||
if (balance < price) {
|
||||
alert(translateText("store.not_enough_currency"));
|
||||
if (method === "hard") {
|
||||
// Send the user to the packs tab so they can top up plutonium.
|
||||
window.location.hash = "#modal=store&tab=packs";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ function countryFlag(name: string, code: string): Flag {
|
||||
|
||||
@customElement("flag-input-modal")
|
||||
export class FlagInputModal extends BaseModal {
|
||||
protected routerName = "flag-input";
|
||||
|
||||
@state() private search = "";
|
||||
@state() private cosmetics: Cosmetics | null = null;
|
||||
@state() private userMe: UserMeResponse | false = false;
|
||||
|
||||
@@ -11,6 +11,8 @@ import { TroubleshootingModal } from "./TroubleshootingModal";
|
||||
|
||||
@customElement("help-modal")
|
||||
export class HelpModal extends BaseModal {
|
||||
protected routerName = "help";
|
||||
|
||||
@state() private keybinds: Record<string, string> = this.getKeybinds();
|
||||
@query("#tutorial-video-iframe") private videoIframe?: HTMLIFrameElement;
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ interface LanguageOption {
|
||||
|
||||
@customElement("language-modal")
|
||||
export class LanguageModal extends BaseModal {
|
||||
protected routerName = "language";
|
||||
|
||||
@property({ type: Array }) languageList: LanguageOption[] = [];
|
||||
@property({ type: String }) currentLang = "en";
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import { translateText } from "./Utils";
|
||||
|
||||
@customElement("leaderboard-modal")
|
||||
export class LeaderboardModal extends BaseModal {
|
||||
protected routerName = "leaderboard";
|
||||
|
||||
@state()
|
||||
private clanDateRange: { start: string; end: string } | null = null;
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ import { initLayout } from "./Layout";
|
||||
import "./LeaderboardModal";
|
||||
import "./Matchmaking";
|
||||
import { MatchmakingModal } from "./Matchmaking";
|
||||
import { modalRouter } from "./ModalRouter";
|
||||
import { initNavigation } from "./Navigation";
|
||||
import "./NewsModal";
|
||||
import "./PatternInput";
|
||||
@@ -268,6 +269,50 @@ class Client {
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
crazyGamesSDK.maybeInit();
|
||||
|
||||
// Register modals with the URL router. Lobby modals (join/host) and
|
||||
// matchmaking are intentionally omitted — they own their own URL state
|
||||
// (path-based) or none at all.
|
||||
modalRouter.register("store", {
|
||||
tag: "store-modal",
|
||||
pageId: "page-item-store",
|
||||
});
|
||||
modalRouter.register("settings", {
|
||||
tag: "user-setting",
|
||||
pageId: "page-settings",
|
||||
});
|
||||
modalRouter.register("leaderboard", {
|
||||
tag: "leaderboard-modal",
|
||||
pageId: "page-leaderboard",
|
||||
});
|
||||
modalRouter.register("clan", { tag: "clan-modal", pageId: "page-clan" });
|
||||
modalRouter.register("account", {
|
||||
tag: "account-modal",
|
||||
pageId: "page-account",
|
||||
});
|
||||
modalRouter.register("help", { tag: "help-modal", pageId: "page-help" });
|
||||
modalRouter.register("news", { tag: "news-modal", pageId: "page-news" });
|
||||
modalRouter.register("language", {
|
||||
tag: "language-modal",
|
||||
pageId: "page-language",
|
||||
});
|
||||
modalRouter.register("single-player", {
|
||||
tag: "single-player-modal",
|
||||
pageId: "page-single-player",
|
||||
});
|
||||
modalRouter.register("ranked", {
|
||||
tag: "ranked-modal",
|
||||
pageId: "page-ranked",
|
||||
});
|
||||
modalRouter.register("troubleshooting", {
|
||||
tag: "troubleshooting-modal",
|
||||
pageId: "page-troubleshooting",
|
||||
});
|
||||
modalRouter.register("territory-patterns", {
|
||||
tag: "territory-patterns-modal",
|
||||
});
|
||||
modalRouter.register("flag-input", { tag: "flag-input-modal" });
|
||||
|
||||
// Prefetch turnstile token so it is available when
|
||||
// the user joins a lobby.
|
||||
this.turnstileTokenPromise = getTurnstileToken();
|
||||
@@ -525,6 +570,13 @@ class Client {
|
||||
}
|
||||
|
||||
const onHashUpdate = () => {
|
||||
// Router-managed hash changes (#modal=...) are handled by the router
|
||||
// syncing in/out; we don't need to tear down the lobby state for them.
|
||||
if (modalRouter.isHashRouted()) {
|
||||
modalRouter.routeFromHash();
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset the UI to its initial state
|
||||
this.joinModal?.close();
|
||||
|
||||
@@ -725,6 +777,9 @@ class Client {
|
||||
console.log(`joining lobby ${lobbyId}`);
|
||||
return;
|
||||
}
|
||||
if (modalRouter.routeFromHash()) {
|
||||
return;
|
||||
}
|
||||
if (decodedHash.startsWith("#affiliate=")) {
|
||||
const affiliateCode = decodedHash.replace("#affiliate=", "");
|
||||
strip();
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* 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();
|
||||
@@ -10,6 +10,8 @@ import { normalizeNewsMarkdown } from "./NewsMarkdown";
|
||||
|
||||
@customElement("news-modal")
|
||||
export class NewsModal extends BaseModal {
|
||||
protected routerName = "news";
|
||||
|
||||
@property({ type: String }) markdown = "Loading...";
|
||||
|
||||
private initialized: boolean = false;
|
||||
|
||||
@@ -63,6 +63,8 @@ const DEFAULT_OPTIONS = {
|
||||
|
||||
@customElement("single-player-modal")
|
||||
export class SinglePlayerModal extends BaseModal {
|
||||
protected routerName = "single-player";
|
||||
|
||||
@state() private selectedMap: GameMapType = DEFAULT_OPTIONS.selectedMap;
|
||||
@state() private selectedDifficulty: Difficulty =
|
||||
DEFAULT_OPTIONS.selectedDifficulty;
|
||||
|
||||
@@ -19,6 +19,7 @@ type StoreTab = "patterns" | "flags" | "packs" | "subscriptions";
|
||||
|
||||
@customElement("store-modal")
|
||||
export class StoreModal extends BaseModal {
|
||||
protected routerName = "store";
|
||||
private cosmetics: Cosmetics | null = null;
|
||||
private affiliateCode: string | null = null;
|
||||
private userMeResponse: UserMeResponse | false = false;
|
||||
|
||||
@@ -24,6 +24,7 @@ import { translateText } from "./Utils";
|
||||
|
||||
@customElement("territory-patterns-modal")
|
||||
export class TerritoryPatternsModal extends BaseModal {
|
||||
protected routerName = "territory-patterns";
|
||||
public previewButton: HTMLElement | null = null;
|
||||
|
||||
@state() private selectedPattern: PlayerPattern | null;
|
||||
|
||||
@@ -12,6 +12,8 @@ const infoIcon = assetUrl("images/InfoIcon.svg");
|
||||
|
||||
@customElement("troubleshooting-modal")
|
||||
export class TroubleshootingModal extends BaseModal {
|
||||
protected routerName = "troubleshooting";
|
||||
|
||||
@property({ type: String }) markdown = "Loading...";
|
||||
|
||||
@property({ type: Object })
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Platform } from "./Platform";
|
||||
|
||||
@customElement("user-setting")
|
||||
export class UserSettingModal extends BaseModal {
|
||||
protected routerName = "settings";
|
||||
private userSettings: UserSettings = new UserSettings();
|
||||
private readonly defaultKeybinds = getDefaultKeybinds(Platform.isMac);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { html, LitElement, TemplateResult } from "lit";
|
||||
import { property, query, state } from "lit/decorators.js";
|
||||
import { modalRouter } from "../ModalRouter";
|
||||
import "./baseComponents/Modal";
|
||||
import type { OModalTab } from "./baseComponents/Modal";
|
||||
|
||||
@@ -57,6 +58,13 @@ export abstract class BaseModal extends LitElement {
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional router name. When set, BaseModal syncs URL state on open/close/
|
||||
* tab change as `#modal=<routerName>&tab=<key>&...`. Modals that own their
|
||||
* own URL state (e.g. lobby modals) should leave this undefined.
|
||||
*/
|
||||
protected routerName?: string;
|
||||
|
||||
/** Render slot="header" content. Default: no header slot. */
|
||||
protected renderHeaderSlot(): TemplateResult | null {
|
||||
return null;
|
||||
@@ -149,19 +157,24 @@ export abstract class BaseModal extends LitElement {
|
||||
if (tabs.length && this.activeTab === "") {
|
||||
this.activeTab = tabs[0].key;
|
||||
}
|
||||
if (
|
||||
typeof args?.tab === "string" &&
|
||||
tabs.some((t) => t.key === args.tab)
|
||||
) {
|
||||
this.activeTab = args.tab;
|
||||
}
|
||||
const requestedTab =
|
||||
typeof args?.tab === "string" && tabs.some((t) => t.key === args.tab)
|
||||
? args.tab
|
||||
: null;
|
||||
|
||||
const wasOpen = this.isModalOpen;
|
||||
if (!wasOpen) {
|
||||
if (requestedTab) this.activeTab = requestedTab;
|
||||
this.registerEscapeHandler();
|
||||
this.onOpen(args);
|
||||
if (this.activeTab) this.onTabEnter(this.activeTab);
|
||||
} else {
|
||||
this.onOpen(args);
|
||||
// Already open: route tab changes through setActiveTab so URL syncs.
|
||||
if (requestedTab && requestedTab !== this.activeTab) {
|
||||
this.setActiveTab(requestedTab);
|
||||
}
|
||||
}
|
||||
this.onOpen(args);
|
||||
if (this.activeTab) this.onTabEnter(this.activeTab);
|
||||
|
||||
if (wasOpen) return;
|
||||
|
||||
@@ -176,6 +189,10 @@ export abstract class BaseModal extends LitElement {
|
||||
} else {
|
||||
this.modalEl?.open();
|
||||
}
|
||||
|
||||
if (this.routerName) {
|
||||
modalRouter.syncOpened(this.routerName, args);
|
||||
}
|
||||
} finally {
|
||||
this.opening = false;
|
||||
}
|
||||
@@ -193,6 +210,10 @@ export abstract class BaseModal extends LitElement {
|
||||
} else {
|
||||
this.modalEl?.close();
|
||||
}
|
||||
|
||||
if (this.routerName) {
|
||||
modalRouter.syncClosed(this.routerName);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Tab management ----
|
||||
@@ -204,6 +225,9 @@ export abstract class BaseModal extends LitElement {
|
||||
if (this.activeTab === key) return;
|
||||
this.activeTab = key;
|
||||
this.onTabEnter(key);
|
||||
if (this.routerName) {
|
||||
modalRouter.syncTab(this.routerName, key);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Internals ----
|
||||
|
||||
@@ -9,6 +9,8 @@ import { modalHeader } from "./ui/ModalHeader";
|
||||
|
||||
@customElement("ranked-modal")
|
||||
export class RankedModal extends BaseModal {
|
||||
protected routerName = "ranked";
|
||||
|
||||
@state() private elo: number | string = "...";
|
||||
@state() private userMeResponse: UserMeResponse | false = false;
|
||||
@state() private errorMessage: string | null = null;
|
||||
|
||||
Reference in New Issue
Block a user