mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 07:40:43 +00:00
Create clan stats modal (#2479)
Resolves #2452 ## Description: Created a Clan Stats PR to show top clans. In another PR we can show the player leaderboard to show top players. Based on PR from https://github.com/Geekyhobo <img width="659" height="792" alt="Screenshot 2025-11-19 at 10 00 40 AM" src="https://github.com/user-attachments/assets/9333b7e2-2357-47a6-a7c8-788cf81e9be3" /> ## 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 Co-authored-by: Geekyhobo <geekyhobo@users.noreply.github.com>
This commit is contained in:
+3
-1
@@ -25,4 +25,6 @@ Licensed under [CC-BY 4.0](https://creativecommons.org/licenses/by/4.0/)
|
||||
|
||||
## Icons
|
||||
|
||||
Icons from [The Noun Project](https://thenounproject.com/)
|
||||
### [The Noun Project](https://thenounproject.com/)
|
||||
|
||||
Stats icon by [Meko](https://thenounproject.com/mekoda/) – https://thenounproject.com/icon/stats-4942475/
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="white" d="m479.78 149.95c-4-0.015625-7.8359 1.5664-10.664 4.3906-2.8281 2.8281-4.4102 6.668-4.3945 10.668v870.1c0.042969 3.9609 1.6484 7.7422 4.4727 10.523 2.8203 2.7773 6.625 4.332 10.586 4.3164h240.5c3.9609 0.015625 7.7695-1.5391 10.59-4.3164 2.8203-2.7812 4.4258-6.5625 4.4688-10.523v-870.1c0.015625-4-1.5625-7.8398-4.3906-10.664-2.8281-2.8281-6.668-4.4102-10.668-4.3945zm14.836 30.117h210.61v839.98h-210.61z"/>
|
||||
<path fill="white" d="m165.09 440.05c-3.9609-0.015626-7.7656 1.5352-10.586 4.3164-2.8203 2.7812-4.4297 6.5625-4.4727 10.523v580.21c0.042969 3.9609 1.6523 7.7422 4.4727 10.523 2.8203 2.7773 6.625 4.332 10.586 4.3164h240.5c3.9609 0.015625 7.7656-1.5391 10.586-4.3164 2.8203-2.7812 4.4297-6.5625 4.4727-10.523v-580.21c-0.042969-3.9609-1.6523-7.7422-4.4727-10.523-2.8203-2.7812-6.625-4.332-10.586-4.3164zm14.84 29.898h210.61v550.1h-210.61z"/>
|
||||
<path fill="white" d="m794.47 729.94c-4-0.015625-7.8398 1.5664-10.668 4.3945-2.8281 2.8281-4.4102 6.668-4.3945 10.664v290.11c0.042969 3.9609 1.6523 7.7422 4.4727 10.523 2.8203 2.7773 6.6289 4.332 10.59 4.3164h240.5c3.9609 0.015625 7.7656-1.5391 10.586-4.3164 2.8203-2.7812 4.4297-6.5625 4.4727-10.523v-290.11c0.015625-3.9961-1.5664-7.8359-4.3945-10.664-2.8281-2.8281-6.6641-4.4102-10.664-4.3945zm14.836 30.121h210.61v259.99h-210.61z"/>
|
||||
<path fill="white" d="m479.69 164.99h240.63v870.02h-240.63z"/>
|
||||
<path fill="white" d="m165 455h240.63v580.01h-240.63z"/>
|
||||
<path fill="white" d="m794.38 745h240.63v290h-240.63z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -164,6 +164,18 @@
|
||||
"logged_in_with_discord": "Logged in with Discord",
|
||||
"recovery_email_sent": "Recovery email sent to {email}"
|
||||
},
|
||||
"stats_modal": {
|
||||
"title": "Stats",
|
||||
"clan_stats": "Clan Stats",
|
||||
"loading": "Loading...",
|
||||
"error": "Error loading clan stats",
|
||||
"no_stats": "No clan stats available",
|
||||
"clan": "Clan",
|
||||
"games": "Games",
|
||||
"win_score": "Win Score",
|
||||
"loss_score": "Loss Score",
|
||||
"win_loss_ratio": "Win/Loss"
|
||||
},
|
||||
"map": {
|
||||
"map": "Map",
|
||||
"world": "World",
|
||||
|
||||
@@ -27,6 +27,7 @@ import { NewsModal } from "./NewsModal";
|
||||
import "./PublicLobby";
|
||||
import { PublicLobby } from "./PublicLobby";
|
||||
import { SinglePlayerModal } from "./SinglePlayerModal";
|
||||
import "./StatsModal";
|
||||
import { TerritoryPatternsModal } from "./TerritoryPatternsModal";
|
||||
import { TokenLoginModal } from "./TokenLoginModal";
|
||||
import { SendKickPlayerIntentEvent } from "./Transport";
|
||||
@@ -524,6 +525,7 @@ class Client {
|
||||
"news-modal",
|
||||
"flag-input-modal",
|
||||
"account-button",
|
||||
"stats-button",
|
||||
"token-login",
|
||||
"matchmaking-modal",
|
||||
].forEach((tag) => {
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import {
|
||||
ClanLeaderboardResponse,
|
||||
ClanLeaderboardResponseSchema,
|
||||
} from "../core/ApiSchemas";
|
||||
import { getApiBase } from "./jwt";
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
@customElement("stats-modal")
|
||||
export class StatsModal extends LitElement {
|
||||
@query("o-modal")
|
||||
private modalEl!: HTMLElement & {
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
@state() private isLoading: boolean = false;
|
||||
@state() private error: string | null = null;
|
||||
@state() private data: ClanLeaderboardResponse | null = null;
|
||||
|
||||
private hasLoaded = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
public open() {
|
||||
this.modalEl?.open();
|
||||
if (!this.hasLoaded && !this.isLoading) {
|
||||
void this.loadLeaderboard();
|
||||
}
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.modalEl?.close();
|
||||
}
|
||||
|
||||
private async loadLeaderboard() {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${getApiBase()}/public/clans/leaderboard`, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Unexpected status ${res.status}`);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
const parsed = ClanLeaderboardResponseSchema.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
console.warn(
|
||||
"ClanLeaderboardModal: invalid response schema",
|
||||
parsed.error,
|
||||
);
|
||||
throw new Error("Invalid response format");
|
||||
}
|
||||
|
||||
this.data = parsed.data;
|
||||
this.hasLoaded = true;
|
||||
} catch (err) {
|
||||
console.warn("ClanLeaderboardModal: failed to load leaderboard", err);
|
||||
this.error = translateText("stats_modal.error");
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private renderBody() {
|
||||
if (this.isLoading) {
|
||||
return html`
|
||||
<div class="flex flex-col items-center justify-center p-6 text-white">
|
||||
<p class="mb-2 text-lg font-semibold">
|
||||
${translateText("stats_modal.loading")}
|
||||
</p>
|
||||
<div
|
||||
class="w-6 h-6 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (this.error) {
|
||||
return html`
|
||||
<div class="flex flex-col items-center justify-center p-6 text-white">
|
||||
<p class="mb-4 text-center">${this.error}</p>
|
||||
<button
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded text-sm font-medium"
|
||||
@click=${() => this.loadLeaderboard()}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (!this.data || this.data.clans.length === 0) {
|
||||
return html`
|
||||
<div class="p-6 text-center text-gray-200">
|
||||
<p class="text-lg font-semibold mb-2">
|
||||
${translateText("stats_modal.no_stats")}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const { start, end, clans } = this.data;
|
||||
const startDate = new Date(start);
|
||||
const endDate = new Date(end);
|
||||
|
||||
return html`
|
||||
<div class="p-4 md:p-6 text-gray-200">
|
||||
<div
|
||||
class="flex flex-col md:flex-row md:items-center md:justify-between mb-4 gap-2"
|
||||
>
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">
|
||||
${translateText("stats_modal.clan_stats")}
|
||||
</h2>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
${startDate.toLocaleDateString()} ·
|
||||
${endDate.toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-xs md:text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-700 text-gray-300">
|
||||
<th class="py-2 pr-3 text-left">
|
||||
${translateText("stats_modal.clan")}
|
||||
</th>
|
||||
<th class="py-2 px-2 text-right">
|
||||
${translateText("stats_modal.games")}
|
||||
</th>
|
||||
<th class="py-2 px-2 text-right">
|
||||
${translateText("stats_modal.win_score")}
|
||||
</th>
|
||||
<th class="py-2 px-2 text-right">
|
||||
${translateText("stats_modal.loss_score")}
|
||||
</th>
|
||||
<th class="py-2 pl-2 text-right">
|
||||
${translateText("stats_modal.win_loss_ratio")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${clans.map(
|
||||
(clan) => html`
|
||||
<tr class="border-b border-gray-800 last:border-b-0">
|
||||
<td class="py-2 pr-3 font-semibold text-left">
|
||||
${clan.clanTag}
|
||||
</td>
|
||||
<td class="py-2 px-2 text-right">
|
||||
${clan.games.toLocaleString()}
|
||||
</td>
|
||||
<td class="py-2 px-2 text-right">${clan.weightedWins}</td>
|
||||
<td class="py-2 px-2 text-right">${clan.weightedLosses}</td>
|
||||
<td class="py-2 pl-2 text-right">
|
||||
${clan.weightedWLRatio}
|
||||
</td>
|
||||
</tr>
|
||||
`,
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<o-modal id="stats-modal" title="${translateText("stats_modal.title")}">
|
||||
${this.renderBody()}
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("stats-button")
|
||||
export class StatsButton extends LitElement {
|
||||
@query("stats-modal") private statsModal: StatsModal;
|
||||
@state() private isVisible: boolean = true;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.isVisible) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="fixed top-20 right-4 z-[9999]">
|
||||
<button
|
||||
@click="${this.open}"
|
||||
class="w-12 h-12 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-2xl hover:shadow-3xl transition-all duration-200 flex items-center justify-center text-xl focus:outline-none focus:ring-4 focus:ring-blue-500 focus:ring-offset-4"
|
||||
title="${translateText("stats_modal.title")}"
|
||||
>
|
||||
<img src="/icons/stats.svg" alt="Stats" class="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
<stats-modal></stats-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
private open() {
|
||||
this.isVisible = true;
|
||||
this.requestUpdate();
|
||||
this.statsModal?.open();
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.statsModal?.close();
|
||||
this.isVisible = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
@@ -405,6 +405,7 @@
|
||||
<spawn-timer></spawn-timer>
|
||||
<help-modal></help-modal>
|
||||
<dark-mode-button></dark-mode-button>
|
||||
<stats-button></stats-button>
|
||||
<alert-frame></alert-frame>
|
||||
<chat-modal></chat-modal>
|
||||
<user-setting></user-setting>
|
||||
|
||||
@@ -90,3 +90,24 @@ export const PlayerProfileSchema = z.object({
|
||||
stats: PlayerStatsTreeSchema,
|
||||
});
|
||||
export type PlayerProfile = z.infer<typeof PlayerProfileSchema>;
|
||||
|
||||
export const ClanLeaderboardEntrySchema = z.object({
|
||||
clanTag: z.string(),
|
||||
games: z.number(),
|
||||
wins: z.number(),
|
||||
losses: z.number(),
|
||||
playerSessions: z.number(),
|
||||
weightedWins: z.number(),
|
||||
weightedLosses: z.number(),
|
||||
weightedWLRatio: z.number(),
|
||||
});
|
||||
export type ClanLeaderboardEntry = z.infer<typeof ClanLeaderboardEntrySchema>;
|
||||
|
||||
export const ClanLeaderboardResponseSchema = z.object({
|
||||
start: z.iso.datetime(),
|
||||
end: z.iso.datetime(),
|
||||
clans: ClanLeaderboardEntrySchema.array(),
|
||||
});
|
||||
export type ClanLeaderboardResponse = z.infer<
|
||||
typeof ClanLeaderboardResponseSchema
|
||||
>;
|
||||
|
||||
Reference in New Issue
Block a user