From 57b86a006513c7a553b29dcb3c0c9864deb2e78a Mon Sep 17 00:00:00 2001 From: evanpelle Date: Thu, 14 May 2026 15:00:17 -0700 Subject: [PATCH] modal refactor --- src/client/AccountModal.ts | 92 ++++----- src/client/ClanModal.ts | 66 +++---- src/client/FlagInputModal.ts | 90 ++++----- src/client/HelpModal.ts | 56 ++---- src/client/HostLobbyModal.ts | 60 +++--- src/client/JoinLobbyModal.ts | 83 +++----- src/client/LanguageModal.ts | 161 +++++++--------- src/client/LeaderboardModal.ts | 98 +++++----- src/client/Main.ts | 11 +- src/client/Matchmaking.ts | 39 ++-- src/client/NewsModal.ts | 60 +++--- src/client/SinglePlayerModal.ts | 98 ++++------ src/client/Store.ts | 128 +++++++------ src/client/TerritoryPatternsModal.ts | 89 ++++----- src/client/TokenLoginModal.ts | 64 +++---- src/client/TroubleshootingModal.ts | 248 +++++++++++------------- src/client/UserSettingModal.ts | 59 +++--- src/client/components/BaseModal.ts | 276 ++++++++++++++++++--------- src/client/components/RankedModal.ts | 73 ++++--- 19 files changed, 850 insertions(+), 1001 deletions(-) diff --git a/src/client/AccountModal.ts b/src/client/AccountModal.ts index ab57c70b89..8f5b05dda3 100644 --- a/src/client/AccountModal.ts +++ b/src/client/AccountModal.ts @@ -64,71 +64,43 @@ export class AccountModal extends BaseModal { ); } - render() { - const content = this.isLoadingUser - ? this.renderLoadingSpinner( - translateText("account_modal.fetching_account"), - ) - : this.renderInner(); - - if (this.inline) { - return this.isLoadingUser - ? html`
- ${modalHeader({ - title: translateText("account_modal.title"), - onBack: () => this.close(), - ariaLabel: translateText("common.back"), - })} - ${content} -
` - : content; - } - - return html` - - ${content} - - `; - } - - private renderInner() { + protected renderHeaderSlot() { const isLoggedIn = !!this.userMeResponse?.user; - const title = translateText("account_modal.title"); const publicId = this.userMeResponse?.player?.publicId ?? ""; const displayId = publicId || translateText("account_modal.not_found"); + return modalHeader({ + title: translateText("account_modal.title"), + onBack: () => this.close(), + ariaLabel: translateText("common.back"), + rightContent: + isLoggedIn && !this.isLoadingUser + ? html` +
+ ${translateText("account_modal.public_player_id")} + +
+ ` + : undefined, + }); + } + protected renderBody() { + if (this.isLoadingUser) { + return this.renderLoadingSpinner( + translateText("account_modal.fetching_account"), + ); + } + const isLoggedIn = !!this.userMeResponse?.user; return html` -
- ${modalHeader({ - title, - onBack: () => this.close(), - ariaLabel: translateText("common.back"), - rightContent: isLoggedIn - ? html` -
- ${translateText("account_modal.public_player_id")} - -
- ` - : undefined, - })} - -
- ${isLoggedIn ? this.renderAccountInfo() : this.renderLoginOptions()} -
+
+ ${isLoggedIn ? this.renderAccountInfo() : this.renderLoginOptions()}
`; } diff --git a/src/client/ClanModal.ts b/src/client/ClanModal.ts index 1aebed5dd2..66f7361a00 100644 --- a/src/client/ClanModal.ts +++ b/src/client/ClanModal.ts @@ -18,7 +18,6 @@ import "./components/CopyButton"; import { modalHeader } from "./components/ui/ModalHeader"; import { translateText } from "./Utils"; -type Tab = "my-clans" | "browse"; type View = | "list" | "detail" @@ -30,7 +29,6 @@ type View = @customElement("clan-modal") export class ClanModal extends BaseModal { - @state() private activeTab: Tab = "my-clans"; @state() private view: View = "list"; @state() private loading = false; @@ -59,36 +57,42 @@ export class ClanModal extends BaseModal { stats: ClanStats | null; } | null = null; - render() { - const onListView = this.view === "list" && !this.selectedClanTag; - const tabs = onListView - ? [ - { key: "my-clans", label: translateText("clan_modal.my_clans") }, - { key: "browse", label: translateText("clan_modal.browse") }, - ] - : []; - const header = onListView + private get onListView(): boolean { + return this.view === "list" && !this.selectedClanTag; + } + + protected modalConfig() { + return { + tabs: this.onListView + ? [ + { key: "my-clans", label: translateText("clan_modal.my_clans") }, + { key: "browse", label: translateText("clan_modal.browse") }, + ] + : [], + }; + } + + protected renderHeaderSlot() { + return this.onListView ? modalHeader({ title: translateText("clan_modal.title"), onBack: () => this.close(), ariaLabel: translateText("common.back"), }) : this.renderSubViewHeader(); - return html` - this.handleTabChange(key as Tab)} - > - ${header ? html`
${header}
` : ""} -
${this.renderInner()}
-
- `; + } + + protected renderBody() { + return html`
${this.renderInner()}
`; + } + + protected onTabEnter(tab: string): void { + this.view = "list"; + this.selectedClan = null; + this.selectedClanTag = ""; + if (tab === "my-clans") { + this.loadMyClans(); + } } private tagPill(tag: string) { @@ -152,16 +156,6 @@ export class ClanModal extends BaseModal { }); } - private handleTabChange(tab: Tab) { - this.activeTab = tab; - this.view = "list"; - this.selectedClan = null; - this.selectedClanTag = ""; - if (tab === "my-clans") { - this.loadMyClans(); - } - } - protected onOpen(): void { this.loadMyClans(); } diff --git a/src/client/FlagInputModal.ts b/src/client/FlagInputModal.ts index 7d33a2240a..8c7210cc69 100644 --- a/src/client/FlagInputModal.ts +++ b/src/client/FlagInputModal.ts @@ -122,67 +122,49 @@ export class FlagInputModal extends BaseModal { `; } - render() { - const content = html` -
-
- ${modalHeader({ - title: translateText("flag_input.title"), - onBack: () => this.close(), - ariaLabel: translateText("common.back"), - rightContent: html``, - })} - -
- + ${modalHeader({ + title: translateText("flag_input.title"), + onBack: () => this.close(), + ariaLabel: translateText("common.back"), + rightContent: html``, + })} + +
+ -
-
-
- { - this.close(); - window.showPage?.("page-item-store"); - }} - > -
- -
- ${this.renderFlags()} + type="text" + placeholder=${translateText("flag_input.search_flag")} + .value=${this.search} + @change=${this.handleSearch} + @keyup=${this.handleSearch} + />
`; + } - if (this.inline) { - return content; - } - + protected renderBody() { return html` - - ${content} - +
+ { + this.close(); + window.showPage?.("page-item-store"); + }} + > +
+
${this.renderFlags()}
`; } diff --git a/src/client/HelpModal.ts b/src/client/HelpModal.ts index ab2e0d943f..f6af03dbe7 100644 --- a/src/client/HelpModal.ts +++ b/src/client/HelpModal.ts @@ -57,28 +57,28 @@ export class HelpModal extends BaseModal { >`; } - render() { - const keybinds = this.keybinds; + protected renderHeaderSlot() { + return modalHeader({ + title: translateText("main.help"), + onBack: () => this.close(), + ariaLabel: translateText("common.back"), + }); + } - const content = html` -
- ${modalHeader({ - title: translateText("main.help"), - onBack: () => this.close(), - ariaLabel: translateText("common.back"), - })} + protected renderBody() { + const keybinds = this.keybinds; -
+ return html` +
@@ -1228,22 +1228,6 @@ export class HelpModal extends BaseModal {
`; - - if (this.inline) { - return content; - } - - return html` - - ${content} - - `; } openTroubleshooting() { diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index aad4fde6cf..5b683ba568 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -150,7 +150,25 @@ export class HostLobbyModal extends BaseModal { this.eventBus?.off(LobbyInfoEvent, this.handleLobbyInfo); } - render() { + protected renderHeaderSlot() { + return modalHeader({ + title: translateText("host_modal.title"), + onBack: () => { + this.leaveLobbyOnClose = true; + this.close(); + }, + ariaLabel: translateText("common.back"), + rightContent: html` + + `, + }); + } + + protected renderBody() { const inputCards = [ html``, ]; - const content = html` -
- - ${modalHeader({ - title: translateText("host_modal.title"), - onBack: () => { - this.leaveLobbyOnClose = true; - this.close(); - }, - ariaLabel: translateText("common.back"), - rightContent: html` - - `, - })} - -
+ return html` +
+
`; - - if (this.inline) { - return content; - } - - return html` - - ${content} - - `; } protected onOpen(): void { diff --git a/src/client/JoinLobbyModal.ts b/src/client/JoinLobbyModal.ts index ca2fa83775..115bff0fa4 100644 --- a/src/client/JoinLobbyModal.ts +++ b/src/client/JoinLobbyModal.ts @@ -77,7 +77,26 @@ export class JoinLobbyModal extends BaseModal { }); }; - render() { + protected renderHeaderSlot() { + if (!this.currentLobbyId) { + return modalHeader({ + title: translateText("private_lobby.title"), + onBack: () => this.closeAndLeave(), + ariaLabel: translateText("common.close"), + }); + } + return modalHeader({ + title: translateText("public_lobby.title"), + onBack: () => this.closeAndLeave(), + ariaLabel: translateText("common.close"), + rightContent: + this.currentLobbyId && this.isPrivateLobby() + ? html`` + : undefined, + }); + } + + protected renderBody() { // Pre-join state: show lobby ID input form if (!this.currentLobbyId) { return this.renderJoinForm(); @@ -104,20 +123,9 @@ export class JoinLobbyModal extends BaseModal { const hostClientID = this.isPrivateLobby() ? (this.lobbyCreatorClientID ?? "") : ""; - const content = html` -
- ${modalHeader({ - title: translateText("public_lobby.title"), - onBack: () => this.closeAndLeave(), - ariaLabel: translateText("common.close"), - rightContent: - this.currentLobbyId && this.isPrivateLobby() - ? html` - - ` - : undefined, - })} -
+ return html` +
+
${this.isConnecting ? html`
`; - - if (this.inline) { - return content; - } - - return html` - - ${content} - - `; } private renderJoinForm() { - const content = html` -
- ${modalHeader({ - title: translateText("private_lobby.title"), - onBack: () => this.closeAndLeave(), - ariaLabel: translateText("common.close"), - })} -
+ return html` +
-
- `; - - if (this.inline) { - return content; - } - - return html` - - ${content} - + `; } - public open(lobbyId: string = "", lobbyInfo?: GameInfo | PublicGameInfo) { - super.open(); + protected onOpen(args?: Record): void { + const lobbyId = typeof args?.lobbyId === "string" ? args.lobbyId : ""; + const lobbyInfo = args?.lobbyInfo as GameInfo | PublicGameInfo | undefined; if (lobbyId) { this.startTrackingLobby(lobbyId, lobbyInfo); // If opened with lobbyId but no lobbyInfo (URL join case), auto-join the lobby diff --git a/src/client/LanguageModal.ts b/src/client/LanguageModal.ts index 1bc9b3da0d..9e963c092b 100644 --- a/src/client/LanguageModal.ts +++ b/src/client/LanguageModal.ts @@ -1,8 +1,7 @@ -import { html } from "lit"; +import { html, TemplateResult } from "lit"; import { customElement, property } from "lit/decorators.js"; import { translateText } from "../client/Utils"; import { assetUrl } from "../core/AssetUrls"; -import "./components/baseComponents/Modal"; import { BaseModal } from "./components/BaseModal"; import { modalHeader } from "./components/ui/ModalHeader"; @@ -29,105 +28,83 @@ export class LanguageModal extends BaseModal { this.close(); }; - render() { - const content = html` -
- - ${modalHeader({ - title: translateText("select_lang.title"), - onBack: () => this.close(), - ariaLabel: translateText("common.back"), - })} + protected renderHeaderSlot() { + return modalHeader({ + title: translateText("select_lang.title"), + onBack: () => this.close(), + ariaLabel: translateText("common.back"), + }); + } + protected renderBody(): TemplateResult { + return html` +
-
- ${this.languageList.map((lang) => { - const isActive = this.currentLang === lang.code; - const isDebug = lang.code === "debug"; + ${this.languageList.map((lang) => { + const isActive = this.currentLang === lang.code; + const isDebug = lang.code === "debug"; - let buttonClasses = - "relative group rounded-xl border transition-all duration-200 flex items-center p-3 gap-3 w-full cursor-pointer"; + let buttonClasses = + "relative group rounded-xl border transition-all duration-200 flex items-center p-3 gap-3 w-full cursor-pointer"; - if (isDebug) { - buttonClasses += - " animate-pulse font-bold text-white border-2 border-dashed border-cyan-400 shadow-[0_0_15px_rgba(34,211,238,0.2)] bg-gradient-to-r from-red-600 via-yellow-600 via-green-600 via-blue-600 to-purple-600"; - } else if (isActive) { - buttonClasses += " bg-malibu-blue/20 border-malibu-blue/50"; - } else { - buttonClasses += - " bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20"; - } + if (isDebug) { + buttonClasses += + " animate-pulse font-bold text-white border-2 border-dashed border-cyan-400 shadow-[0_0_15px_rgba(34,211,238,0.2)] bg-gradient-to-r from-red-600 via-yellow-600 via-green-600 via-blue-600 to-purple-600"; + } else if (isActive) { + buttonClasses += " bg-malibu-blue/20 border-malibu-blue/50"; + } else { + buttonClasses += + " bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20"; + } - return html` - - `; - })} -
-
+ ${isActive + ? html` +
+ + + +
+ ` + : ""} + + `; + })}
`; - - if (this.inline) { - return content; - } - - return html` - - ${content} - - `; } } diff --git a/src/client/LeaderboardModal.ts b/src/client/LeaderboardModal.ts index c167f87084..511400602e 100644 --- a/src/client/LeaderboardModal.ts +++ b/src/client/LeaderboardModal.ts @@ -10,7 +10,6 @@ import { translateText } from "./Utils"; @customElement("leaderboard-modal") export class LeaderboardModal extends BaseModal { - @state() private activeTab: "players" | "clans" = "players"; @state() private clanDateRange: { start: string; end: string } | null = null; @@ -21,10 +20,26 @@ export class LeaderboardModal extends BaseModal { private loadToken = 0; + protected modalConfig() { + return { + tabs: [ + { + key: "players", + label: translateText("leaderboard_modal.ranked_tab"), + }, + { key: "clans", label: translateText("leaderboard_modal.clans_tab") }, + ], + }; + } + protected onOpen(): void { this.loadActiveTabData(); } + protected onTabEnter(): void { + this.loadActiveTabData(); + } + private loadActiveTabData() { const token = ++this.loadToken; @@ -54,18 +69,13 @@ export class LeaderboardModal extends BaseModal { })(); } - private handleTabChange(tab: "clans" | "players") { - this.activeTab = tab; - this.loadActiveTabData(); - } - private handleClanDateRangeChange( event: CustomEvent<{ start: string; end: string }>, ) { this.clanDateRange = event.detail; } - render() { + protected renderHeaderSlot() { let dateRange = html``; if (this.clanDateRange) { const start = new Date(this.clanDateRange.start).toLocaleDateString(); @@ -80,54 +90,36 @@ export class LeaderboardModal extends BaseModal { >(${translateText("leaderboard_modal.refresh_time")})`; - const tabs = [ - { - key: "players", - label: translateText("leaderboard_modal.ranked_tab"), - }, - { key: "clans", label: translateText("leaderboard_modal.clans_tab") }, - ]; + return modalHeader({ + titleContent: html` +
+ + ${translateText("leaderboard_modal.title")} + + ${this.activeTab === "clans" ? dateRange : ""} + ${this.activeTab === "players" ? refreshTime : ""} +
+ `, + onBack: () => this.close(), + ariaLabel: translateText("common.close"), + }); + } + protected renderBody() { return html` - - this.handleTabChange(key as "players" | "clans")} - > -
- ${modalHeader({ - titleContent: html` -
- - ${translateText("leaderboard_modal.title")} - - ${this.activeTab === "clans" ? dateRange : ""} - ${this.activeTab === "players" ? refreshTime : ""} -
- `, - onBack: () => this.close(), - ariaLabel: translateText("common.close"), - })} -
-
- - , - ) => this.handleClanDateRangeChange(event)} - > -
-
+
+ + , + ) => this.handleClanDateRangeChange(event)} + > +
`; } } diff --git a/src/client/Main.ts b/src/client/Main.ts index 6c7b713720..2be193451a 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -611,7 +611,7 @@ class Client { // On low end-chromebooks the join modal was not registered in time. await new Promise((resolve) => setTimeout(resolve, 2000)); window.showPage?.("page-join-lobby"); - this.joinModal?.open(lobbyId); + this.joinModal?.open({ lobbyId }); console.log(`CrazyGames: joining lobby ${lobbyId} from invite param`); return; } @@ -721,7 +721,7 @@ class Client { pathMatch && GAME_ID_REGEX.test(pathMatch[1]) ? pathMatch[1] : null; if (lobbyId) { window.showPage?.("page-join-lobby"); - this.joinModal.open(lobbyId); + this.joinModal.open({ lobbyId }); console.log(`joining lobby ${lobbyId}`); return; } @@ -729,7 +729,7 @@ class Client { const affiliateCode = decodedHash.replace("#affiliate=", ""); strip(); if (affiliateCode) { - this.storeModal?.open(affiliateCode); + this.storeModal?.open({ affiliateCode }); } } if (decodedHash.startsWith("#refresh")) { @@ -776,7 +776,10 @@ class Client { document.body.classList.remove("in-game"); } if (lobby.source === "public") { - this.joinModal?.open(lobby.gameID, lobby.publicLobbyInfo); + this.joinModal?.open({ + lobbyId: lobby.gameID, + lobbyInfo: lobby.publicLobbyInfo, + }); } // Only update URL immediately for private lobbies, not public ones if (lobby.source !== "public") { diff --git a/src/client/Matchmaking.ts b/src/client/Matchmaking.ts index f0f02a1120..146a6cf1e5 100644 --- a/src/client/Matchmaking.ts +++ b/src/client/Matchmaking.ts @@ -28,39 +28,24 @@ export class MatchmakingModal extends BaseModal { return this; } - render() { + protected renderHeaderSlot() { + return modalHeader({ + title: translateText("matchmaking_modal.title"), + onBack: () => this.close(), + ariaLabel: translateText("common.back"), + }); + } + + protected renderBody() { const eloDisplay = html`

${translateText("matchmaking_modal.elo", { elo: this.elo })}

`; - - const content = html` -
- ${modalHeader({ - title: translateText("matchmaking_modal.title"), - onBack: () => this.close(), - ariaLabel: translateText("common.back"), - })} -
- ${eloDisplay} ${this.renderInner()} -
-
- `; - - if (this.inline) { - return content; - } - return html` - - ${content} - +
+ ${eloDisplay} ${this.renderInner()} +
`; } diff --git a/src/client/NewsModal.ts b/src/client/NewsModal.ts index e5eafdac15..1607af3627 100644 --- a/src/client/NewsModal.ts +++ b/src/client/NewsModal.ts @@ -4,7 +4,6 @@ import { customElement, property, query } from "lit/decorators.js"; import version from "resources/version.txt?raw"; import { translateText } from "../client/Utils"; import { assetUrl } from "../core/AssetUrls"; -import "./components/baseComponents/Modal"; import { BaseModal } from "./components/BaseModal"; import { modalHeader } from "./components/ui/ModalHeader"; import { normalizeNewsMarkdown } from "./NewsMarkdown"; @@ -15,46 +14,31 @@ export class NewsModal extends BaseModal { private initialized: boolean = false; - render() { - const content = html` -
- ${modalHeader({ - title: translateText("news.title"), - onBack: () => this.close(), - ariaLabel: translateText("common.back"), - })} -
- ${resolveMarkdown(this.markdown, { - includeImages: true, - includeCodeBlockClassNames: true, - })} -
-
- `; - - if (this.inline) { - return content; - } + protected renderHeaderSlot() { + return modalHeader({ + title: translateText("news.title"), + onBack: () => this.close(), + ariaLabel: translateText("common.back"), + }); + } + protected renderBody() { return html` - - ${content} - + ${resolveMarkdown(this.markdown, { + includeImages: true, + includeCodeBlockClassNames: true, + })} +
`; } diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index 682be4d04d..97ef29c942 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -169,7 +169,34 @@ export class SinglePlayerModal extends BaseModal { this.mapWins = winsMap; } - render() { + protected renderHeaderSlot() { + return modalHeader({ + title: translateText("main.solo") || "Solo", + onBack: () => this.close(), + ariaLabel: translateText("common.back"), + rightContent: hasLinkedAccount(this.userMeResponse) + ? html`` + : this.renderNotLoggedInBanner(), + }); + } + + protected renderBody() { const inputCards = [ html``, ]; - const content = html` -
- - ${modalHeader({ - title: translateText("main.solo") || "Solo", - onBack: () => this.close(), - ariaLabel: translateText("common.back"), - rightContent: hasLinkedAccount(this.userMeResponse) - ? html`` - : this.renderNotLoggedInBanner(), - })} - -
+ return html` +
- ${hasLinkedAccount(this.userMeResponse) && this.hasOptionsChanged() - ? html`
- ${translateText("single_modal.options_changed_no_achievements")} -
` - : null} + ${ + hasLinkedAccount(this.userMeResponse) && this.hasOptionsChanged() + ? html`
+ ${translateText( + "single_modal.options_changed_no_achievements", + )} +
` + : null + }
`; - - if (this.inline) { - return content; - } - - return html` - - ${content} - - `; } // Check if any options other than map and difficulty have been changed from defaults diff --git a/src/client/Store.ts b/src/client/Store.ts index 08f15daabb..9480450d87 100644 --- a/src/client/Store.ts +++ b/src/client/Store.ts @@ -1,6 +1,6 @@ import type { TemplateResult } from "lit"; import { html } from "lit"; -import { customElement, state } from "lit/decorators.js"; +import { customElement } from "lit/decorators.js"; import { UserMeResponse } from "../core/ApiSchemas"; import { Cosmetics } from "../core/CosmeticSchemas"; import { UserSettings } from "../core/game/UserSettings"; @@ -15,16 +15,29 @@ import { } from "./Cosmetics"; import { translateText } from "./Utils"; +type StoreTab = "patterns" | "flags" | "packs" | "subscriptions"; + @customElement("store-modal") export class StoreModal extends BaseModal { - @state() private activeTab: "patterns" | "flags" | "packs" | "subscriptions" = - "patterns"; - private cosmetics: Cosmetics | null = null; - private isActive = false; private affiliateCode: string | null = null; private userMeResponse: UserMeResponse | false = false; + protected modalConfig() { + if (this.affiliateCode) { + // Affiliate mode: hide tabs, show only items associated with the code. + return {}; + } + return { + tabs: [ + { key: "packs", label: translateText("store.packs") }, + { key: "subscriptions", label: translateText("store.subscriptions") }, + { key: "patterns", label: translateText("store.patterns") }, + { key: "flags", label: translateText("store.flags") }, + ], + }; + } + connectedCallback() { super.connectedCallback(); document.addEventListener( @@ -188,71 +201,72 @@ export class StoreModal extends BaseModal { `; } - render() { - if (!this.isActive && !this.inline) return html``; + protected renderHeaderSlot() { + return this.renderHeader(); + } - const tabs = [ - { key: "packs", label: translateText("store.packs") }, - { key: "subscriptions", label: translateText("store.subscriptions") }, - { key: "patterns", label: translateText("store.patterns") }, - { key: "flags", label: translateText("store.flags") }, - ]; + protected renderBody(key: string): TemplateResult { + if (this.affiliateCode) { + return this.renderAffiliateGrid(); + } + switch (key as StoreTab) { + case "patterns": + return this.renderPatternGrid(); + case "flags": + return this.renderFlagGrid(); + case "subscriptions": + return this.renderSubscriptionGrid(); + case "packs": + default: + return this.renderPackGrid(); + } + } - const grid = - this.activeTab === "patterns" - ? this.renderPatternGrid() - : this.activeTab === "flags" - ? this.renderFlagGrid() - : this.activeTab === "subscriptions" - ? this.renderSubscriptionGrid() - : this.renderPackGrid(); + private renderAffiliateGrid(): TemplateResult { + const items = resolveCosmetics( + this.cosmetics, + this.userMeResponse, + this.affiliateCode, + ).filter( + (r) => + (r.type === "pattern" || r.type === "flag" || r.type === "pack") && + r.relationship === "purchasable", + ); + + if (items.length === 0) { + return html`
+ ${translateText("store.no_skins")} +
`; + } return html` - - (this.activeTab = key as - | "patterns" - | "flags" - | "packs" - | "subscriptions")} +
-
${this.renderHeader()}
- ${grid} - + ${items.map( + (r) => html` + + `, + )} +
`; } - public async open(options?: string | { affiliateCode?: string }) { - if (this.isModalOpen) return; - this.isActive = true; - if (typeof options === "string") { - this.affiliateCode = options; - } else if ( - options !== null && - typeof options === "object" && - !Array.isArray(options) - ) { - this.affiliateCode = options.affiliateCode ?? null; - } else { - this.affiliateCode = null; - } - + protected async onOpen(args?: Record) { + const affiliate = + typeof args?.affiliateCode === "string" ? args.affiliateCode : null; + this.affiliateCode = affiliate; this.cosmetics ??= await fetchCosmetics(); await this.refresh(); - super.open(); } - public close() { - this.isActive = false; + protected onClose(): void { this.affiliateCode = null; - super.close(); } public async refresh() { diff --git a/src/client/TerritoryPatternsModal.ts b/src/client/TerritoryPatternsModal.ts index 605b240132..3a0e91a54a 100644 --- a/src/client/TerritoryPatternsModal.ts +++ b/src/client/TerritoryPatternsModal.ts @@ -122,66 +122,49 @@ export class TerritoryPatternsModal extends BaseModal { `; } - render() { - const content = html` -
-
- ${modalHeader({ - title: translateText("territory_patterns.title"), - onBack: () => this.close(), - ariaLabel: translateText("common.back"), - rightContent: html``, - })} - -
- + ${modalHeader({ + title: translateText("territory_patterns.title"), + onBack: () => this.close(), + ariaLabel: translateText("common.back"), + rightContent: html``, + })} + +
+ -
-
-
- { - this.close(); - window.showPage?.("page-item-store"); - }} - > -
-
- ${this.renderPatternGrid()} + type="text" + placeholder=${translateText("territory_patterns.search")} + .value=${this.search} + @change=${this.handleSearch} + @keyup=${this.handleSearch} + />
`; + } - if (this.inline) { - return content; - } - + protected renderBody() { return html` - - ${content} - +
+ { + this.close(); + window.showPage?.("page-item-store"); + }} + > +
+
${this.renderPatternGrid()}
`; } diff --git a/src/client/TokenLoginModal.ts b/src/client/TokenLoginModal.ts index 9c968e414d..f4d0d45ae9 100644 --- a/src/client/TokenLoginModal.ts +++ b/src/client/TokenLoginModal.ts @@ -22,35 +22,23 @@ export class TokenLoginModal extends BaseModal { super(); } - render() { - const title = translateText("token_login_modal.title"); - const content = html` -
- ${modalHeader({ - title, - onBack: () => this.close(), - ariaLabel: translateText("common.back"), - })} -
- ${this.email ? this.loginSuccess(this.email) : this.loggingIn()} -
-
- `; + protected modalConfig() { + return { maxWidth: "620px" }; + } - if (this.inline) { - return content; - } + protected renderHeaderSlot() { + return modalHeader({ + title: translateText("token_login_modal.title"), + onBack: () => this.close(), + ariaLabel: translateText("common.back"), + }); + } + protected renderBody() { return html` - - ${content} - +
+ ${this.email ? this.loginSuccess(this.email) : this.loggingIn()} +
`; } @@ -89,15 +77,6 @@ export class TokenLoginModal extends BaseModal { `; } - public open(): void { - if (!this.token) { - return; - } - super.open(); - clearInterval(this.retryInterval); - this.retryInterval = setInterval(() => this.tryLogin(), 3000); - } - public openWithToken(token: string): void { this.token = token; this.email = null; @@ -106,11 +85,22 @@ export class TokenLoginModal extends BaseModal { this.open(); } - public close() { + public open(args?: Record): void { + if (!this.token) { + return; + } + super.open(args); + } + + protected onOpen(): void { + clearInterval(this.retryInterval); + this.retryInterval = setInterval(() => this.tryLogin(), 3000); + } + + protected onClose(): void { this.token = null; clearInterval(this.retryInterval); this.attemptCount = 0; - super.close(); this.isAttemptingLogin = false; } diff --git a/src/client/TroubleshootingModal.ts b/src/client/TroubleshootingModal.ts index 46a783113f..7f433e4fbd 100644 --- a/src/client/TroubleshootingModal.ts +++ b/src/client/TroubleshootingModal.ts @@ -3,7 +3,6 @@ import { customElement, property } from "lit/decorators.js"; import { assetUrl } from "../core/AssetUrls"; import { translateText } from "./Utils"; import { BaseModal } from "./components/BaseModal"; -import "./components/baseComponents/Modal"; import { modalHeader } from "./components/ui/ModalHeader"; import { collectGraphicsDiagnostics, @@ -29,140 +28,117 @@ export class TroubleshootingModal extends BaseModal { this.initialized = true; } - render() { - const content = html` -
- ${modalHeader({ - titleContent: html`
+ + ${translateText("main.help")} - - ${translateText("main.help")} - / ${translateText("troubleshooting.title")} - - -
`, - onBack: () => this.close(), - ariaLabel: translateText("common.back"), - })} - ${this.loading - ? "" - : html` -
- ${this.section( - "", - html`${this.infoTip( - translateText("troubleshooting.hardware_acceleration_tip"), - true, - )}`, - )} - ${this.section( - translateText("troubleshooting.environment"), - html` - ${this.row( - translateText("troubleshooting.browser"), - this.diagnostics!.browser.engine, - )} - ${this.row( - translateText("troubleshooting.platform"), - this.diagnostics!.browser.platform, - )} - ${this.row( - translateText("troubleshooting.os"), - this.diagnostics!.browser.os, - )} - ${this.row( - translateText("troubleshooting.device_pixel_ratio"), - this.diagnostics!.browser.dpr, - )} - ${this.infoTip( - translateText("troubleshooting.chromium_tip"), - )} - `, - )} - ${this.section( - translateText("troubleshooting.rendering"), - html` - ${this.row( - translateText("troubleshooting.renderer"), - this.describeRenderer(this.diagnostics!.rendering), - )} - ${this.row( - translateText("troubleshooting.max_texture_size"), - this.diagnostics!.rendering.maxTextureSize ?? - translateText("troubleshooting.unknown"), - )} - ${this.row( - translateText("troubleshooting.high_precision_shaders"), - this.diagnostics!.rendering.shaderHighp === true - ? translateText("troubleshooting.yes") - : translateText("troubleshooting.no"), - )}${this.row( - translateText("troubleshooting.gpu"), - !this.diagnostics!.rendering.gpu || - this.diagnostics!.rendering.gpu.unavailable - ? translateText("troubleshooting.unavailable") - : `${this.diagnostics!.rendering.gpu.vendor} — ${this.diagnostics!.rendering.gpu.renderer}`, - )} - ${this.infoTip(translateText("troubleshooting.gpu_tip"))} - `, - )} - ${this.section( - translateText("troubleshooting.power"), - html` - ${this.diagnostics!.power.unavailable - ? this.row( - translateText("troubleshooting.battery"), - translateText("troubleshooting.unavailable"), - ) - : html` - ${this.row( - translateText("troubleshooting.charging"), - this.diagnostics!.power.charging - ? translateText("troubleshooting.yes") - : translateText("troubleshooting.no"), - )} - ${this.row( - translateText("troubleshooting.battery_level"), - this.diagnostics!.power.level, - )} - `} - ${this.infoTip( - translateText("troubleshooting.power_saving_tip"), - )} - `, - )} -
- `} -
- `; - - if (this.inline) { - return content; - } + / ${translateText("troubleshooting.title")} + + +
`, + onBack: () => this.close(), + ariaLabel: translateText("common.back"), + }); + } + protected renderBody() { + if (this.loading) return html``; return html` - - ${content} - +
+ ${this.section( + "", + html`${this.infoTip( + translateText("troubleshooting.hardware_acceleration_tip"), + true, + )}`, + )} + ${this.section( + translateText("troubleshooting.environment"), + html` + ${this.row( + translateText("troubleshooting.browser"), + this.diagnostics!.browser.engine, + )} + ${this.row( + translateText("troubleshooting.platform"), + this.diagnostics!.browser.platform, + )} + ${this.row( + translateText("troubleshooting.os"), + this.diagnostics!.browser.os, + )} + ${this.row( + translateText("troubleshooting.device_pixel_ratio"), + this.diagnostics!.browser.dpr, + )} + ${this.infoTip(translateText("troubleshooting.chromium_tip"))} + `, + )} + ${this.section( + translateText("troubleshooting.rendering"), + html` + ${this.row( + translateText("troubleshooting.renderer"), + this.describeRenderer(this.diagnostics!.rendering), + )} + ${this.row( + translateText("troubleshooting.max_texture_size"), + this.diagnostics!.rendering.maxTextureSize ?? + translateText("troubleshooting.unknown"), + )} + ${this.row( + translateText("troubleshooting.high_precision_shaders"), + this.diagnostics!.rendering.shaderHighp === true + ? translateText("troubleshooting.yes") + : translateText("troubleshooting.no"), + )}${this.row( + translateText("troubleshooting.gpu"), + !this.diagnostics!.rendering.gpu || + this.diagnostics!.rendering.gpu.unavailable + ? translateText("troubleshooting.unavailable") + : `${this.diagnostics!.rendering.gpu.vendor} — ${this.diagnostics!.rendering.gpu.renderer}`, + )} + ${this.infoTip(translateText("troubleshooting.gpu_tip"))} + `, + )} + ${this.section( + translateText("troubleshooting.power"), + html` + ${this.diagnostics!.power.unavailable + ? this.row( + translateText("troubleshooting.battery"), + translateText("troubleshooting.unavailable"), + ) + : html` + ${this.row( + translateText("troubleshooting.charging"), + this.diagnostics!.power.charging + ? translateText("troubleshooting.yes") + : translateText("troubleshooting.no"), + )} + ${this.row( + translateText("troubleshooting.battery_level"), + this.diagnostics!.power.level, + )} + `} + ${this.infoTip(translateText("troubleshooting.power_saving_tip"))} + `, + )} +
`; } @@ -237,13 +213,13 @@ export class TroubleshootingModal extends BaseModal { } public close(): void { + // Override BaseModal.close() to navigate back to Help (this modal is + // opened from inside HelpModal), not to page-play like other inline modals. this.unregisterEscapeHandler(); - + this.onClose(); if (this.inline) { this.style.pointerEvents = "none"; - if (window.showPage) { - window.showPage?.("page-help"); - } + window.showPage?.("page-help"); } else { this.modalEl?.close(); } diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts index 016945fc14..da9bb00844 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -17,8 +17,6 @@ export class UserSettingModal extends BaseModal { private userSettings: UserSettings = new UserSettings(); private readonly defaultKeybinds = getDefaultKeybinds(Platform.isMac); - @state() private activeTab: "basic" | "keybinds" = "basic"; - @state() private keySequence: string[] = []; @state() private showEasterEggSettings = false; @@ -322,40 +320,31 @@ export class UserSettingModal extends BaseModal { this.userSettings.togglePerformanceOverlay(); } - render() { - const activeContent = - this.activeTab === "basic" - ? this.renderBasicSettings() - : this.renderKeybindSettings(); + protected modalConfig() { + return { + tabs: [ + { key: "basic", label: translateText("user_setting.tab_basic") }, + { key: "keybinds", label: translateText("user_setting.tab_keybinds") }, + ], + }; + } - const tabs = [ - { key: "basic", label: translateText("user_setting.tab_basic") }, - { key: "keybinds", label: translateText("user_setting.tab_keybinds") }, - ]; + protected renderHeaderSlot() { + return modalHeader({ + title: translateText("user_setting.title"), + onBack: () => this.close(), + ariaLabel: translateText("common.back"), + showDivider: true, + }); + } + protected renderBody(tab: string) { + const body = + tab === "keybinds" + ? this.renderKeybindSettings() + : this.renderBasicSettings(); return html` - - (this.activeTab = key as "basic" | "keybinds")} - > -
- ${modalHeader({ - title: translateText("user_setting.title"), - onBack: () => this.close(), - ariaLabel: translateText("common.back"), - showDivider: true, - })} -
-
- ${activeContent} -
-
+
${body}
`; } @@ -944,8 +933,4 @@ export class UserSettingModal extends BaseModal { window.addEventListener("keydown", this.handleEasterEggKey); this.loadKeybindsFromStorage(); } - - public open() { - super.open(); - } } diff --git a/src/client/components/BaseModal.ts b/src/client/components/BaseModal.ts index 26284c248d..aba455fa4d 100644 --- a/src/client/components/BaseModal.ts +++ b/src/client/components/BaseModal.ts @@ -1,29 +1,47 @@ import { html, LitElement, TemplateResult } from "lit"; import { property, query, state } from "lit/decorators.js"; +import "./baseComponents/Modal"; +import type { OModalTab } from "./baseComponents/Modal"; /** - * Base class for modal components that provides unified Escape key handling and common modal patterns. + * Static-ish configuration for the shell. + * Subclasses return a fresh object from modalConfig(); avoid heavy work — it's + * read on every render() and during open()/setActiveTab(). + */ +export interface ModalConfig { + title?: string; + tabs?: OModalTab[]; + hideHeader?: boolean; + hideCloseButton?: boolean; + alwaysMaximized?: boolean; + maxWidth?: string; +} + +/** + * Base class for modal components. * - * Features: - * - Visibility tracking with isModalOpen state - * - Escape key handler with visibility check and target validation - * - Automatic listener lifecycle management - * - Common inline/modal element handling - * - Shared open/close logic with hooks for custom behavior - * - Standardized loading spinner UI - * - Consistent modal container styling + * BaseModal renders the shell itself — subclasses provide content + * via renderContent() (or renderTab() for tabbed modals) and declare + * configuration via modalConfig(). + * + * Lifecycle: + * open(args?) → onOpen(args) hook → shell visible + * close(args?) → onClose(args) hook → shell hidden + * + * Tabs (optional): + * Return a non-empty tabs[] from modalConfig(). BaseModal owns activeTab + * state and dispatches rendering to renderTab(key). Subclasses can opt in + * to onTabEnter(key) for per-tab lifecycle (e.g. lazy load). */ export abstract class BaseModal extends LitElement { @state() protected isModalOpen = false; + @state() protected activeTab = ""; @property({ type: Boolean }) inline = false; - /** - * Standard modal container class string. - * Provides consistent dark glassmorphic styling across all modals. - * No rounding on mobile for full-screen appearance. - */ - protected readonly modalContainerClass = - "h-full flex flex-col overflow-hidden bg-black/70 backdrop-blur-xl lg:rounded-2xl lg:border border-white/10"; + // Re-entrancy guard: showPage() (for inline modals) re-invokes .open() + // with no args after we call it. We must not re-run onOpen(undefined) + // from that nested call, which would clobber state set by the outer call. + private opening = false; @query("o-modal") protected modalEl?: HTMLElement & { open: () => void; @@ -31,14 +49,165 @@ export abstract class BaseModal extends LitElement { onClose?: () => void; }; + // ---- Subclass configuration ---- + // Override modalConfig() to configure the rendered . Defaults match + // the most common shape (custom in-content header, no built-in close button). + + protected modalConfig(): ModalConfig { + return {}; + } + + /** Render slot="header" content. Default: no header slot. */ + protected renderHeaderSlot(): TemplateResult | null { + return null; + } + + /** + * Render the modal body. For tabbed modals, switch on `tab` to render the + * appropriate panel. Modals without tabs can ignore the argument. + */ + protected renderBody(_tab: string): TemplateResult { + return html``; + } + + // ---- Lifecycle hooks ---- + + /** Called when the modal opens. Receives router args / direct-caller args. */ + protected onOpen(_args?: Record): void {} + + /** Called when the modal closes. */ + protected onClose(_args?: Record): void {} + + /** Called when the active tab changes (including initial set on open). */ + protected onTabEnter(_key: string): void {} + + /** + * Guard called before closing via Escape key or click-outside. + * Return false to prevent the modal from closing. + */ + public confirmBeforeClose(): boolean { + return true; + } + + // ---- Rendering ---- + createRenderRoot() { return this; } + protected willUpdate(): void { + // Default the active tab so the highlight is correct on first render, + // before open() runs (matters for inline modals rendered on page mount). + const tabs = this.modalConfig().tabs ?? []; + if (tabs.length && this.activeTab === "") { + this.activeTab = tabs[0].key; + } + } + + render(): TemplateResult { + const cfg = this.modalConfig(); + const tabs = cfg.tabs ?? []; + const body = this.renderBody(this.activeTab); + const headerSlot = this.renderHeaderSlot(); + + return html` + this.setActiveTab(key)} + > + ${headerSlot ? html`
${headerSlot}
` : null} + ${body} +
+ `; + } + + // ---- Open / close ---- + public isOpen(): boolean { return this.isModalOpen; } + /** + * Open the modal. `args` is a loose bag forwarded to onOpen(). The router + * passes parsed URL params; direct callers can pass whatever they want. + * + * Recognized keys: + * - tab: string — sets active tab (validated against modalTabs) + */ + public open(args?: Record): void { + if (this.opening) return; + this.opening = true; + try { + const tabs = this.modalConfig().tabs ?? []; + 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 wasOpen = this.isModalOpen; + if (!wasOpen) { + this.registerEscapeHandler(); + } + this.onOpen(args); + if (this.activeTab) this.onTabEnter(this.activeTab); + + if (wasOpen) return; + + if (this.inline) { + const needsShow = + this.classList.contains("hidden") || this.style.display === "none"; + if (needsShow && window.showPage) { + const pageId = this.id || this.tagName.toLowerCase(); + window.showPage?.(pageId); + } + this.style.pointerEvents = "auto"; + } else { + this.modalEl?.open(); + } + } finally { + this.opening = false; + } + } + + public close(args?: Record): void { + this.unregisterEscapeHandler(); + this.onClose(args); + + if (this.inline) { + this.style.pointerEvents = "none"; + if (window.showPage) { + window.showPage?.("page-play"); + } + } else { + this.modalEl?.close(); + } + } + + // ---- Tab management ---- + + /** Programmatically change the active tab. Triggers onTabEnter. */ + public setActiveTab(key: string): void { + const tabs = this.modalConfig().tabs ?? []; + if (!tabs.some((t) => t.key === key)) return; + if (this.activeTab === key) return; + this.activeTab = key; + this.onTabEnter(key); + } + + // ---- Internals ---- + protected firstUpdated(): void { if (this.modalEl) { this.modalEl.onClose = () => { @@ -59,10 +228,6 @@ export abstract class BaseModal extends LitElement { super.disconnectedCallback(); } - /** - * Handle Escape key press to close the modal. - * Only closes if the modal is open. - */ private handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape" && this.isModalOpen) { e.preventDefault(); @@ -73,87 +238,16 @@ export abstract class BaseModal extends LitElement { } }; - /** - * Register the Escape key handler and mark modal as open. - */ protected registerEscapeHandler() { this.isModalOpen = true; window.addEventListener("keydown", this.handleKeyDown); } - /** - * Unregister the Escape key handler and mark modal as closed. - */ protected unregisterEscapeHandler() { this.isModalOpen = false; window.removeEventListener("keydown", this.handleKeyDown); } - /** - * Hook for custom logic when modal opens. - * Override this in subclasses to add custom open behavior. - */ - protected onOpen(): void { - // Default implementation does nothing - } - - /** - * Hook for custom logic when modal closes. - * Override this in subclasses to add custom close behavior. - */ - protected onClose(): void { - // Default implementation does nothing - } - - /** - * Guard called before closing via Escape key or click-outside. - * Override in subclasses to show a confirmation dialog. - * Return false to prevent the modal from closing. - */ - public confirmBeforeClose(): boolean { - return true; - } - - /** - * Open the modal. Handles both inline and modal element modes. - * Subclasses can override onOpen() for custom behavior. - */ - public open(): void { - if (this.isModalOpen) return; - this.registerEscapeHandler(); - this.onOpen(); - - if (this.inline) { - const needsShow = - this.classList.contains("hidden") || this.style.display === "none"; - if (needsShow && window.showPage) { - const pageId = this.id || this.tagName.toLowerCase(); - window.showPage?.(pageId); - } - this.style.pointerEvents = "auto"; - } else { - this.modalEl?.open(); - } - } - - /** - * Close the modal. Handles both inline and modal element modes. - * Subclasses can override onClose() for custom behavior. - */ - public close(): void { - this.unregisterEscapeHandler(); - this.onClose(); - - if (this.inline) { - this.style.pointerEvents = "none"; - if (window.showPage) { - window.showPage?.("page-play"); - } - } else { - this.modalEl?.close(); - } - } - protected renderLoadingSpinner( message?: string, spinnerColor: "blue" | "green" | "yellow" | "white" = "blue", diff --git a/src/client/components/RankedModal.ts b/src/client/components/RankedModal.ts index cc10defa80..bebf8fbd50 100644 --- a/src/client/components/RankedModal.ts +++ b/src/client/components/RankedModal.ts @@ -78,49 +78,40 @@ export class RankedModal extends BaseModal { return this; } - render() { - const content = html` -
- ${modalHeader({ - title: translateText("mode_selector.ranked_title"), - onBack: () => this.close(), - ariaLabel: translateText("common.back"), - })} -
-
- ${this.renderCard( - translateText("mode_selector.ranked_1v1_title"), - this.errorMessage ?? - (hasLinkedAccount(this.userMeResponse) - ? translateText("matchmaking_modal.elo", { elo: this.elo }) - : translateText("mode_selector.ranked_title")), - () => this.handleRanked(), - )} - ${this.renderDisabledCard( - translateText("mode_selector.ranked_2v2_title"), - translateText("mode_selector.coming_soon"), - )} - ${this.renderDisabledCard( - translateText("mode_selector.coming_soon"), - "", - )} - ${this.renderDisabledCard( - translateText("mode_selector.coming_soon"), - "", - )} -
-
-
- `; - - if (this.inline) { - return content; - } + protected renderHeaderSlot() { + return modalHeader({ + title: translateText("mode_selector.ranked_title"), + onBack: () => this.close(), + ariaLabel: translateText("common.back"), + }); + } + protected renderBody() { return html` - - ${content} - +
+
+ ${this.renderCard( + translateText("mode_selector.ranked_1v1_title"), + this.errorMessage ?? + (hasLinkedAccount(this.userMeResponse) + ? translateText("matchmaking_modal.elo", { elo: this.elo }) + : translateText("mode_selector.ranked_title")), + () => this.handleRanked(), + )} + ${this.renderDisabledCard( + translateText("mode_selector.ranked_2v2_title"), + translateText("mode_selector.coming_soon"), + )} + ${this.renderDisabledCard( + translateText("mode_selector.coming_soon"), + "", + )} + ${this.renderDisabledCard( + translateText("mode_selector.coming_soon"), + "", + )} +
+
`; }