Files
OpenFrontIO/src/client/GameInfoModal.ts
T
Evan 0733c680b9 homepage UI improvements (#3352)
## Description:

A bunch of small UI improvements:

* Make the content width a bit smaller so gutter ads fit
* remove the "duos" "trios" "quads" description on the game card since
it's redundant
* update UI in game card
* minor footer layout changes
* update z-index to ensure content appears above ads
* removed hasUnusualThumbnailSize, instead just check the map ratio
* Use "object cover" for non-irregular maps to the entire game card is
filed
* remove white ouline from the version
* changed solo button to sky blue
* make timer "s" lowercase


I think we may need to change the openfront logo color a bit too to
match the color palette, but we can do that in a follow up.

<img width="1591" height="969" alt="Screenshot 2026-03-05 at 2 04 48 PM"
src="https://github.com/user-attachments/assets/7bb9ea4c-5a17-47e1-bdad-9d6437b363b3"
/>


## 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
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

evan
2026-03-05 15:17:28 -08:00

214 lines
6.1 KiB
TypeScript

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 { UsernameInput } from "./UsernameInput";
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 username: 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.username === player.rawUsername}
></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 loadUserName() {
const usernameInput = document.querySelector(
"username-input",
) as UsernameInput;
if (usernameInput) {
this.username = usernameInput.getCurrentUsername();
}
}
public async loadGame(gameId: string) {
try {
this.isLoadingGame = true;
this.loadUserName();
const session = await fetchGameById(gameId);
if (!session) return;
this.gameInfo = session.info;
this.ranking = new Ranking(session);
this.updateRanking();
this.isLoadingGame = false;
await this.loadMapImage(session.info.config.gameMap);
} catch (err) {
console.error("Failed to load game:", err);
} finally {
this.isLoadingGame = false;
}
}
}