convert stats modal to embed instead of popup (#4626)

## Description:

convert stats modal to embed instead of popup

before:
<img width="1193" height="939" alt="image"
src="https://github.com/user-attachments/assets/4f96843a-3b96-4874-8292-e670181bd87c"
/>

after:
<img width="1226" height="863" alt="image"
src="https://github.com/user-attachments/assets/801273cc-77cd-41b6-bc77-4fdc65afef71"
/>


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
This commit is contained in:
Ryan
2026-07-17 08:49:44 -07:00
committed by GitHub
parent 95840a2074
commit 0fc80e931e
12 changed files with 833 additions and 237 deletions
+76 -4
View File
@@ -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()}
</div>`;
}
if (this.selectedGameId) {
return html`
<div class="custom-scrollbar mr-1">
<div class="p-6">
<game-info-view .gameId=${this.selectedGameId}></game-info-view>
</div>
</div>
`;
}
return html`
<div class="custom-scrollbar mr-1">
<div class="p-6">${this.renderTab(tab)}</div>
@@ -423,6 +445,8 @@ export class AccountModal extends BaseModal {
@history-updated=${(e: CustomEvent<PlayerGameHistoryCache>) => {
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)}
></player-game-history-view>
@@ -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<void> {
await this.updateComplete;
await this.modalEl?.updateComplete;
const historyView = this.querySelector<
HTMLElement & { updateComplete?: Promise<boolean> }
>("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`
<o-button
@@ -772,6 +830,19 @@ export class AccountModal extends BaseModal {
}
protected onOpen(args?: Record<string, unknown>): 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 }),
);
-202
View File
@@ -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<PlayerInfo> = [];
@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`
<o-modal
id="gameInfoModal"
title="${translateText("game_info_modal.title")}"
translationKey="main.game_info"
>
<div
class="h-full flex flex-col items-center px-25 text-center mb-4 scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent"
>
<div class="w-75 sm:w-125">
${this.isLoadingGame
? this.renderLoadingAnimation()
: this.renderRanking()}
</div>
</div>
</o-modal>
`;
}
private renderRanking() {
if (this.rankedPlayers.length === 0) {
return html`
<div class="flex flex-col items-center justify-center p-6 text-white">
<p class="mb-2">❌ ${translateText("game_info_modal.no_winner")}</p>
</div>
`;
}
return html`
${this.renderGameInfo()}
<ranking-controls
.rankType=${this.rankType}
@sort=${this.sort}
></ranking-controls>
${this.renderSummaryTable()}
`;
}
private renderLoadingAnimation() {
return html` <div
class="flex flex-col items-center justify-center p-6 text-white"
>
<p class="mb-2">${translateText("game_info_modal.loading_game_info")}</p>
<div
class="w-6 h-6 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"
></div>
</div>`;
}
private sort(e: CustomEvent<RankType>) {
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`
<div
class="h-37.5 flex relative justify-between rounded-xl bg-black/20 items-center"
>
${this.mapImage
? html`<img
src="${this.mapImage}"
class="absolute place-self-start col-span-full row-span-full h-full rounded-xl mask-[linear-gradient(to_left,transparent,#fff)] object-cover object-center"
/>`
: html`<div
class="place-self-start col-span-full row-span-full h-full rounded-xl bg-gray-300"
></div>`}
<div class="text-right p-3 w-full">
<div class="font-normal pl-1 pr-1">
<span class="bg-white text-blue-800 font-normal pl-1 pr-1"
>${info.config.gameMode}</span
>
<span class="font-bold">${info.config.gameMap}</span>
</div>
<div>${renderDuration(info.duration)}</div>
<div>
${info.players.length} ${translateText("game_info_modal.players")}
</div>
</div>
</div>
`;
}
private renderSummaryTable() {
const bestScore =
this.rankedPlayers.length > 0 ? this.score(this.rankedPlayers[0]) : 0;
return html`
<ul>
<ranking-header
.rankType=${this.rankType}
@sort=${this.sort}
></ranking-header>
${this.rankedPlayers.map(
(player: PlayerInfo, index) => html`
<player-row
.player=${player}
.rank=${index + 1}
.score=${this.ranking?.score(player, this.rankType) ?? 0}
.rankType=${this.rankType}
.bestScore=${bestScore}
.currentPlayer=${this.currentClientID === player.id}
></player-row>
`,
)}
</ul>
`;
}
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;
}
}
}
+3
View File
@@ -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",
-5
View File
@@ -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", () => {
+2 -6
View File
@@ -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 <o-modal>. Defaults match
+14 -2
View File
@@ -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 {
<section class="${sectionClass}">
<slot name="header"></slot>
${hasTabs ? this.renderTabs() : html``}
<div class="flex-1 min-h-0 overflow-y-auto">
<div data-modal-scroll class="flex-1 min-h-0 overflow-y-auto">
<slot></slot>
</div>
</section>
@@ -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<this>): 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`
<div class="w-full max-w-[500px] mx-auto text-center">
${this.isLoadingGame
? this.renderLoadingAnimation()
: this.loadFailed
? this.renderError()
: this.renderRanking()}
</div>
`;
}
private renderRanking() {
if (this.rankedPlayers.length === 0) {
return html`
<div class="flex flex-col items-center justify-center p-6 text-white">
<p class="mb-2">${translateText("game_info_modal.no_winner")}</p>
</div>
`;
}
return html`
${this.renderGameInfo()}
<ranking-controls
.rankType=${this.rankType}
@sort=${this.sort}
></ranking-controls>
${this.renderSummaryTable()}
`;
}
private renderError() {
return html`
<div
class="flex flex-col items-center justify-center gap-3 p-6 text-white"
>
<p class="mb-1">${translateText("game_info_modal.load_failed")}</p>
<button
type="button"
@click=${() => this.retry()}
class="px-3 py-1.5 text-xs font-bold text-white/80 uppercase tracking-wider bg-white/10 hover:bg-white/20 border border-white/10 rounded-lg transition-colors"
>
${translateText("game_info_modal.retry")}
</button>
</div>
`;
}
private retry(): void {
if (this.gameId) void this.fetchGame(this.gameId);
}
private renderLoadingAnimation() {
return html`
<div class="flex flex-col items-center justify-center p-6 text-white">
<p class="mb-2">
${translateText("game_info_modal.loading_game_info")}
</p>
<div
class="w-6 h-6 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"
></div>
</div>
`;
}
private sort(e: CustomEvent<RankType>) {
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`
<div
class="h-37.5 flex relative justify-between rounded-xl bg-black/20 items-center"
>
${this.mapImage
? html`<img
src=${this.mapImage}
class="absolute place-self-start col-span-full row-span-full h-full rounded-xl mask-[linear-gradient(to_left,transparent,#fff)] object-cover object-center"
/>`
: html`<div
class="place-self-start col-span-full row-span-full h-full rounded-xl bg-gray-300"
></div>`}
<div class="text-right p-3 w-full">
<div class="font-normal pl-1 pr-1">
<span class="bg-white text-blue-800 font-normal pl-1 pr-1"
>${translateText(
info.config.gameMode === GameMode.Team
? "game_mode.teams"
: "game_mode.ffa",
)}</span
>
<span class="font-bold"
>${getMapName(info.config.gameMap) ?? info.config.gameMap}</span
>
</div>
<div>${renderDuration(info.duration)}</div>
<div>
${info.players.length} ${translateText("game_info_modal.players")}
</div>
</div>
</div>
`;
}
private renderSummaryTable() {
const bestScore =
this.rankedPlayers.length > 0 ? this.score(this.rankedPlayers[0]) : 0;
return html`
<ul>
<ranking-header
.rankType=${this.rankType}
@sort=${this.sort}
></ranking-header>
${this.rankedPlayers.map(
(player, index) => html`
<player-row
.player=${player}
.rank=${index + 1}
.score=${this.ranking?.score(player, this.rankType) ?? 0}
.rankType=${this.rankType}
.bestScore=${bestScore}
></player-row>
`,
)}
</ul>
`;
}
private score(player: PlayerInfo): number {
if (!this.ranking) return 0;
return this.ranking.score(player, this.rankType);
}
private async fetchGame(gameId: string): Promise<void> {
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;
}
}
}
}
@@ -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 {
<div class="flex items-center gap-2 shrink-0">
<button
type="button"
@click=${() => this.showRanking(game.gameId)}
@click=${() => this.showStats(game.gameId)}
class="px-3 py-1.5 text-xs font-bold text-white/80 uppercase tracking-wider bg-white/10 hover:bg-white/20 border border-white/10 rounded-lg transition-colors"
>
${translateText("game_list.stats")}