mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-11 13:48:07 +00:00
Main Menu UI Overhaul (#2829)
## Description: Overhauls the Main Menu UI, visit https://menu.openfront.dev to see everything. ## 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: w.o.n
This commit is contained in:
+313
-125
@@ -1,41 +1,75 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import { html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import {
|
||||
ClanLeaderboardEntry,
|
||||
ClanLeaderboardResponse,
|
||||
ClanLeaderboardResponseSchema,
|
||||
} from "../core/ApiSchemas";
|
||||
import { getApiBase } from "./Api";
|
||||
import { translateText } from "./Utils";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
|
||||
@customElement("stats-modal")
|
||||
export class StatsModal extends LitElement {
|
||||
@query("o-modal")
|
||||
private modalEl!: HTMLElement & {
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
export class StatsModal extends BaseModal {
|
||||
@state() private isLoading: boolean = false;
|
||||
@state() private error: string | null = null;
|
||||
@state() private data: ClanLeaderboardResponse | null = null;
|
||||
@state() private sortBy: "rank" | "games" | "wins" | "losses" | "ratio" =
|
||||
"rank";
|
||||
@state() private sortOrder: "asc" | "desc" = "asc";
|
||||
|
||||
private hasLoaded = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
private handleSort(column: "rank" | "games" | "wins" | "losses" | "ratio") {
|
||||
if (this.sortBy === column) {
|
||||
this.sortOrder = this.sortOrder === "asc" ? "desc" : "asc";
|
||||
} else {
|
||||
this.sortBy = column;
|
||||
this.sortOrder = column === "rank" ? "asc" : "desc";
|
||||
}
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
public open() {
|
||||
this.modalEl?.open();
|
||||
private getSortedClans(clans: ClanLeaderboardEntry[]) {
|
||||
const sorted = [...clans];
|
||||
sorted.sort((a, b) => {
|
||||
let aVal: number, bVal: number;
|
||||
|
||||
switch (this.sortBy) {
|
||||
case "games":
|
||||
aVal = a.games;
|
||||
bVal = b.games;
|
||||
break;
|
||||
case "wins":
|
||||
aVal = a.weightedWins;
|
||||
bVal = b.weightedWins;
|
||||
break;
|
||||
case "losses":
|
||||
aVal = a.weightedLosses;
|
||||
bVal = b.weightedLosses;
|
||||
break;
|
||||
case "ratio":
|
||||
aVal = a.weightedWLRatio;
|
||||
bVal = b.weightedWLRatio;
|
||||
break;
|
||||
case "rank":
|
||||
default:
|
||||
// Original order
|
||||
return 0;
|
||||
}
|
||||
|
||||
return this.sortOrder === "asc" ? aVal - bVal : bVal - aVal;
|
||||
});
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
protected onOpen(): void {
|
||||
if (!this.hasLoaded && !this.isLoading) {
|
||||
void this.loadLeaderboard();
|
||||
}
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.modalEl?.close();
|
||||
}
|
||||
|
||||
private async loadLeaderboard() {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
@@ -75,26 +109,52 @@ export class StatsModal extends LitElement {
|
||||
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">
|
||||
<div
|
||||
class="flex flex-col items-center justify-center p-12 text-white h-full"
|
||||
>
|
||||
<div
|
||||
class="w-12 h-12 border-4 border-blue-500/30 border-t-blue-500 rounded-full animate-spin mb-6"
|
||||
></div>
|
||||
<p
|
||||
class="text-blue-200/80 text-sm font-bold tracking-[0.2em] uppercase"
|
||||
>
|
||||
${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>
|
||||
<div
|
||||
class="flex flex-col items-center justify-center p-12 text-white h-full"
|
||||
>
|
||||
<div
|
||||
class="bg-red-500/10 p-6 rounded-full mb-6 border border-red-500/20 shadow-[0_0_30px_rgba(239,68,68,0.2)]"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-12 w-12 text-red-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="mb-8 text-center text-red-100/80 max-w-xs font-medium">
|
||||
${this.error}
|
||||
</p>
|
||||
<button
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-sm text-sm font-medium"
|
||||
class="px-8 py-3 bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 hover:border-red-500/50 text-red-200 rounded-xl text-sm font-bold uppercase tracking-wider transition-all cursor-pointer hover:shadow-lg hover:shadow-red-500/10 active:scale-95"
|
||||
@click=${() => this.loadLeaderboard()}
|
||||
>
|
||||
Retry
|
||||
${translateText("stats_modal.try_again")}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
@@ -102,85 +162,195 @@ export class StatsModal extends LitElement {
|
||||
|
||||
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">
|
||||
<div
|
||||
class="p-12 text-center text-white/40 flex flex-col items-center h-full justify-center"
|
||||
>
|
||||
<div class="bg-white/5 p-6 rounded-full mb-6 border border-white/5">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-16 w-16 text-white/20"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1"
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-white/60 mb-2">
|
||||
${translateText("stats_modal.no_data_yet")}
|
||||
</h3>
|
||||
<p class="text-white/30 text-sm max-w-[200px]">
|
||||
${translateText("stats_modal.no_stats")}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const { start, end, clans } = this.data;
|
||||
const startDate = new Date(start);
|
||||
const endDate = new Date(end);
|
||||
const { clans } = this.data;
|
||||
const maxGames = Math.max(...clans.map((c) => c.games), 1);
|
||||
|
||||
return html`
|
||||
<div class="p-4 md:p-6 text-gray-200">
|
||||
<div class="w-full">
|
||||
<div
|
||||
class="flex flex-col md:flex-row md:items-center md:justify-between mb-4 gap-2"
|
||||
class="overflow-x-auto rounded-xl border border-white/5 bg-black/20"
|
||||
>
|
||||
<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">
|
||||
<table class="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-700 text-gray-300">
|
||||
<th class="py-2 pr-3 text-left">
|
||||
<tr
|
||||
class="text-white/40 text-xs uppercase tracking-wider border-b border-white/5 bg-white/[0.02]"
|
||||
>
|
||||
<th class="py-4 px-4 text-center font-bold w-16">
|
||||
${translateText("stats_modal.rank")}
|
||||
</th>
|
||||
<th class="py-2 pr-3 text-left">
|
||||
<th class="py-4 px-4 text-left font-bold">
|
||||
${translateText("stats_modal.clan")}
|
||||
</th>
|
||||
<th class="py-2 px-2 text-right">
|
||||
${translateText("stats_modal.games")}
|
||||
<th
|
||||
@click=${() => this.handleSort("games")}
|
||||
class="py-4 px-4 text-right font-bold w-32 cursor-pointer hover:text-white/60 transition-colors select-none"
|
||||
>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
${translateText("stats_modal.games")}
|
||||
${this.sortBy === "games"
|
||||
? this.sortOrder === "asc"
|
||||
? html`<span class="text-blue-400">↑</span>`
|
||||
: html`<span class="text-blue-400">↓</span>`
|
||||
: html`<span class="text-white/20">↕</span>`}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
class="py-2 px-2 text-right"
|
||||
@click=${() => this.handleSort("wins")}
|
||||
class="py-4 px-4 text-right font-bold hidden md:table-cell cursor-pointer hover:text-white/60 transition-colors select-none"
|
||||
title=${translateText("stats_modal.win_score_tooltip")}
|
||||
>
|
||||
${translateText("stats_modal.win_score")}
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
${translateText("stats_modal.win_score")}
|
||||
${this.sortBy === "wins"
|
||||
? this.sortOrder === "asc"
|
||||
? html`<span class="text-blue-400">↑</span>`
|
||||
: html`<span class="text-blue-400">↓</span>`
|
||||
: html`<span class="text-white/20">↕</span>`}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
class="py-2 px-2 text-right"
|
||||
@click=${() => this.handleSort("losses")}
|
||||
class="py-4 px-4 text-right font-bold hidden md:table-cell cursor-pointer hover:text-white/60 transition-colors select-none"
|
||||
title=${translateText("stats_modal.loss_score_tooltip")}
|
||||
>
|
||||
${translateText("stats_modal.loss_score")}
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
${translateText("stats_modal.loss_score")}
|
||||
${this.sortBy === "losses"
|
||||
? this.sortOrder === "asc"
|
||||
? html`<span class="text-blue-400">↑</span>`
|
||||
: html`<span class="text-blue-400">↓</span>`
|
||||
: html`<span class="text-white/20">↕</span>`}
|
||||
</div>
|
||||
</th>
|
||||
<th class="py-2 pl-2 text-right">
|
||||
${translateText("stats_modal.win_loss_ratio")}
|
||||
<th
|
||||
@click=${() => this.handleSort("ratio")}
|
||||
class="py-4 px-4 text-right font-bold pr-6 cursor-pointer hover:text-white/60 transition-colors select-none"
|
||||
>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
${translateText("stats_modal.win_loss_ratio")}
|
||||
${this.sortBy === "ratio"
|
||||
? this.sortOrder === "asc"
|
||||
? html`<span class="text-blue-400">↑</span>`
|
||||
: html`<span class="text-blue-400">↓</span>`
|
||||
: html`<span class="text-white/20">↕</span>`}
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${clans.map(
|
||||
(clan, index) => html`
|
||||
<tr class="border-b border-gray-800 last:border-b-0">
|
||||
<td class="py-2 pr-3 text-center">
|
||||
${(index + 1).toLocaleString()}
|
||||
${this.getSortedClans(clans).map((clan, index) => {
|
||||
const rankColor =
|
||||
index === 0
|
||||
? "text-yellow-400 bg-yellow-400/10 ring-1 ring-yellow-400/20"
|
||||
: index === 1
|
||||
? "text-slate-300 bg-slate-400/10 ring-1 ring-slate-400/20"
|
||||
: index === 2
|
||||
? "text-amber-600 bg-amber-600/10 ring-1 ring-amber-600/20"
|
||||
: "text-white/40 bg-white/5";
|
||||
|
||||
const rankIcon =
|
||||
index === 0
|
||||
? "👑"
|
||||
: index === 1
|
||||
? "🥈"
|
||||
: index === 2
|
||||
? "🥉"
|
||||
: String(index + 1);
|
||||
|
||||
return html`
|
||||
<tr
|
||||
class="border-b border-white/5 hover:bg-white/[0.07] transition-colors group"
|
||||
>
|
||||
<td class="py-3 px-4 text-center">
|
||||
<div
|
||||
class="w-10 h-10 mx-auto flex items-center justify-center rounded-lg font-bold font-mono text-lg ${rankColor}"
|
||||
>
|
||||
${rankIcon}
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-2 pr-3 font-semibold text-left">
|
||||
${clan.clanTag}
|
||||
<td class="py-3 px-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="px-2.5 py-1 rounded bg-blue-500/10 border border-blue-500/20 text-blue-300 font-bold text-xs tracking-wide group-hover:bg-blue-500/20 transition-colors"
|
||||
>
|
||||
${clan.clanTag}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-2 px-2 text-right">
|
||||
${clan.games.toLocaleString()}
|
||||
<td class="py-3 px-4 text-right">
|
||||
<div class="flex flex-col items-end gap-1">
|
||||
<span class="text-white font-mono font-medium"
|
||||
>${clan.games.toLocaleString()}</span
|
||||
>
|
||||
<div
|
||||
class="w-24 h-1 bg-white/10 rounded-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="h-full bg-blue-500/50 rounded-full"
|
||||
style="width: ${(clan.games / maxGames) * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</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
|
||||
class="py-3 px-4 text-right font-mono text-green-400/90 hidden md:table-cell"
|
||||
>
|
||||
${clan.weightedWins}
|
||||
</td>
|
||||
<td
|
||||
class="py-3 px-4 text-right font-mono text-red-400/90 hidden md:table-cell"
|
||||
>
|
||||
${clan.weightedLosses}
|
||||
</td>
|
||||
<td class="py-3 px-4 text-right pr-6">
|
||||
<div class="inline-flex flex-col items-end">
|
||||
<span
|
||||
class="font-mono font-bold ${Number(
|
||||
clan.weightedWLRatio,
|
||||
) >= 1
|
||||
? "text-green-400"
|
||||
: "text-red-400"}"
|
||||
>
|
||||
${clan.weightedWLRatio}
|
||||
</span>
|
||||
<span
|
||||
class="text-[10px] uppercase text-white/30 font-bold tracking-wider"
|
||||
>${translateText("stats_modal.ratio")}</span
|
||||
>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`,
|
||||
)}
|
||||
`;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -189,61 +359,79 @@ export class StatsModal extends LitElement {
|
||||
}
|
||||
|
||||
render() {
|
||||
let dateRange = html``;
|
||||
if (this.data) {
|
||||
const start = new Date(this.data.start).toLocaleDateString();
|
||||
const end = new Date(this.data.end).toLocaleDateString();
|
||||
dateRange = html`<span
|
||||
class="text-sm font-normal text-white/40 ml-2 break-words"
|
||||
>(${start} - ${end})</span
|
||||
>`;
|
||||
}
|
||||
|
||||
const content = html`
|
||||
<div
|
||||
class="h-full flex flex-col ${this.inline
|
||||
? "bg-black/40 backdrop-blur-md rounded-2xl border border-white/10"
|
||||
: ""}"
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap items-center mb-6 pb-2 border-b border-white/10 gap-2 shrink-0 p-6"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-4 flex-1">
|
||||
<button
|
||||
@click=${this.close}
|
||||
class="group flex items-center justify-center w-10 h-10 rounded-full bg-white/5 hover:bg-white/10 transition-all border border-white/10"
|
||||
aria-label=${translateText("common.close")}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-5 h-5 text-gray-400 group-hover:text-white transition-colors"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="text-white text-xl sm:text-2xl md:text-3xl font-bold uppercase tracking-widest break-words hyphens-auto"
|
||||
>
|
||||
${translateText("stats_modal.clan_stats")}
|
||||
</span>
|
||||
${dateRange}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent px-6 pb-6 mr-1"
|
||||
>
|
||||
${this.renderBody()}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (this.inline) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return html`
|
||||
<o-modal id="stats-modal" title="${translateText("stats_modal.title")}">
|
||||
${this.renderBody()}
|
||||
<o-modal
|
||||
id="stats-modal"
|
||||
title="${translateText("stats_modal.clan_stats")}"
|
||||
?inline=${this.inline}
|
||||
hideCloseButton
|
||||
hideHeader
|
||||
>
|
||||
${content}
|
||||
</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-9998">
|
||||
<button
|
||||
@click="${this.open}"
|
||||
class="w-12 h-12 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-2xl hover:shadow-2xl transition-all duration-200 flex items-center justify-center text-xl focus:outline-hidden 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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user