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:
Ryan
2026-01-10 04:26:34 +00:00
committed by GitHub
parent 848a3a5633
commit 5e6c90d9bb
60 changed files with 7671 additions and 4546 deletions
+13 -5
View File
@@ -11,6 +11,7 @@ export class OButton extends LitElement {
@property({ type: Boolean }) block = false;
@property({ type: Boolean }) blockDesktop = false;
@property({ type: Boolean }) disable = false;
@property({ type: Boolean }) fill = false;
createRenderRoot() {
return this;
@@ -20,11 +21,18 @@ export class OButton extends LitElement {
return html`
<button
class=${classMap({
"c-button": true,
"c-button--block": this.block,
"c-button--blockDesktop": this.blockDesktop,
"c-button--secondary": this.secondary,
"c-button--disabled": this.disable,
"bg-blue-600 hover:bg-blue-700 text-white font-bold uppercase tracking-wider px-4 py-3 rounded-xl transition-all duration-300 transform hover:-translate-y-px outline-none border border-transparent text-center text-base lg:text-lg":
true,
"dark:bg-blue-500 dark:hover:bg-blue-600": true,
"w-full block": this.block,
"h-full w-full flex items-center justify-center": this.fill,
"lg:w-auto lg:inline-block":
!this.block && !this.blockDesktop && !this.fill,
"lg:w-1/2 lg:mx-auto lg:block": this.blockDesktop,
"bg-blue-100 text-gray-900 hover:bg-blue-200 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600":
this.secondary,
"disabled:opacity-70 disabled:cursor-not-allowed disabled:transform-none disabled:bg-gray-600 dark:disabled:bg-gray-600":
this.disable,
})}
?disabled=${this.disable}
>
+71 -74
View File
@@ -1,105 +1,102 @@
import { LitElement, css, html } from "lit";
import { LitElement, html, unsafeCSS } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { translateText } from "../../Utils";
import tailwindStyles from "../../styles.css?inline";
@customElement("o-modal")
export class OModal extends LitElement {
static styles = [unsafeCSS(tailwindStyles)];
@state() public isModalOpen = false;
@property({ type: String }) title = "";
@property({ type: String }) translationKey = "";
@property({ type: Boolean }) alwaysMaximized = false;
@property({ type: Function }) onClose?: () => void;
static styles = css`
.c-modal {
position: fixed;
padding: 1rem;
z-index: 9999;
left: 0;
bottom: 0;
right: 0;
top: 0;
background-color: rgba(0, 0, 0, 0.5);
overflow-y: auto;
display: flex;
align-items: center;
justify-content: center;
}
static openCount = 0;
.c-modal__wrapper {
border-radius: 8px;
min-width: 340px;
max-width: 860px;
}
@property({ type: Boolean })
public inline = false;
.c-modal__wrapper.always-maximized {
width: 100%;
min-width: 340px;
max-width: 860px;
min-height: 320px;
/* Fallback for older browsers */
height: 60vh;
/* Use dvh if supported for dynamic viewport handling */
height: 60dvh;
}
@property({ type: Boolean })
public alwaysMaximized = false;
.c-modal__header {
position: relative;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
font-size: 18px;
background: #000000a1;
text-align: center;
color: #fff;
padding: 1rem 2.4rem 1rem 1.4rem;
}
@property({ type: Boolean })
public hideCloseButton = false;
.c-modal__close {
cursor: pointer;
position: absolute;
right: 1rem;
top: 1rem;
}
@property({ type: String })
public title = "";
@property({ type: Boolean })
public hideHeader = false;
public onClose?: () => void;
.c-modal__content {
background: #23232382;
position: relative;
color: #fff;
padding: 1.4rem;
max-height: 60dvh;
overflow-y: auto;
backdrop-filter: blur(8px);
}
`;
public open() {
this.isModalOpen = true;
if (!this.isModalOpen) {
if (!this.inline) {
OModal.openCount = OModal.openCount + 1;
if (OModal.openCount === 1) document.body.style.overflow = "hidden";
}
this.isModalOpen = true;
}
}
public close() {
if (this.isModalOpen) {
this.isModalOpen = false;
this.onClose?.();
if (!this.inline) {
OModal.openCount = Math.max(0, OModal.openCount - 1);
if (OModal.openCount === 0) document.body.style.overflow = "";
}
}
}
disconnectedCallback() {
// Ensure global counter is decremented if this modal is removed while open.
if (this.isModalOpen && !this.inline) {
OModal.openCount = Math.max(0, OModal.openCount - 1);
if (OModal.openCount === 0) document.body.style.overflow = "";
}
super.disconnectedCallback();
}
render() {
const backdropClass = this.inline
? "relative z-10 w-full h-full flex items-stretch bg-transparent"
: "fixed inset-0 z-[9999] bg-black/70 flex items-center justify-center overflow-hidden";
const wrapperClass = this.inline
? "relative flex flex-col w-full h-full m-0 max-w-full max-h-none shadow-none"
: `relative flex flex-col w-[90%] min-w-[400px] max-w-[900px] m-8 rounded-lg shadow-[0_20px_60px_rgba(0,0,0,0.8)] max-h-[calc(100vh-4rem)] ${
this.alwaysMaximized ? "h-auto" : ""
}`;
return html`
${this.isModalOpen
? html`
<aside class="c-modal" @click=${this.close}>
<aside
class="${backdropClass}"
@click=${this.inline ? null : this.close}
>
<div
@click=${(e: Event) => e.stopPropagation()}
class="c-modal__wrapper ${this.alwaysMaximized
? "always-maximized"
: ""}"
class="${wrapperClass}"
>
<header class="c-modal__header">
${`${this.translationKey}` === ""
? `${this.title}`
: `${translateText(this.translationKey)}`}
<div class="c-modal__close" @click=${this.close}>✕</div>
</header>
<section class="c-modal__content">
${this.inline || this.hideCloseButton
? html``
: html`<div
class="absolute top-4 right-4 z-10 text-white cursor-pointer"
@click=${this.close}
>
</div>`}
${!this.hideHeader && this.title
? html`<div
class="p-[1.4rem] pb-0 text-2xl font-bold text-white"
>
${this.title}
</div>`
: html``}
<section
class="relative flex-1 min-h-0 p-[1.4rem] text-white bg-[#23232382] backdrop-blur-md rounded-lg overflow-y-auto"
>
<slot></slot>
</section>
</div>
@@ -1,6 +1,6 @@
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { translateText } from "../../../../client/Utils";
import { formatKeyForDisplay, translateText } from "../../../../client/Utils";
@customElement("setting-keybind")
export class SettingKeybind extends LitElement {
@@ -9,6 +9,7 @@ export class SettingKeybind extends LitElement {
@property({ type: String, reflect: true }) action = "";
@property({ type: String }) defaultKey = "";
@property({ type: String }) value = "";
@property({ type: String }) display = "";
@property({ type: Boolean }) easter = false;
createRenderRoot() {
@@ -18,43 +19,58 @@ export class SettingKeybind extends LitElement {
private listening = false;
render() {
const currentValue = this.value === "" ? "" : this.value || this.defaultKey;
const canReset = this.value !== undefined && this.value !== this.defaultKey;
const displayValue = this.display || currentValue;
const rainbowClass = this.easter
? "bg-[linear-gradient(270deg,#990033,#996600,#336600,#008080,#1c3f99,#5e0099,#990033)] bg-[length:1400%_1400%] animate-rainbow-bg text-white hover:bg-[linear-gradient(270deg,#990033,#996600,#336600,#008080,#1c3f99,#5e0099,#990033)]"
: "";
return html`
<div class="setting-item column${this.easter ? " easter-egg" : ""}">
<div class="setting-label-group">
<label class="setting-label block mb-1">${this.label} </label>
<div
class="flex flex-row items-center justify-between w-full p-4 bg-white/5 border border-white/10 rounded-xl hover:bg-white/10 transition-all gap-4 ${rainbowClass}"
>
<div class="flex flex-col flex-1 min-w-0 mr-4">
<label class="text-white font-bold text-base block mb-1"
>${this.label}</label
>
<div class="text-white/50 text-sm leading-snug">
${this.description}
</div>
</div>
<div class="setting-keybind-box flex flex-wrap items-start gap-2">
<div
class="setting-keybind-description flex-1 min-w-60 max-w-full whitespace-normal wrap-break-words text-sm text-gray-300 [word-break:break-word]"
<div class="flex items-center gap-3 shrink-0">
<div
class="relative h-12 min-w-[80px] px-4 flex items-center justify-center bg-black/40 border border-white/20 rounded-lg text-xl font-bold font-mono shadow-inner hover:border-blue-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/50 transition-all cursor-pointer select-none text-white
${this.listening
? "border-blue-500 text-blue-400 ring-2 ring-blue-500/50"
: ""}"
role="button"
aria-label="${translateText("user_setting.press_a_key")}"
tabindex="0"
@keydown=${this.handleKeydown}
@click=${this.startListening}
@blur=${this.handleBlur}
>
${this.listening ? "..." : this.displayKey(displayValue)}
</div>
<div class="flex flex-col gap-1">
<button
class="text-[10px] font-bold uppercase tracking-wider bg-white/5 hover:bg-white/20 border border-white/10 px-3 py-1 rounded text-white/60 hover:text-white transition-colors ${canReset
? ""
: "opacity-50 cursor-not-allowed pointer-events-none"}"
@click=${this.resetToDefault}
?disabled=${!canReset}
>
${this.description}
</div>
<div
class="flex flex-wrap items-center gap-2 gap-y-1 basis-full sm:basis-auto min-w-0"
${translateText("user_setting.reset")}
</button>
<button
class="text-[10px] font-bold uppercase tracking-wider bg-white/5 hover:bg-red-500/20 border border-white/10 hover:border-red-500/50 px-3 py-1 rounded text-white/60 hover:text-red-200 transition-colors"
@click=${this.unbindKey}
>
<span
class="setting-key shrink-0"
tabindex="0"
@keydown=${this.handleKeydown}
@click=${this.startListening}
>
${this.displayKey(this.value || this.defaultKey)}
</span>
<button
class="text-xs text-gray-400 hover:text-white border border-gray-500 px-2 py-0.5 rounded-sm transition whitespace-normal wrap-break-words max-w-full"
@click=${this.resetToDefault}
>
${translateText("user_setting.reset")}
</button>
<button
class="text-xs text-gray-400 hover:text-white border border-gray-500 px-2 py-0.5 rounded-sm transition whitespace-normal wrap-break-words max-w-full"
@click=${this.unbindKey}
>
${translateText("user_setting.unbind")}
</button>
</div>
${translateText("user_setting.unbind")}
</button>
</div>
</div>
</div>
@@ -62,13 +78,8 @@ export class SettingKeybind extends LitElement {
}
private displayKey(key: string): string {
if (key === " ") return "Space";
if (key.startsWith("Key") && key.length === 4) {
return key.slice(3);
}
return key.length
? key.charAt(0).toUpperCase() + key.slice(1)
: "Press a key";
if (!key) return translateText("user_setting.press_a_key");
return formatKeyForDisplay(key);
}
private startListening() {
@@ -78,20 +89,40 @@ export class SettingKeybind extends LitElement {
private handleKeydown(e: KeyboardEvent) {
if (!this.listening) return;
// Allow Tab and Escape to work normally (don't trap focus)
if (e.key === "Tab" || e.key === "Escape") {
if (e.key === "Escape") {
// Cancel listening on Escape
this.listening = false;
this.requestUpdate();
}
return;
}
// Prevent default only for keys we're actually capturing
e.preventDefault();
const code = e.code;
const prevValue = this.value;
// Temporarily set the value to the new code for validation in parent
this.value = code;
this.dispatchEvent(
new CustomEvent("change", {
detail: { action: this.action, value: code, key: e.key },
bubbles: true,
composed: true,
}),
);
const event = new CustomEvent("change", {
detail: { action: this.action, value: code, key: e.key, prevValue },
bubbles: true,
composed: true,
});
this.dispatchEvent(event);
// If parent rejects (restores value), this.value will be set back externally
// Otherwise, keep the new value
this.listening = false;
this.requestUpdate();
}
private handleBlur() {
this.listening = false;
this.requestUpdate();
}
@@ -100,7 +131,10 @@ export class SettingKeybind extends LitElement {
this.value = this.defaultKey;
this.dispatchEvent(
new CustomEvent("change", {
detail: { action: this.action, value: this.defaultKey },
detail: {
action: this.action,
value: this.defaultKey,
},
bubbles: true,
composed: true,
}),
@@ -108,10 +142,14 @@ export class SettingKeybind extends LitElement {
}
private unbindKey() {
this.value = "";
this.value = "Null";
this.dispatchEvent(
new CustomEvent("change", {
detail: { action: this.action, value: "Null" },
detail: {
action: this.action,
value: "Null",
key: "",
},
bubbles: true,
composed: true,
}),
@@ -29,18 +29,28 @@ export class SettingNumber extends LitElement {
}
render() {
const rainbowClass = this.easter
? "bg-[linear-gradient(270deg,#990033,#996600,#336600,#008080,#1c3f99,#5e0099,#990033)] bg-[length:1400%_1400%] animate-rainbow-bg text-white hover:bg-[linear-gradient(270deg,#990033,#996600,#336600,#008080,#1c3f99,#5e0099,#990033)]"
: "";
return html`
<div class="setting-item${this.easter ? " easter-egg" : ""}">
<div class="setting-label-group">
<label class="setting-label" for="setting-number-input"
<div
class="flex flex-row items-center justify-between w-full p-4 bg-white/5 border border-white/10 rounded-xl hover:bg-white/10 transition-all gap-4 ${rainbowClass}"
>
<div class="flex flex-col flex-1 min-w-0 mr-4">
<label
class="text-white font-bold text-base block mb-1"
for="setting-number-input"
>${this.label}</label
>
<div class="setting-description">${this.description}</div>
<div class="text-white/50 text-sm leading-snug">
${this.description}
</div>
</div>
<input
type="number"
id="setting-number-input"
class="setting-input number"
class="shrink-0 w-[100px] py-2 px-3 border border-white/20 rounded-lg bg-black/40 text-white font-mono text-center focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all"
.value=${String(this.value ?? 0)}
min=${this.min}
max=${this.max}
@@ -28,20 +28,10 @@ export class SettingSlider extends LitElement {
);
}
private handleSliderChange(e: Event) {
const detail = (e as CustomEvent)?.detail;
if (!detail || detail.value === undefined) {
console.warn("Invalid slider change event", e);
return;
}
const value = detail.value;
console.log("Slider changed to", value);
}
private updateSliderStyle(slider: HTMLInputElement) {
const percent = ((this.value - this.min) / (this.max - this.min)) * 100;
slider.style.background = `linear-gradient(to right, #2196f3 ${percent}%, #444 ${percent}%)`;
const clamped = Math.max(0, Math.min(100, percent));
slider.style.setProperty("--fill", `${clamped}%`);
}
firstUpdated() {
@@ -52,24 +42,39 @@ export class SettingSlider extends LitElement {
}
render() {
const rainbowClass = this.easter
? "bg-[linear-gradient(270deg,#990033,#996600,#336600,#008080,#1c3f99,#5e0099,#990033)] bg-[length:1400%_1400%] animate-rainbow-bg text-white hover:bg-[linear-gradient(270deg,#990033,#996600,#336600,#008080,#1c3f99,#5e0099,#990033)]"
: "";
return html`
<div class="setting-item vertical${this.easter ? " easter-egg" : ""}">
<div class="setting-label-group">
<label class="setting-label" for="setting-slider-input"
<div
class="flex flex-row items-center justify-between w-full p-4 bg-white/5 border border-white/10 rounded-xl hover:bg-white/10 transition-all gap-4 ${rainbowClass}"
>
<div class="flex flex-col flex-1 min-w-0 mr-4">
<label class="text-white font-bold text-base block mb-1"
>${this.label}</label
>
<div class="setting-description">${this.description}</div>
<div class="text-white/50 text-sm leading-snug">
${this.description}
</div>
</div>
<div class="flex flex-col items-end gap-2 shrink-0 w-[200px]">
<span class="text-white font-bold text-sm">${this.value}%</span>
<input
type="range"
class="w-full appearance-none h-2 bg-transparent rounded outline-none
[&::-webkit-slider-runnable-track]:h-2 [&::-webkit-slider-runnable-track]:rounded [&::-webkit-slider-runnable-track]:bg-[image:linear-gradient(to_right,#3b82f6_0%,#3b82f6_var(--fill),rgba(255,255,255,0.1)_var(--fill),rgba(255,255,255,0.1)_100%)]
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-[18px] [&::-webkit-slider-thumb]:w-[18px] [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-blue-500 [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:-mt-[6px] [&::-webkit-slider-thumb]:shadow-[0_0_0_4px_rgba(59,130,246,0.2)] [&::-webkit-slider-thumb]:transition-all active:[&::-webkit-slider-thumb]:scale-110 active:[&::-webkit-slider-thumb]:shadow-[0_0_0_6px_rgba(59,130,246,0.3)]
[&::-moz-range-track]:h-2 [&::-moz-range-track]:rounded [&::-moz-range-track]:bg-white/10
[&::-moz-range-progress]:h-2 [&::-moz-range-progress]:rounded [&::-moz-range-progress]:bg-blue-500
[&::-moz-range-thumb]:h-[18px] [&::-moz-range-thumb]:w-[18px] [&::-moz-range-thumb]:border-none [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-blue-500 [&::-moz-range-thumb]:cursor-pointer [&::-moz-range-thumb]:shadow-[0_0_0_4px_rgba(59,130,246,0.2)] [&::-moz-range-thumb]:transition-all active:[&::-moz-range-thumb]:scale-110 active:[&::-moz-range-thumb]:shadow-[0_0_0_6px_rgba(59,130,246,0.3)]"
min=${this.min}
max=${this.max}
.value=${String(this.value)}
@input=${this.handleInput}
/>
</div>
<input
type="range"
id="setting-slider-input"
class="setting-input slider full-width"
min=${this.min}
max=${this.max}
.value=${String(this.value)}
@input=${this.handleInput}
/>
<div class="slider-value">${this.value}%</div>
</div>
`;
}
@@ -26,22 +26,39 @@ export class SettingToggle extends LitElement {
}
render() {
const rainbowClass = this.easter
? "bg-[linear-gradient(270deg,#990033,#996600,#336600,#008080,#1c3f99,#5e0099,#990033)] bg-[length:1400%_1400%] animate-rainbow-bg text-white hover:bg-[linear-gradient(270deg,#990033,#996600,#336600,#008080,#1c3f99,#5e0099,#990033)]"
: "";
return html`
<div class="setting-item vertical${this.easter ? " easter-egg" : ""}">
<div class="toggle-row">
<label class="setting-label" for=${this.id}>${this.label}</label>
<label class="switch">
<input
type="checkbox"
id=${this.id}
?checked=${this.checked}
@change=${this.handleChange}
/>
<span class="slider-round"></span>
</label>
<label
class="flex flex-row items-center justify-between w-full p-4 bg-white/5 border border-white/10 rounded-xl hover:bg-white/10 transition-all gap-4 cursor-pointer ${rainbowClass}"
>
<div class="flex flex-col flex-1 min-w-0 mr-4">
<div class="text-white font-bold text-base block mb-1">
${this.label}
</div>
<div class="text-white/50 text-sm leading-snug">
${this.description}
</div>
</div>
<div class="setting-description">${this.description}</div>
</div>
<div class="relative inline-block w-[52px] h-[28px] shrink-0">
<input
type="checkbox"
class="opacity-0 w-0 h-0 peer"
id=${this.id}
?checked=${this.checked}
@change=${this.handleChange}
/>
<span
class="absolute inset-0 bg-black/40 border border-white/10 transition-all duration-300 rounded-full
before:absolute before:content-[''] before:h-5 before:w-5 before:left-[3px] before:top-[3px]
before:bg-white/40 before:transition-all before:duration-300 before:rounded-full before:shadow-sm hover:before:bg-white/60
peer-checked:bg-blue-600 peer-checked:border-blue-500 peer-checked:before:translate-x-[24px] peer-checked:before:bg-white"
></span>
</div>
</label>
`;
}
}
@@ -1,32 +1,13 @@
import { LitElement, css, html } from "lit";
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import type { DiscordUser } from "../../../../core/ApiSchemas";
import { translateText } from "../../../Utils";
@customElement("discord-user-header")
export class DiscordUserHeader extends LitElement {
static styles = css`
.wrap {
display: flex;
align-items: center;
gap: 0.5rem;
}
.avatarFrame {
padding: 3px;
border-radius: 9999px;
background: #6b7280; /* bg-gray-500 */
}
.avatar {
width: 48px;
height: 48px;
border-radius: 9999px;
display: block;
}
.name {
font-weight: 600;
color: white;
}
`;
createRenderRoot() {
return this;
}
@state() private _data: DiscordUser | null = null;
@@ -59,19 +40,19 @@ export class DiscordUserHeader extends LitElement {
render() {
return html`
<div class="wrap">
<div class="flex items-center gap-2">
${this.avatarUrl
? html`
<div class="avatarFrame">
<div class="p-[3px] rounded-full bg-gray-500">
<img
class="avatar"
class="w-12 h-12 rounded-full block"
src="${this.avatarUrl}"
alt="${translateText("discord_user_header.avatar_alt")}"
/>
</div>
`
: null}
<span class="name">${this.discordDisplayName}</span>
<span class="font-semibold text-white">${this.discordDisplayName}</span>
</div>
`;
}
@@ -1,4 +1,4 @@
import { LitElement, css, html } from "lit";
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { PlayerGame } from "../../../../core/ApiSchemas";
import { GameMode } from "../../../../core/game/Game";
@@ -7,52 +7,9 @@ import { translateText } from "../../../Utils";
@customElement("game-list")
export class GameList extends LitElement {
static styles = css`
.section-title {
color: #888;
font-size: 1rem;
font-weight: bold;
margin-bottom: 0.5rem;
}
.card {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0.5rem;
overflow: hidden;
transition: all 0.3s ease;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem;
}
.title {
font-size: 0.875rem;
font-weight: 600;
color: white;
}
.subtle {
font-size: 0.75rem;
color: #9ca3af;
}
.btn {
font-size: 0.875rem;
color: #d1d5db;
background: #374151;
padding: 0.25rem 0.75rem;
border-radius: 0.25rem;
}
.btn.secondary {
background: #4b5563;
}
.details {
padding: 0 1rem 0.5rem 1rem;
font-size: 0.75rem;
color: #d1d5db;
transition: all 0.3s ease;
}
`;
createRenderRoot() {
return this;
}
@property({ type: Array }) games: PlayerGame[] = [];
@property({ attribute: false }) onViewGame?: (id: string) => void;
@@ -77,91 +34,115 @@ export class GameList extends LitElement {
}
render() {
return html` <div class="mt-4 w-full max-w-md">
<div class="text-sm text-gray-400 font-semibold mb-1">
<div class="section-title">
🎮 ${translateText("game_list.recent_games")}
</div>
<div class="flex flex-col gap-2">
${this.games.map(
(game) => html`
<div class="card">
<div class="row">
return html` <div class="w-full">
<div class="flex flex-col gap-3">
${this.games.map(
(game) => html`
<div
class="bg-white/5 border border-white/5 rounded-xl overflow-hidden hover:bg-white/10 transition-all duration-200"
>
<div
class="flex flex-col sm:flex-row sm:items-center justify-between px-4 py-3 gap-3"
>
<div class="flex items-center gap-4">
<div class="p-2 bg-blue-500/20 rounded-lg text-blue-400">
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10"></circle>
<polygon points="10 8 16 12 10 16 10 8"></polygon>
</svg>
</div>
<div>
<div class="title">
${translateText("game_list.game_id")}: ${game.gameId}
<div class="text-sm font-bold text-white tracking-wide">
${new Date(game.start).toLocaleDateString()}
</div>
<div class="subtle">
<div
class="text-xs text-blue-200/60 font-semibold uppercase tracking-wider"
>
${translateText("game_list.mode")}:
${game.mode === GameMode.FFA
? translateText("game_list.mode_ffa")
: html`${translateText("game_list.mode_team")}`}
</div>
</div>
<div class="flex gap-2">
<button
class="btn"
@click=${() => this.onViewGame?.(game.gameId)}
>
${translateText("game_list.view")}
</button>
<button
class="btn secondary"
@click=${() => this.toggle(game.gameId)}
>
${translateText("game_list.details")}
</button>
<button
class="btn secondary"
@click=${() => this.showRanking(game.gameId)}
>
${translateText("game_list.ranking")}
</button>
</div>
</div>
<div
class="details max-h-(--max-height) ${this.expandedGameId ===
game.gameId
? "max-h-50"
: "py-0"}"
>
<div class="flex gap-2 self-end sm:self-auto">
<button
class="text-xs font-bold text-white bg-blue-600 hover:bg-blue-500 px-3 py-1.5 rounded-lg transition-colors shadow-lg shadow-blue-900/20"
@click=${() => this.onViewGame?.(game.gameId)}
>
${translateText("game_list.replay")}
</button>
<button
class="text-xs font-bold text-gray-300 bg-white/10 hover:bg-white/20 px-3 py-1.5 rounded-lg transition-colors border border-white/5"
@click=${() => this.toggle(game.gameId)}
>
${translateText("game_list.details")}
</button>
<button
class="text-xs font-bold text-gray-300 bg-white/10 hover:bg-white/20 px-3 py-1.5 rounded-lg transition-colors border border-white/5"
@click=${() => this.showRanking(game.gameId)}
>
${translateText("game_list.ranking")}
</button>
</div>
</div>
<div
class="bg-black/20 border-t border-white/5 px-4 text-xs text-gray-400 transition-all duration-300 overflow-hidden"
style="max-height:${this.expandedGameId === game.gameId
? "200px"
: "0"}; opacity:${this.expandedGameId === game.gameId
? "1"
: "0"}"
>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 py-3">
<div>
<span class="title text-xs"
>${translateText("game_list.started")}:</span
<div
class="font-bold text-white uppercase tracking-wider text-[10px] mb-1"
>
${new Date(game.start).toLocaleString()}
${translateText("game_list.game_id")}
</div>
<div class="text-white font-mono">${game.gameId}</div>
</div>
<div>
<span class="title text-xs"
>${translateText("game_list.mode")}:</span
<div
class="font-bold text-white uppercase tracking-wider text-[10px] mb-1"
>
${game.mode === GameMode.FFA
? translateText("game_list.mode_ffa")
: translateText("game_list.mode_team")}
${translateText("game_list.map")}
</div>
<div class="text-white">${game.map}</div>
</div>
<div>
<span class="title text-xs"
>${translateText("game_list.map")}:</span
<div
class="font-bold text-white uppercase tracking-wider text-[10px] mb-1"
>
${game.map}
${translateText("game_list.difficulty")}
</div>
<div class="text-white">${game.difficulty}</div>
</div>
<div>
<span class="title text-xs"
>${translateText("game_list.difficulty")}:</span
<div
class="font-bold text-white uppercase tracking-wider text-[10px] mb-1"
>
${game.difficulty}
</div>
<div>
<span class="title text-xs"
>${translateText("game_list.type")}:</span
>
${game.type}
${translateText("game_list.type")}
</div>
<div class="text-white">${game.type}</div>
</div>
</div>
</div>
`,
)}
</div>
</div>
`,
)}
</div>
</div>`;
}
@@ -1,33 +1,11 @@
import { LitElement, css, html } from "lit";
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
@customElement("player-stats-grid")
export class PlayerStatsGrid extends LitElement {
static styles = css`
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
@media (min-width: 640px) {
.grid {
grid-template-columns: repeat(2, 1fr);
}
}
.stat {
text-align: center;
color: white;
font-size: 1rem;
}
.stat-title {
color: #bbb;
font-size: 0.9rem;
}
.stat-value {
font-size: 1.25rem;
font-weight: bold;
}
`;
createRenderRoot() {
return this;
}
@property({ type: Array }) titles: string[] = [];
@property({ type: Array }) values: Array<string | number> = [];
@@ -37,14 +15,22 @@ export class PlayerStatsGrid extends LitElement {
render() {
return html`
<div class="grid mb-2">
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-2">
${Array(this.VISIBLE_STATS_COUNT)
.fill(0)
.map(
(_, i) => html`
<div class="stat">
<div class="stat-value">${this.values[i] ?? ""}</div>
<div class="stat-title">${this.titles[i] ?? ""}</div>
<div
class="flex flex-col items-center justify-center p-4 rounded-xl bg-white/5 border border-white/5 hover:bg-white/10 transition-colors"
>
<div class="text-2xl font-bold text-white mb-1">
${this.values[i] ?? ""}
</div>
<div
class="text-blue-200/60 text-xs font-bold uppercase tracking-widest"
>
${this.titles[i] ?? ""}
</div>
</div>
`,
)}
@@ -1,4 +1,4 @@
import { LitElement, css, html } from "lit";
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import {
PlayerStats,
@@ -10,186 +10,271 @@ import { renderNumber, translateText } from "../../../Utils";
@customElement("player-stats-table")
export class PlayerStatsTable extends LitElement {
static styles = css`
.table-container {
margin-top: 1rem;
width: 100%;
max-width: 28rem;
}
table {
width: 100%;
font-size: 0.95rem;
color: #ccc;
border-collapse: collapse;
}
th,
td {
padding: 0.25rem 0.5rem;
text-align: center;
}
th {
color: #bbb;
font-weight: 600;
}
.section-title {
color: #888;
font-size: 1rem;
font-weight: bold;
margin-bottom: 0.5rem;
}
`;
createRenderRoot() {
return this;
}
@property({ type: Object }) stats: PlayerStats;
render() {
return html`
<div class="table-container">
<div class="section-title">
${translateText("player_stats_table.building_stats")}
</div>
<table>
<thead>
<tr>
<th class="text-left">
${translateText("player_stats_table.building")}
</th>
<th>${translateText("player_stats_table.built")}</th>
<th>${translateText("player_stats_table.destroyed")}</th>
<th>${translateText("player_stats_table.captured")}</th>
<th>${translateText("player_stats_table.lost")}</th>
</tr>
</thead>
<tbody>
${otherUnits.map((key) => {
const built = this.stats?.units?.[key]?.[0] ?? 0n;
const destroyed = this.stats?.units?.[key]?.[1] ?? 0n;
const captured = this.stats?.units?.[key]?.[2] ?? 0n;
const lost = this.stats?.units?.[key]?.[3] ?? 0n;
return html`
<tr>
<td>${translateText(`player_stats_table.unit.${key}`)}</td>
<td>${renderNumber(built)}</td>
<td>${renderNumber(destroyed)}</td>
<td>${renderNumber(captured)}</td>
<td>${renderNumber(lost)}</td>
<div class="grid grid-cols-1 gap-6 w-full">
<div class="w-full">
<div
class="text-gray-400 text-sm font-bold uppercase tracking-wider mb-2"
>
${translateText("player_stats_table.building_stats")}
</div>
<div
class="overflow-x-auto rounded-lg border border-white/5 bg-black/20"
>
<table class="w-full text-sm text-gray-300">
<thead>
<tr class="bg-white/5">
<th class="px-4 py-2 font-semibold text-left text-gray-400">
${translateText("player_stats_table.building")}
</th>
<th class="px-3 py-2 text-center font-semibold text-gray-400">
${translateText("player_stats_table.built")}
</th>
<th class="px-3 py-2 text-center font-semibold text-gray-400">
${translateText("player_stats_table.destroyed")}
</th>
<th class="px-3 py-2 text-center font-semibold text-gray-400">
${translateText("player_stats_table.captured")}
</th>
<th class="px-3 py-2 text-center font-semibold text-gray-400">
${translateText("player_stats_table.lost")}
</th>
</tr>
`;
})}
</tbody>
</table>
</div>
<div class="table-container">
<div class="section-title">
${translateText("player_stats_table.ship_arrivals")}
</thead>
<tbody class="divide-y divide-white/5">
${otherUnits.map((key) => {
const built = this.stats?.units?.[key]?.[0] ?? 0n;
const destroyed = this.stats?.units?.[key]?.[1] ?? 0n;
const captured = this.stats?.units?.[key]?.[2] ?? 0n;
const lost = this.stats?.units?.[key]?.[3] ?? 0n;
return html`
<tr class="hover:bg-white/5 transition-colors">
<td class="px-4 py-2 text-left font-medium text-white/80">
${translateText(`player_stats_table.unit.${key}`)}
</td>
<td class="px-3 py-2 text-center text-white/60">
${renderNumber(built)}
</td>
<td class="px-3 py-2 text-center text-white/60">
${renderNumber(destroyed)}
</td>
<td class="px-3 py-2 text-center text-white/60">
${renderNumber(captured)}
</td>
<td class="px-3 py-2 text-center text-white/60">
${renderNumber(lost)}
</td>
</tr>
`;
})}
</tbody>
</table>
</div>
</div>
<table>
<thead>
<tr>
<th class="text-left">
${translateText("player_stats_table.ship_type")}
</th>
<th>${translateText("player_stats_table.sent")}</th>
<th>${translateText("player_stats_table.destroyed")}</th>
<th>${translateText("player_stats_table.arrived")}</th>
</tr>
</thead>
<tbody>
${boatUnits.map((key) => {
const sent = this.stats?.boats?.[key]?.[0] ?? 0n;
const arrived = this.stats?.boats?.[key]?.[1] ?? 0n;
const destroyed = this.stats?.boats?.[key]?.[3] ?? 0n;
return html`
<tr>
<td>${translateText(`player_stats_table.unit.${key}`)}</td>
<td>${renderNumber(sent)}</td>
<td>${renderNumber(destroyed)}</td>
<td>${renderNumber(arrived)}</td>
<div class="w-full">
<div
class="text-gray-400 text-sm font-bold uppercase tracking-wider mb-2"
>
${translateText("player_stats_table.ship_arrivals")}
</div>
<div
class="overflow-x-auto rounded-lg border border-white/5 bg-black/20"
>
<table class="w-full text-sm text-gray-300">
<thead>
<tr class="bg-white/5">
<th class="px-4 py-2 font-semibold text-left text-gray-400">
${translateText("player_stats_table.ship_type")}
</th>
<th class="px-3 py-2 text-center font-semibold text-gray-400">
${translateText("player_stats_table.sent")}
</th>
<th class="px-3 py-2 text-center font-semibold text-gray-400">
${translateText("player_stats_table.destroyed")}
</th>
<th class="px-3 py-2 text-center font-semibold text-gray-400">
${translateText("player_stats_table.arrived")}
</th>
</tr>
`;
})}
</tbody>
</table>
</div>
<div class="table-container">
<div class="section-title">
${translateText("player_stats_table.nuke_stats")}
</thead>
<tbody class="divide-y divide-white/5">
${boatUnits.map((key) => {
const sent = this.stats?.boats?.[key]?.[0] ?? 0n;
const arrived = this.stats?.boats?.[key]?.[1] ?? 0n;
const destroyed = this.stats?.boats?.[key]?.[3] ?? 0n;
return html`
<tr class="hover:bg-white/5 transition-colors">
<td class="px-4 py-2 text-left font-medium text-white/80">
${translateText(`player_stats_table.unit.${key}`)}
</td>
<td class="px-3 py-2 text-center text-white/60">
${renderNumber(sent)}
</td>
<td class="px-3 py-2 text-center text-white/60">
${renderNumber(destroyed)}
</td>
<td class="px-3 py-2 text-center text-white/60">
${renderNumber(arrived)}
</td>
</tr>
`;
})}
</tbody>
</table>
</div>
</div>
<table>
<thead>
<tr>
<th class="text-left w-2/5">
${translateText("player_stats_table.weapon")}
</th>
<th class="text-center w-1/5">
${translateText("player_stats_table.launched")}
</th>
<th class="text-center w-1/5">
${translateText("player_stats_table.landed")}
</th>
<th class="text-center w-1/5">
${translateText("player_stats_table.hits")}
</th>
</tr>
</thead>
<tbody>
${bombUnits.map((bomb) => {
const launched = this.stats?.bombs?.[bomb]?.[0] ?? 0n;
const landed = this.stats?.bombs?.[bomb]?.[1] ?? 0n;
const intercepted = this.stats?.bombs?.[bomb]?.[2] ?? 0n;
return html`
<tr>
<td>${translateText(`player_stats_table.unit.${bomb}`)}</td>
<td class="text-center">${renderNumber(launched)}</td>
<td class="text-center">${renderNumber(landed)}</td>
<td class="text-center">${renderNumber(intercepted)}</td>
<div class="w-full">
<div
class="text-gray-400 text-sm font-bold uppercase tracking-wider mb-2"
>
${translateText("player_stats_table.nuke_stats")}
</div>
<div
class="overflow-x-auto rounded-lg border border-white/5 bg-black/20"
>
<table class="w-full text-sm text-gray-300">
<thead>
<tr class="bg-white/5">
<th class="px-4 py-2 font-semibold text-left text-gray-400">
${translateText("player_stats_table.weapon")}
</th>
<th class="px-3 py-2 text-center font-semibold text-gray-400">
${translateText("player_stats_table.launched")}
</th>
<th class="px-3 py-2 text-center font-semibold text-gray-400">
${translateText("player_stats_table.landed")}
</th>
<th class="px-3 py-2 text-center font-semibold text-gray-400">
${translateText("player_stats_table.hits")}
</th>
</tr>
`;
})}
</tbody>
</table>
</div>
<div class="table-container">
<div class="section-title">
${translateText("player_stats_table.player_metrics")}
</thead>
<tbody class="divide-y divide-white/5">
${bombUnits.map((bomb) => {
const launched = this.stats?.bombs?.[bomb]?.[0] ?? 0n;
const landed = this.stats?.bombs?.[bomb]?.[1] ?? 0n;
const intercepted = this.stats?.bombs?.[bomb]?.[2] ?? 0n;
return html`
<tr class="hover:bg-white/5 transition-colors">
<td class="px-4 py-2 text-left font-medium text-white/80">
${translateText(`player_stats_table.unit.${bomb}`)}
</td>
<td class="px-3 py-2 text-center text-white/60">
${renderNumber(launched)}
</td>
<td class="px-3 py-2 text-center text-white/60">
${renderNumber(landed)}
</td>
<td class="px-3 py-2 text-center text-white/60">
${renderNumber(intercepted)}
</td>
</tr>
`;
})}
</tbody>
</table>
</div>
</div>
<div class="w-full">
<div
class="text-gray-400 text-sm font-bold uppercase tracking-wider mb-2"
>
${translateText("player_stats_table.player_metrics")}
</div>
<div
class="overflow-x-auto rounded-lg border border-white/5 bg-black/20 mb-4"
>
<table class="w-full text-sm text-gray-300">
<thead>
<tr class="bg-white/5">
<th class="px-4 py-2 text-center font-semibold text-gray-400">
${translateText("player_stats_table.attack")}
</th>
<th class="px-3 py-2 text-center font-semibold text-gray-400">
${translateText("player_stats_table.sent")}
</th>
<th class="px-3 py-2 text-center font-semibold text-gray-400">
${translateText("player_stats_table.received")}
</th>
<th class="px-3 py-2 text-center font-semibold text-gray-400">
${translateText("player_stats_table.cancelled")}
</th>
</tr>
</thead>
<tbody class="divide-y divide-white/5">
<tr class="hover:bg-white/5 transition-colors">
<td class="px-4 py-2 text-center text-white/60">
${translateText("player_stats_table.count")}
</td>
<td class="px-3 py-2 text-center text-white/60">
${renderNumber(this.stats?.attacks?.[0] ?? 0n)}
</td>
<td class="px-3 py-2 text-center text-white/60">
${renderNumber(this.stats?.attacks?.[1] ?? 0n)}
</td>
<td class="px-3 py-2 text-center text-white/60">
${renderNumber(this.stats?.attacks?.[2] ?? 0n)}
</td>
</tr>
</tbody>
</table>
</div>
<div
class="overflow-x-auto rounded-lg border border-white/5 bg-black/20"
>
<table class="w-full text-sm text-gray-300">
<thead>
<tr class="bg-white/5">
<th class="px-3 py-2 text-center font-semibold text-gray-400">
${translateText("player_stats_table.gold")}
</th>
<th class="px-3 py-2 text-center font-semibold text-gray-400">
${translateText("player_stats_table.workers")}
</th>
<th class="px-3 py-2 text-center font-semibold text-gray-400">
${translateText("player_stats_table.war")}
</th>
<th class="px-3 py-2 text-center font-semibold text-gray-400">
${translateText("player_stats_table.trade")}
</th>
<th class="px-3 py-2 text-center font-semibold text-gray-400">
${translateText("player_stats_table.steal")}
</th>
</tr>
</thead>
<tbody class="divide-y divide-white/5">
<tr class="hover:bg-white/5 transition-colors">
<td class="px-3 py-2 text-center text-white/60">
${renderNumber(this.stats?.gold?.[0] ?? 0n)}
</td>
<td class="px-3 py-2 text-center text-white/60">
${renderNumber(this.stats?.gold?.[1] ?? 0n)}
</td>
<td class="px-3 py-2 text-center text-white/60">
${renderNumber(this.stats?.gold?.[2] ?? 0n)}
</td>
<td class="px-3 py-2 text-center text-white/60">
${renderNumber(this.stats?.gold?.[3] ?? 0n)}
</td>
<td class="px-3 py-2 text-center text-white/60">
${renderNumber(this.stats?.gold?.[4] ?? 0n)}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<table>
<thead>
<tr>
<th>${translateText("player_stats_table.attack")}</th>
<th>${translateText("player_stats_table.sent")}</th>
<th>${translateText("player_stats_table.received")}</th>
<th>${translateText("player_stats_table.cancelled")}</th>
</tr>
</thead>
<tbody>
<tr>
<td>${translateText("player_stats_table.count")}</td>
<td>${renderNumber(this.stats?.attacks?.[0] ?? 0n)}</td>
<td>${renderNumber(this.stats?.attacks?.[1] ?? 0n)}</td>
<td>${renderNumber(this.stats?.attacks?.[2] ?? 0n)}</td>
</tr>
</tbody>
</table>
<table class="mt-3">
<thead>
<tr>
<th>${translateText("player_stats_table.gold")}</th>
<th>${translateText("player_stats_table.workers")}</th>
<th>${translateText("player_stats_table.war")}</th>
<th>${translateText("player_stats_table.trade")}</th>
<th>${translateText("player_stats_table.steal")}</th>
</tr>
</thead>
<tbody>
<tr>
<td>${translateText("player_stats_table.count")}</td>
<td>${renderNumber(this.stats?.gold?.[0] ?? 0n)}</td>
<td>${renderNumber(this.stats?.gold?.[1] ?? 0n)}</td>
<td>${renderNumber(this.stats?.gold?.[2] ?? 0n)}</td>
<td>${renderNumber(this.stats?.gold?.[3] ?? 0n)}</td>
</tr>
</tbody>
</table>
</div>
`;
}
@@ -117,86 +117,111 @@ export class PlayerStatsTreeView extends LitElement {
: 0;
return html`
<!-- Type selector -->
<div class="flex gap-2 mt-2 justify-center">
${types.map(
(t) => html`
<button
class="text-xs px-2 py-0.5 rounded-sm border ${this
.selectedType === t
? "border-white/60 text-white"
: "border-white/20 text-gray-300"}"
@click=${() => this.setGameType(t)}
>
${t === GameType.Public
? translateText("player_stats_tree.public")
: t === GameType.Private
? translateText("player_stats_tree.private")
: translateText("player_stats_tree.singleplayer")}
</button>
`,
)}
</div>
<!-- Mode selector -->
${modes.length
? html`<div class="flex gap-2 mt-2 justify-center">
${modes.map(
(m) => html`
<div class="flex flex-col gap-4">
<!-- Filters -->
<div
class="flex flex-wrap gap-2 items-center justify-between p-2 bg-black/20 rounded-lg border border-white/5"
>
<!-- Type selector -->
<div class="flex gap-1">
${types.map(
(t) => html`
<button
class="text-xs px-2 py-0.5 rounded-sm border ${this
.selectedMode === m
? "border-white/60 text-white"
: "border-white/20 text-gray-300"}"
@click=${() => this.setMode(m)}
title=${translateText("player_stats_tree.mode")}
class="text-xs px-3 py-1.5 rounded-md border font-bold uppercase tracking-wider transition-all duration-200 ${this
.selectedType === t
? "bg-blue-600 border-blue-500 text-white shadow-lg shadow-blue-900/40"
: "bg-white/5 border-white/10 text-gray-400 hover:bg-white/10 hover:text-white"}"
@click=${() => this.setGameType(t)}
>
${this.labelForMode(m)}
${t === GameType.Public
? translateText("player_stats_tree.public")
: t === GameType.Private
? translateText("player_stats_tree.private")
: translateText("player_stats_tree.solo")}
</button>
`,
)}
</div>`
: html``}
<!-- Difficulty selector -->
${diffs.length
? html`<div class="flex gap-2 mt-2 justify-center">
${diffs.map(
(d) =>
html` <button
class="text-xs px-2 py-0.5 rounded-sm border ${this
.selectedDifficulty === d
? "border-white/60 text-white"
: "border-white/20 text-gray-300"}"
@click=${() => this.setDifficulty(d)}
title=${translateText("difficulty.difficulty")}
</div>
<div class="flex gap-2">
<!-- Mode selector -->
${modes.length
? html`<div
class="flex gap-1 bg-black/20 rounded-md p-1 border border-white/5"
>
${translateText(`difficulty.${d}`)}
</button>`,
)}
</div>`
: html``}
${leaf
? html`
<hr class="w-2/3 border-gray-600 my-2" />
<player-stats-grid
.titles=${[
translateText("player_stats_tree.stats_wins"),
translateText("player_stats_tree.stats_losses"),
translateText("player_stats_tree.stats_wlr"),
translateText("player_stats_tree.stats_games_played"),
]}
.values=${[
renderNumber(leaf.wins),
renderNumber(leaf.losses),
wlr.toFixed(2),
renderNumber(leaf.total),
]}
></player-stats-grid>
<hr class="w-2/3 border-gray-600 my-2" />
<player-stats-table
.stats=${this.getDisplayedStats()}
></player-stats-table>
`
: html``}
${modes.map(
(m) => html`
<button
class="text-xs px-3 py-1 rounded-sm transition-colors ${this
.selectedMode === m
? "bg-white/20 text-white font-bold"
: "text-gray-400 hover:text-white"}"
@click=${() => this.setMode(m)}
title=${translateText("player_stats_tree.mode")}
>
${this.labelForMode(m)}
</button>
`,
)}
</div>`
: html``}
<!-- Difficulty selector -->
${diffs.length
? html`<div
class="flex gap-1 bg-black/20 rounded-md p-1 border border-white/5"
>
${diffs.map(
(d) =>
html` <button
class="text-xs px-3 py-1 rounded-sm transition-colors ${this
.selectedDifficulty === d
? "bg-white/20 text-white font-bold"
: "text-gray-400 hover:text-white"}"
@click=${() => this.setDifficulty(d)}
title=${translateText("difficulty.difficulty")}
>
${translateText(`difficulty.${d.toLowerCase()}`)}
</button>`,
)}
</div>`
: html``}
</div>
</div>
${leaf
? html`
<div class="space-y-6 mt-2">
<player-stats-grid
.titles=${[
translateText("player_stats_tree.stats_wins"),
translateText("player_stats_tree.stats_losses"),
translateText("player_stats_tree.stats_wlr"),
translateText("player_stats_tree.stats_games_played"),
]}
.values=${[
renderNumber(leaf.wins),
renderNumber(leaf.losses),
wlr.toFixed(2),
renderNumber(leaf.total),
]}
></player-stats-grid>
<div class="border-t border-white/10 pt-6">
<player-stats-table
.stats=${this.getDisplayedStats()}
></player-stats-table>
</div>
</div>
`
: html`
<div
class="py-12 text-center text-white/30 italic border border-white/5 rounded-xl bg-white/5"
>
${translateText("player_stats_tree.no_stats")}
</div>
`}
</div>
`;
}
}