diff --git a/CREDITS.md b/CREDITS.md index 0528ed38b..0dc86ef8c 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -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/ diff --git a/resources/icons/stats.svg b/resources/icons/stats.svg new file mode 100644 index 000000000..fc3547bbd --- /dev/null +++ b/resources/icons/stats.svg @@ -0,0 +1,9 @@ + + diff --git a/resources/lang/en.json b/resources/lang/en.json index 05039b365..77e6b24b5 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -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", diff --git a/src/client/Main.ts b/src/client/Main.ts index c7cf0f270..871278732 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -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) => { diff --git a/src/client/StatsModal.ts b/src/client/StatsModal.ts new file mode 100644 index 000000000..eff3b7125 --- /dev/null +++ b/src/client/StatsModal.ts @@ -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` +
+ ${translateText("stats_modal.loading")} +
+ +${this.error}
+ ++ ${translateText("stats_modal.no_stats")} +
++ ${startDate.toLocaleDateString()} · + ${endDate.toLocaleDateString()} +
+| + ${translateText("stats_modal.clan")} + | ++ ${translateText("stats_modal.games")} + | ++ ${translateText("stats_modal.win_score")} + | ++ ${translateText("stats_modal.loss_score")} + | ++ ${translateText("stats_modal.win_loss_ratio")} + | +
|---|---|---|---|---|
| + ${clan.clanTag} + | ++ ${clan.games.toLocaleString()} + | +${clan.weightedWins} | +${clan.weightedLosses} | ++ ${clan.weightedWLRatio} + | +