From 0fc80e931ef56a9ac809e58a2222e853f0ed5436 Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:49:44 +0100 Subject: [PATCH] convert stats modal to embed instead of popup (#4626) ## Description: convert stats modal to embed instead of popup before: image after: image also has context to remember where we were in the scrolled list, so pressing "back" keeps the position ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: w.o.n --- index.html | 1 - resources/lang/en.json | 4 +- src/client/AccountModal.ts | 80 +++++- src/client/GameInfoModal.ts | 202 -------------- src/client/LangSelector.ts | 3 + src/client/Main.ts | 5 - src/client/components/BaseModal.ts | 8 +- src/client/components/baseComponents/Modal.ts | 16 +- .../baseComponents/stats/GameInfoView.ts | 233 ++++++++++++++++ .../stats/PlayerGameHistoryView.ts | 24 +- .../client/AccountGameStatsNavigation.test.ts | 231 +++++++++++++++ tests/client/GameInfoView.test.ts | 263 ++++++++++++++++++ 12 files changed, 833 insertions(+), 237 deletions(-) delete mode 100644 src/client/GameInfoModal.ts create mode 100644 src/client/components/baseComponents/stats/GameInfoView.ts create mode 100644 tests/client/AccountGameStatsNavigation.test.ts create mode 100644 tests/client/GameInfoView.test.ts diff --git a/index.html b/index.html index fc658e653..f2e53c14f 100644 --- a/index.html +++ b/index.html @@ -362,7 +362,6 @@ - diff --git a/resources/lang/en.json b/resources/lang/en.json index cf943f929..b4352e8c9 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -553,6 +553,7 @@ "duration": "Duration", "economy": "Economy", "hydros": "Hydros", + "load_failed": "Couldn't load game stats.", "loading_game_info": "Loading game stats", "mirv": "MIRV", "naval_trade": "Tradeship", @@ -561,9 +562,9 @@ "num_of_conquests_humans": "Player kills", "pirate": "Pirate", "players": "Players", + "retry": "Retry", "stolen_gold": "Stolen with warships", "survival_time": "Survival time", - "title": "Game info", "total_gold": "Total", "trade": "Trade", "train_trade": "Train", @@ -891,7 +892,6 @@ "copyright": "© OpenFront™ and Contributors", "create": "Create Lobby", "discord_avatar_alt": "Discord profile avatar", - "game_info": "Game info", "github": "GitHub", "go_to_troubleshooting": "Go to our troubleshooting page", "help": "Help", diff --git a/src/client/AccountModal.ts b/src/client/AccountModal.ts index 562005445..8145f3bed 100644 --- a/src/client/AccountModal.ts +++ b/src/client/AccountModal.ts @@ -19,6 +19,7 @@ import { sendMagicLink, } from "./Auth"; import "./components/baseComponents/stats/DiscordUserHeader"; +import "./components/baseComponents/stats/GameInfoView"; import "./components/baseComponents/stats/PlayerGameHistoryView"; import type { PlayerGameHistoryCache } from "./components/baseComponents/stats/PlayerGameHistoryView"; import "./components/baseComponents/stats/PlayerStatsTable"; @@ -34,6 +35,7 @@ import "./components/SubscriptionPanel"; import { modalHeader } from "./components/ui/ModalHeader"; import { fetchCosmetics } from "./Cosmetics"; import { crazyGamesSDK, type CrazyGamesUser } from "./CrazyGamesSDK"; +import { modalRouter } from "./ModalRouter"; import { translateText } from "./Utils"; @customElement("account-modal") @@ -51,6 +53,8 @@ export class AccountModal extends BaseModal { private statsTree: PlayerStatsTree | null = null; // Preserves the Games tab's accumulated list + cursor across tab switches. private gameHistoryCache: PlayerGameHistoryCache | null = null; + @state() private selectedGameId: string | null = null; + private gamesScrollTop = 0; private cosmetics: Cosmetics | null = null; constructor() { @@ -68,13 +72,11 @@ export class AccountModal extends BaseModal { // different account) so stats/history from the previous player don't // linger. if (this.userMeResponse?.player?.publicId !== previousPublicId) { - this.statsTree = null; - this.gameHistoryCache = null; + this.resetPlayerData(); this.requestUpdate(); } } else { - this.statsTree = null; - this.gameHistoryCache = null; + this.resetPlayerData(); this.requestUpdate(); } }); @@ -103,6 +105,14 @@ export class AccountModal extends BaseModal { } protected renderHeaderSlot() { + if (this.selectedGameId) { + return modalHeader({ + title: translateText("game_list.stats"), + onBack: () => this.backToGames(), + ariaLabel: translateText("common.back"), + }); + } + const isLoggedIn = !!this.userMeResponse?.user; const publicId = this.userMeResponse?.player?.publicId ?? ""; const displayId = publicId || translateText("account_modal.not_found"); @@ -144,6 +154,9 @@ export class AccountModal extends BaseModal { if (this.isLoadingUser || !this.isLinkedAccount()) { return {}; } + if (this.selectedGameId) { + return {}; + } return { tabs: [ { key: "account", label: translateText("account_modal.tab_account") }, @@ -168,6 +181,15 @@ export class AccountModal extends BaseModal { : this.renderLoginOptions()} `; } + if (this.selectedGameId) { + return html` +
+
+ +
+
+ `; + } return html`
${this.renderTab(tab)}
@@ -423,6 +445,8 @@ export class AccountModal extends BaseModal { @history-updated=${(e: CustomEvent) => { this.gameHistoryCache = e.detail; }} + @view-stats=${(e: CustomEvent<{ gameId: string }>) => + this.openGameStats(e.detail.gameId)} @view-game=${(e: CustomEvent<{ gameId: string }>) => void this.viewGame(e.detail.gameId)} > @@ -576,6 +600,40 @@ export class AccountModal extends BaseModal { ); } + private openGameStats(gameId: string): void { + this.gamesScrollTop = this.modalEl?.getScrollTop() ?? 0; + this.modalEl?.setScrollTop(0); + this.selectedGameId = gameId; + modalRouter.syncArgs(this.routerName, { tab: null, gameID: gameId }); + } + + private backToGames(): void { + this.selectedGameId = null; + modalRouter.syncArgs(this.routerName, { gameID: null, tab: "games" }); + void this.restoreGamesScroll(); + } + + private async restoreGamesScroll(): Promise { + await this.updateComplete; + await this.modalEl?.updateComplete; + const historyView = this.querySelector< + HTMLElement & { updateComplete?: Promise } + >("player-game-history-view"); + await historyView?.updateComplete; + this.modalEl?.setScrollTop(this.gamesScrollTop); + } + + private resetGameStatsNavigation(): void { + this.selectedGameId = null; + this.gamesScrollTop = 0; + } + + private resetPlayerData(): void { + this.statsTree = null; + this.gameHistoryCache = null; + this.resetGameStatsNavigation(); + } + private renderLogoutButton(): TemplateResult { return html` ): void { + if (args !== undefined) { + const gameId = + typeof args.gameID === "string" && args.gameID.length > 0 + ? args.gameID + : null; + this.resetGameStatsNavigation(); + if (gameId) { + this.activeTab = "games"; + this.selectedGameId = gameId; + this.modalEl?.setScrollTop(0); + } + } + this.isLoadingUser = true; this.handleLinkResult(args); @@ -802,6 +873,7 @@ export class AccountModal extends BaseModal { } protected onClose(): void { + this.resetGameStatsNavigation(); this.dispatchEvent( new CustomEvent("close", { bubbles: true, composed: true }), ); diff --git a/src/client/GameInfoModal.ts b/src/client/GameInfoModal.ts deleted file mode 100644 index 198a292d3..000000000 --- a/src/client/GameInfoModal.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { html, LitElement } from "lit"; -import { customElement, property, query, state } from "lit/decorators.js"; -import { GameEndInfo } from "../core/Schemas"; -import { GameMapType } from "../core/game/Game"; -import { fetchGameById } from "./Api"; -import { terrainMapFileLoader } from "./TerrainMapFileLoader"; -import { renderDuration, translateText } from "./Utils"; -import { - PlayerInfo, - Ranking, - RankType, -} from "./components/baseComponents/ranking/GameInfoRanking"; -import "./components/baseComponents/ranking/PlayerRow"; -import "./components/baseComponents/ranking/RankingControls"; -import "./components/baseComponents/ranking/RankingHeader"; - -@customElement("game-info-modal") -export class GameInfoModal extends LitElement { - @query("o-modal") private modalEl!: HTMLElement & { - open: () => void; - close: () => void; - }; - - @state() private mapImage: string | null = null; - @state() private gameInfo: GameEndInfo | null = null; - @state() private rankedPlayers: Array = []; - @property({ type: String }) gameId: string | null = null; - @property({ type: String }) rankType = RankType.Lifetime; - - @state() private currentClientID: string | null = null; - @state() private isLoadingGame: boolean = true; - - private ranking: Ranking | null = null; - - connectedCallback() { - super.connectedCallback(); - this.updateRanking(); - } - - createRenderRoot() { - return this; - } - - render() { - return html` - -
-
- ${this.isLoadingGame - ? this.renderLoadingAnimation() - : this.renderRanking()} -
-
-
- `; - } - - private renderRanking() { - if (this.rankedPlayers.length === 0) { - return html` -
-

❌ ${translateText("game_info_modal.no_winner")}

-
- `; - } - return html` - ${this.renderGameInfo()} - - ${this.renderSummaryTable()} - `; - } - - private renderLoadingAnimation() { - return html`
-

${translateText("game_info_modal.loading_game_info")}

-
-
`; - } - - private sort(e: CustomEvent) { - this.rankType = e.detail; - this.updateRanking(); - } - - private updateRanking() { - if (this.ranking) { - this.rankedPlayers = this.ranking.sortedBy(this.rankType); - } - } - - private renderGameInfo() { - const info = this.gameInfo; - if (!info) { - return html``; - } - return html` -
- ${this.mapImage - ? html`` - : html`
`} -
-
- ${info.config.gameMode} - ${info.config.gameMap} -
-
${renderDuration(info.duration)}
-
- ${info.players.length} ${translateText("game_info_modal.players")} -
-
-
- `; - } - - private renderSummaryTable() { - const bestScore = - this.rankedPlayers.length > 0 ? this.score(this.rankedPlayers[0]) : 0; - return html` -
    - - ${this.rankedPlayers.map( - (player: PlayerInfo, index) => html` - - `, - )} -
- `; - } - - public open() { - this.modalEl?.open(); - } - - public close() { - this.modalEl?.close(); - } - - private score(player: PlayerInfo): number { - if (!this.ranking) return 0; - return this.ranking.score(player, this.rankType); - } - - private async loadMapImage(gameMap: string) { - try { - const mapType = gameMap as GameMapType; - const data = terrainMapFileLoader.getMapData(mapType); - this.mapImage = data.webpPath; - } catch (error) { - console.error("Failed to load map image:", error); - } - } - - public async loadGame(gameId: string, currentClientID: string | null = null) { - try { - this.isLoadingGame = true; - this.currentClientID = currentClientID; - const session = await fetchGameById(gameId); - if (!session) return; - - this.gameInfo = session.info; - this.ranking = new Ranking(session); - this.updateRanking(); - await this.loadMapImage(session.info.config.gameMap); - } catch (err) { - console.error("Failed to load game:", err); - } finally { - this.isLoadingGame = false; - } - } -} diff --git a/src/client/LangSelector.ts b/src/client/LangSelector.ts index b5f427434..62d22672e 100644 --- a/src/client/LangSelector.ts +++ b/src/client/LangSelector.ts @@ -233,6 +233,9 @@ export class LangSelector extends LitElement { "news-modal", "news-button", "account-modal", + "game-info-view", + "ranking-controls", + "ranking-header", "leaderboard-modal", "flag-input-modal", "flag-input", diff --git a/src/client/Main.ts b/src/client/Main.ts index 38cfd17de..ef2f45ad2 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -30,7 +30,6 @@ import "./FlagInput"; import { FlagInput } from "./FlagInput"; import "./FlagInputModal"; import { FlagInputModal } from "./FlagInputModal"; -import { GameInfoModal } from "./GameInfoModal"; import "./GameModeSelector"; import { GameModeSelector } from "./GameModeSelector"; import { GameStartingModal } from "./GameStartingModal"; @@ -403,10 +402,6 @@ class Client { if (!hlpModal || !(hlpModal instanceof HelpModal)) { console.warn("Help modal element not found"); } - const giModal = document.querySelector("game-info-modal") as GameInfoModal; - if (!giModal || !(giModal instanceof GameInfoModal)) { - console.warn("Game info modal element not found"); - } const helpButton = document.getElementById("help-button"); if (helpButton) { helpButton.addEventListener("click", () => { diff --git a/src/client/components/BaseModal.ts b/src/client/components/BaseModal.ts index a317a8937..459884446 100644 --- a/src/client/components/BaseModal.ts +++ b/src/client/components/BaseModal.ts @@ -2,7 +2,7 @@ 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"; +import type { OModal, OModalTab } from "./baseComponents/Modal"; import "./ConfirmDialog"; /** @@ -51,11 +51,7 @@ export abstract class BaseModal extends LitElement { // from that nested call, which would clobber state set by the outer call. private opening = false; - @query("o-modal") protected modalEl?: HTMLElement & { - open: () => void; - close: () => void; - onClose?: () => void; - }; + @query("o-modal") protected modalEl?: OModal; // ---- Subclass configuration ---- // Override modalConfig() to configure the rendered . Defaults match diff --git a/src/client/components/baseComponents/Modal.ts b/src/client/components/baseComponents/Modal.ts index a8c6bc2b8..4cd2fc6df 100644 --- a/src/client/components/baseComponents/Modal.ts +++ b/src/client/components/baseComponents/Modal.ts @@ -1,5 +1,5 @@ import { LitElement, html } from "lit"; -import { customElement, property, state } from "lit/decorators.js"; +import { customElement, property, query, state } from "lit/decorators.js"; import { documentStylesSheet } from "./SharedStyles"; export type OModalTab = { key: string; label: string }; @@ -10,6 +10,8 @@ export class OModal extends LitElement { @state() public isModalOpen = false; + @query("[data-modal-scroll]") private scrollContainer?: HTMLElement; + static openCount = 0; @property({ type: Boolean }) @@ -62,6 +64,16 @@ export class OModal extends LitElement { } } + public getScrollTop(): number { + return this.scrollContainer?.scrollTop ?? 0; + } + + public setScrollTop(scrollTop: number): void { + if (this.scrollContainer) { + this.scrollContainer.scrollTop = scrollTop; + } + } + disconnectedCallback() { // Ensure global counter is decremented if this modal is removed while open. if (this.isModalOpen && !this.inline) { @@ -157,7 +169,7 @@ export class OModal extends LitElement {
${hasTabs ? this.renderTabs() : html``} -
+
diff --git a/src/client/components/baseComponents/stats/GameInfoView.ts b/src/client/components/baseComponents/stats/GameInfoView.ts new file mode 100644 index 000000000..59144b3d8 --- /dev/null +++ b/src/client/components/baseComponents/stats/GameInfoView.ts @@ -0,0 +1,233 @@ +import { html, LitElement, type PropertyValues } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { type GameEndInfo } from "../../../../core/Schemas"; +import { GameMode, type GameMapType } from "../../../../core/game/Game"; +import { fetchGameById } from "../../../Api"; +import { terrainMapFileLoader } from "../../../TerrainMapFileLoader"; +import { getMapName, renderDuration, translateText } from "../../../Utils"; +import { Ranking, RankType, type PlayerInfo } from "../ranking/GameInfoRanking"; +import "../ranking/PlayerRow"; +import "../ranking/RankingControls"; +import "../ranking/RankingHeader"; + +/** + * Game-stats content for the Account > Games > Stats drill-down. + */ +@customElement("game-info-view") +export class GameInfoView extends LitElement { + @property({ type: String }) gameId: string | null = null; + + @state() private rankType = RankType.Lifetime; + @state() private mapImage: string | null = null; + @state() private gameInfo: GameEndInfo | null = null; + @state() private rankedPlayers: PlayerInfo[] = []; + @state() private isLoadingGame = true; + @state() private loadFailed = false; + + private ranking: Ranking | null = null; + private requestedGameId: string | null = null; + // Loading a second game does not cancel the first request, so generations + // prevent a late response for the old game from replacing the current one. + private loadGeneration = 0; + + createRenderRoot() { + return this; + } + + protected updated(changed: PropertyValues): void { + if (changed.has("gameId") && this.gameId !== this.requestedGameId) { + this.requestedGameId = this.gameId; + if (this.gameId) { + void this.fetchGame(this.gameId); + } else { + ++this.loadGeneration; + this.isLoadingGame = false; + this.loadFailed = false; + this.mapImage = null; + this.gameInfo = null; + this.ranking = null; + this.rankedPlayers = []; + } + } + } + + render() { + if (!this.gameId) return html``; + return html` +
+ ${this.isLoadingGame + ? this.renderLoadingAnimation() + : this.loadFailed + ? this.renderError() + : this.renderRanking()} +
+ `; + } + + private renderRanking() { + if (this.rankedPlayers.length === 0) { + return html` +
+

${translateText("game_info_modal.no_winner")}

+
+ `; + } + return html` + ${this.renderGameInfo()} + + ${this.renderSummaryTable()} + `; + } + + private renderError() { + return html` +
+

${translateText("game_info_modal.load_failed")}

+ +
+ `; + } + + private retry(): void { + if (this.gameId) void this.fetchGame(this.gameId); + } + + private renderLoadingAnimation() { + return html` +
+

+ ${translateText("game_info_modal.loading_game_info")} +

+
+
+ `; + } + + private sort(e: CustomEvent) { + this.rankType = e.detail; + this.updateRanking(); + } + + private updateRanking() { + if (this.ranking) { + this.rankedPlayers = this.ranking.sortedBy(this.rankType); + } + } + + private renderGameInfo() { + const info = this.gameInfo; + if (!info) return html``; + return html` +
+ ${this.mapImage + ? html`` + : html`
`} +
+
+ ${translateText( + info.config.gameMode === GameMode.Team + ? "game_mode.teams" + : "game_mode.ffa", + )} + ${getMapName(info.config.gameMap) ?? info.config.gameMap} +
+
${renderDuration(info.duration)}
+
+ ${info.players.length} ${translateText("game_info_modal.players")} +
+
+
+ `; + } + + private renderSummaryTable() { + const bestScore = + this.rankedPlayers.length > 0 ? this.score(this.rankedPlayers[0]) : 0; + return html` +
    + + ${this.rankedPlayers.map( + (player, index) => html` + + `, + )} +
+ `; + } + + private score(player: PlayerInfo): number { + if (!this.ranking) return 0; + return this.ranking.score(player, this.rankType); + } + + private async fetchGame(gameId: string): Promise { + const generation = ++this.loadGeneration; + this.isLoadingGame = true; + this.loadFailed = false; + this.mapImage = null; + this.gameInfo = null; + this.ranking = null; + this.rankedPlayers = []; + + try { + const session = await fetchGameById(gameId); + if (generation !== this.loadGeneration) return; + if (!session) { + this.loadFailed = true; + return; + } + + this.gameInfo = session.info; + this.ranking = new Ranking(session); + this.updateRanking(); + try { + const mapType = session.info.config.gameMap as GameMapType; + this.mapImage = terrainMapFileLoader.getMapData(mapType).webpPath; + } catch (error) { + console.error("Failed to load map image:", error); + } + } catch (err) { + if (generation === this.loadGeneration) { + console.error("Failed to load game:", err); + this.loadFailed = true; + } + } finally { + if (generation === this.loadGeneration) { + this.isLoadingGame = false; + } + } + } +} diff --git a/src/client/components/baseComponents/stats/PlayerGameHistoryView.ts b/src/client/components/baseComponents/stats/PlayerGameHistoryView.ts index 7e720d038..bdc8b7948 100644 --- a/src/client/components/baseComponents/stats/PlayerGameHistoryView.ts +++ b/src/client/components/baseComponents/stats/PlayerGameHistoryView.ts @@ -7,7 +7,6 @@ import { } from "../../../../core/ApiSchemas"; import { GameMapType } from "../../../../core/game/Game"; import { fetchPublicPlayerGames } from "../../../Api"; -import { GameInfoModal } from "../../../GameInfoModal"; import { terrainMapFileLoader } from "../../../TerrainMapFileLoader"; import { getMapName, renderDuration, translateText } from "../../../Utils"; import { renderLoadingSpinner } from "../../BaseModal"; @@ -212,19 +211,14 @@ export class PlayerGameHistoryView extends LitElement { ); } - // Opens the game-info ranking overlay on top of the account modal. The modal - // is a global singleton in the document (queried the same way as Main.ts), - // so we don't close the account modal — the overlay layers above it. - private showRanking(gameId: string) { - const gameInfoModal = document.querySelector( - "game-info-modal", - ) as GameInfoModal | null; - if (!gameInfoModal) { - console.warn("Game info modal element not found"); - return; - } - void gameInfoModal.loadGame(gameId); - gameInfoModal.open(); + private showStats(gameId: string) { + this.dispatchEvent( + new CustomEvent<{ gameId: string }>("view-stats", { + detail: { gameId }, + bubbles: true, + composed: true, + }), + ); } render() { @@ -452,7 +446,7 @@ export class PlayerGameHistoryView extends LitElement {