mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-24 09:44:00 +00:00
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:
@@ -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 }),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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")}
|
||||
|
||||
Reference in New Issue
Block a user