mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-09 16:18:06 +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:
@@ -0,0 +1,114 @@
|
||||
import { LitElement } from "lit";
|
||||
import { property, query, state } from "lit/decorators.js";
|
||||
|
||||
/**
|
||||
* Base class for modal components that provides unified Escape key handling and common modal patterns.
|
||||
*
|
||||
* Features:
|
||||
* - Visibility tracking with isModalOpen state
|
||||
* - Escape key handler with visibility check and target validation
|
||||
* - Automatic listener lifecycle management
|
||||
* - Common inline/modal element handling
|
||||
* - Shared open/close logic with hooks for custom behavior
|
||||
*/
|
||||
export abstract class BaseModal extends LitElement {
|
||||
@state() protected isModalOpen = false;
|
||||
@property({ type: Boolean }) inline = false;
|
||||
|
||||
@query("o-modal") protected modalEl?: HTMLElement & {
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.unregisterEscapeHandler();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Escape key press to close the modal.
|
||||
* Only closes if the modal is open.
|
||||
*/
|
||||
private handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && this.isModalOpen) {
|
||||
e.preventDefault();
|
||||
this.close();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Register the Escape key handler and mark modal as open.
|
||||
*/
|
||||
protected registerEscapeHandler() {
|
||||
this.isModalOpen = true;
|
||||
window.addEventListener("keydown", this.handleKeyDown);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister the Escape key handler and mark modal as closed.
|
||||
*/
|
||||
protected unregisterEscapeHandler() {
|
||||
this.isModalOpen = false;
|
||||
window.removeEventListener("keydown", this.handleKeyDown);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for custom logic when modal opens.
|
||||
* Override this in subclasses to add custom open behavior.
|
||||
*/
|
||||
protected onOpen(): void {
|
||||
// Default implementation does nothing
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for custom logic when modal closes.
|
||||
* Override this in subclasses to add custom close behavior.
|
||||
*/
|
||||
protected onClose(): void {
|
||||
// Default implementation does nothing
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the modal. Handles both inline and modal element modes.
|
||||
* Subclasses can override onOpen() for custom behavior.
|
||||
*/
|
||||
public open(): void {
|
||||
this.registerEscapeHandler();
|
||||
this.onOpen();
|
||||
|
||||
if (this.inline) {
|
||||
const needsShow =
|
||||
this.classList.contains("hidden") || this.style.display === "none";
|
||||
if (needsShow && window.showPage) {
|
||||
const pageId = this.id || this.tagName.toLowerCase();
|
||||
window.showPage?.(pageId);
|
||||
}
|
||||
this.style.pointerEvents = "auto";
|
||||
} else {
|
||||
this.modalEl?.open();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the modal. Handles both inline and modal element modes.
|
||||
* Subclasses can override onClose() for custom behavior.
|
||||
*/
|
||||
public close(): void {
|
||||
this.unregisterEscapeHandler();
|
||||
this.onClose();
|
||||
|
||||
if (this.inline) {
|
||||
this.style.pointerEvents = "none";
|
||||
if (window.showPage) {
|
||||
window.showPage?.("page-play");
|
||||
}
|
||||
} else {
|
||||
this.modalEl?.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,50 +1,13 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
@customElement("difficulty-display")
|
||||
export class DifficultyDisplay extends LitElement {
|
||||
@property({ type: String }) difficultyKey = "";
|
||||
|
||||
static styles = css`
|
||||
.difficulty-indicator {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
gap: 6px;
|
||||
margin: 4px 0 0 0;
|
||||
}
|
||||
|
||||
.difficulty-skull {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
opacity: 0.3;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.difficulty-skull.big {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.difficulty-skull.active {
|
||||
opacity: 1;
|
||||
color: #ff3838;
|
||||
filter: drop-shadow(0 0 4px rgba(255, 56, 56, 0.4));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
:host(:hover) .difficulty-skull.active {
|
||||
filter: drop-shadow(0 0 6px rgba(255, 56, 56, 0.6));
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
:host(.disabled-parent) .difficulty-skull.active,
|
||||
:host(.disabled-parent:hover) .difficulty-skull.active {
|
||||
filter: drop-shadow(0 0 4px rgba(255, 56, 56, 0.4));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
`;
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
private getDifficultyIcon(difficultyKey: string) {
|
||||
const skull = html`<svg
|
||||
@@ -80,21 +43,6 @@ export class DifficultyDisplay extends LitElement {
|
||||
></path>
|
||||
</svg>`;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const kingSkull = html`<svg
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
stroke-width="0"
|
||||
viewBox="0 0 512 512"
|
||||
height="100%"
|
||||
width="100%"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M92.406 13.02l-.164 156.353c3.064.507 6.208 1.38 9.39 2.627 36.496 14.306 74.214 22.435 111.864 25.473l43.402-60.416 42.317 58.906c36.808-4.127 72.566-12.502 105.967-24.09 3.754-1.302 7.368-2.18 10.818-2.6l1.523-156.252-75.82 95.552-34.084-95.55-53.724 103.74-53.722-103.74-35.442 95.55-72.32-95.55h-.006zm164.492 156.07l-28.636 39.86 28.634 39.86 28.637-39.86-28.635-39.86zM86.762 187.55c-2.173-.08-3.84.274-5.012.762-2.345.977-3.173 2.19-3.496 4.196-.645 4.01 2.825 14.35 23.03 21.36 41.7 14.468 84.262 23.748 126.778 26.833l-17.75-24.704c-38.773-3.285-77.69-11.775-115.5-26.596-3.197-1.253-5.877-1.77-8.05-1.85zm333.275.19c-2.156.052-5.048.512-8.728 1.79-33.582 11.65-69.487 20.215-106.523 24.646l-19.264 26.818c40.427-2.602 80.433-11.287 119.22-26.96 15.913-6.43 21.46-17.81 21.36-22.362-.052-2.276-.278-2.566-1.753-3.274-.738-.353-2.157-.71-4.313-.658zm-18.117 47.438c-42.5 15.87-86.26 23.856-130.262 25.117l-14.76 20.547-14.878-20.71c-44.985-1.745-89.98-10.23-133.905-24.306-12.78 28.51-18.94 61.14-19.603 93.44 37.52 17.497 62.135 39.817 75.556 64.63C177 417.8 179.282 443.62 174.184 467.98c7.72 5.007 16.126 9.144 24.98 12.432l5.557-47.89 18.563 2.154-5.935 51.156c9.57 2.21 19.443 3.53 29.377 3.982v-54.67h18.69v54.49c9.903-.638 19.705-2.128 29.155-4.484l-5.857-50.474 18.564-2.155 5.436 46.852c8.747-3.422 17.004-7.643 24.506-12.69-5.758-24.413-3.77-49.666 9.01-72.988 13.28-24.234 37.718-46 74.803-64.29-.62-33.526-6.687-66.122-19.113-94.23zm-266.733 47.006c34.602.23 68.407 12.236 101.358 36.867-46.604 33.147-129.794 34.372-108.29-36.755 2.315-.09 4.626-.127 6.933-.11zm242.825 0c2.307-.016 4.617.022 6.93.11 21.506 71.128-61.684 69.903-108.288 36.757 32.95-24.63 66.756-36.637 101.358-36.866zM255.164 332.14c11.77 21.725 19.193 43.452 25.367 65.178h-50.737c4.57-21.726 13.77-43.45 25.37-65.18z"
|
||||
></path>
|
||||
</svg>`;
|
||||
|
||||
const questionMark = html`<svg
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
@@ -110,31 +58,37 @@ export class DifficultyDisplay extends LitElement {
|
||||
></path>
|
||||
</svg>`;
|
||||
|
||||
const activeClass =
|
||||
"opacity-100 text-[#ff3838] drop-shadow-[0_0_4px_rgba(255,56,56,0.4)] transform group-hover:drop-shadow-[0_0_6px_rgba(255,56,56,0.6)] group-hover:-translate-y-[2px] -translate-y-[1px] transition-all duration-200";
|
||||
const inactiveClass = "opacity-30 w-4 h-4 transition-all duration-200";
|
||||
const bigClass = "w-10 h-10";
|
||||
const smallClass = "w-4 h-4";
|
||||
|
||||
switch (difficultyKey) {
|
||||
case "Easy":
|
||||
return html`
|
||||
<div class="difficulty-skull active">${skull}</div>
|
||||
<div class="difficulty-skull">${skull}</div>
|
||||
<div class="difficulty-skull">${skull}</div>
|
||||
<div class="${smallClass} ${activeClass}">${skull}</div>
|
||||
<div class="${smallClass} ${inactiveClass}">${skull}</div>
|
||||
<div class="${smallClass} ${inactiveClass}">${skull}</div>
|
||||
`;
|
||||
case "Medium":
|
||||
return html`
|
||||
<div class="difficulty-skull active">${skull}</div>
|
||||
<div class="difficulty-skull active">${skull}</div>
|
||||
<div class="difficulty-skull">${skull}</div>
|
||||
<div class="${smallClass} ${activeClass}">${skull}</div>
|
||||
<div class="${smallClass} ${activeClass}">${skull}</div>
|
||||
<div class="${smallClass} ${inactiveClass}">${skull}</div>
|
||||
`;
|
||||
case "Hard":
|
||||
return html`
|
||||
<div class="difficulty-skull active">${skull}</div>
|
||||
<div class="difficulty-skull active">${skull}</div>
|
||||
<div class="difficulty-skull active">${skull}</div>
|
||||
<div class="${smallClass} ${activeClass}">${skull}</div>
|
||||
<div class="${smallClass} ${activeClass}">${skull}</div>
|
||||
<div class="${smallClass} ${activeClass}">${skull}</div>
|
||||
`;
|
||||
case "Impossible":
|
||||
return html`
|
||||
<div class="difficulty-skull big active">${burningSkull}</div>
|
||||
<div class="${bigClass} ${activeClass}">${burningSkull}</div>
|
||||
`;
|
||||
default:
|
||||
return html`<div class="difficulty-skull big active">
|
||||
return html`<div class="${bigClass} ${activeClass}">
|
||||
${questionMark}
|
||||
</div>`;
|
||||
}
|
||||
@@ -142,7 +96,7 @@ export class DifficultyDisplay extends LitElement {
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="difficulty-indicator">
|
||||
<div class="flex justify-center items-center h-10 gap-[6px] mt-1 group">
|
||||
${this.getDifficultyIcon(this.difficultyKey)}
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1,56 +1,12 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators.js";
|
||||
import { translateText } from "../Utils";
|
||||
|
||||
@customElement("fluent-slider")
|
||||
export class FluentSlider extends LitElement {
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
font-family: inherit;
|
||||
}
|
||||
.slider-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
.option-card-title {
|
||||
font-size: 14px; /* match other cards */
|
||||
color: #aaa; /* light gray text */
|
||||
text-align: center;
|
||||
margin: 0 0 4px 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
input[type="range"] {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
background-color: transparent;
|
||||
}
|
||||
input[type="number"] {
|
||||
width: 60px;
|
||||
background-color: #2d3748;
|
||||
color: #aaa; /* match label color */
|
||||
border: 1px solid #4a5568;
|
||||
text-align: center;
|
||||
border-radius: 4px;
|
||||
font-weight: normal;
|
||||
font-family: inherit;
|
||||
}
|
||||
span.editable {
|
||||
cursor: pointer;
|
||||
min-width: 60px;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
color: #aaa; /* match label color */
|
||||
font-weight: normal;
|
||||
user-select: none;
|
||||
}
|
||||
`;
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
@property({ type: Number }) value = 0;
|
||||
@property({ type: Number }) min = 0;
|
||||
@@ -114,18 +70,35 @@ export class FluentSlider extends LitElement {
|
||||
}
|
||||
|
||||
render() {
|
||||
const percentage =
|
||||
this.max === this.min
|
||||
? 0
|
||||
: ((this.value - this.min) / (this.max - this.min)) * 100;
|
||||
return html`
|
||||
<div class="slider-container">
|
||||
<div
|
||||
class="flex flex-col items-center justify-center gap-1 w-full text-center"
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
.min=${this.min}
|
||||
.max=${this.max}
|
||||
.step=${this.step}
|
||||
.valueAsNumber=${this.value}
|
||||
style="background: linear-gradient(to right, #3b82f6 0%, #3b82f6 ${percentage}%, rgba(255, 255, 255, 0.15) ${percentage}%, rgba(255, 255, 255, 0.15) 100%); background-size: 100% 6px; background-repeat: no-repeat; background-position: center; border-radius: 9999px;"
|
||||
class="w-full h-6 p-0 m-0 bg-transparent appearance-none cursor-pointer focus:outline-none
|
||||
[&::-webkit-slider-runnable-track]:w-full [&::-webkit-slider-runnable-track]:h-[6px] [&::-webkit-slider-runnable-track]:cursor-pointer [&::-webkit-slider-runnable-track]:bg-transparent [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:transition-colors
|
||||
[&::-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]:w-full [&::-moz-range-track]:h-[6px] [&::-moz-range-track]:cursor-pointer [&::-moz-range-track]:bg-transparent [&::-moz-range-track]:rounded-full [&::-moz-range-track]:transition-colors
|
||||
[&::-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)]"
|
||||
@input=${this.handleSliderInput}
|
||||
@change=${this.handleSliderChange}
|
||||
/>
|
||||
<div class="option-card-title">
|
||||
<div
|
||||
class="text-xs uppercase font-bold tracking-wider text-center w-full leading-tight mb-1 flex flex-col items-center ${this
|
||||
.value > 0
|
||||
? "text-white"
|
||||
: "text-white/60"}"
|
||||
>
|
||||
<span>${this.labelKey ? translateText(this.labelKey) : ""}</span>
|
||||
${this.isEditing
|
||||
? html`<input
|
||||
@@ -133,6 +106,7 @@ export class FluentSlider extends LitElement {
|
||||
.min=${this.min}
|
||||
.max=${this.max}
|
||||
.valueAsNumber=${this.value}
|
||||
class="w-[60px] bg-black/40 text-white border border-white/20 text-center rounded text-sm p-1 leading-none font-bold font-inherit mt-1 focus:outline-none focus:border-blue-500"
|
||||
@input=${this.handleNumberInput}
|
||||
@blur=${() => {
|
||||
this.isEditing = false;
|
||||
@@ -141,7 +115,10 @@ export class FluentSlider extends LitElement {
|
||||
@keydown=${this.handleNumberKeyDown}
|
||||
/>`
|
||||
: html`<span
|
||||
class="editable"
|
||||
class="cursor-pointer min-w-[60px] inline-block text-center text-sm font-bold select-none hover:text-white transition-colors mt-1 ${this
|
||||
.value > 0
|
||||
? "text-white"
|
||||
: "text-white/60"}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click=${this.enableEditing}
|
||||
|
||||
@@ -71,10 +71,10 @@ export class LobbyTeamView extends LitElement {
|
||||
(t) => t.players.length === 0 && t.team !== ColoredTeams.Nations,
|
||||
);
|
||||
return html` <div
|
||||
class="flex flex-col md:flex-row gap-3 md:gap-4 items-stretch max-h-[65vh]"
|
||||
class="flex flex-col md:flex-row gap-3 md:gap-4 items-stretch"
|
||||
>
|
||||
<div
|
||||
class="w-full md:w-60 bg-gray-800 p-2 border border-gray-700 rounded-lg max-h-40 md:max-h-[65vh] overflow-auto"
|
||||
class="w-full md:w-60 bg-gray-800 p-2 border border-gray-700 rounded-lg"
|
||||
>
|
||||
<div class="font-bold mb-1.5 text-gray-300 text-sm">
|
||||
${translateText("host_modal.players")}
|
||||
@@ -83,14 +83,14 @@ export class LobbyTeamView extends LitElement {
|
||||
this.clients,
|
||||
(c) => c.clientID ?? c.username,
|
||||
(client) =>
|
||||
html`<div class="px-2 py-1 rounded-sm bg-gray-700/70 mb-1 text-xs">
|
||||
html`<div
|
||||
class="px-2 py-1 rounded-sm bg-gray-700/70 mb-1 text-xs text-white"
|
||||
>
|
||||
${client.username}
|
||||
</div>`,
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
class="flex-1 flex flex-col gap-3 md:gap-4 overflow-auto max-h-[65vh] md:pr-1"
|
||||
>
|
||||
<div class="flex-1 flex flex-col gap-3 md:gap-4 md:pr-1">
|
||||
<div>
|
||||
<div class="font-semibold text-gray-200 mb-1 text-sm">
|
||||
${translateText("host_modal.assigned_teams")}
|
||||
@@ -127,20 +127,22 @@ export class LobbyTeamView extends LitElement {
|
||||
(c) => c.clientID ?? c.username,
|
||||
(client) =>
|
||||
html`<span class="player-tag">
|
||||
${client.username}
|
||||
<span class="text-white">${client.username}</span>
|
||||
${client.clientID === this.lobbyCreatorClientID
|
||||
? html`<span class="host-badge"
|
||||
>(${translateText("host_modal.host_badge")})</span
|
||||
>`
|
||||
: html`<button
|
||||
class="remove-player-btn"
|
||||
@click=${() => this.onKickPlayer?.(client.clientID)}
|
||||
aria-label=${translateText("host_modal.remove_player", {
|
||||
username: client.username,
|
||||
})}
|
||||
>
|
||||
×
|
||||
</button>`}
|
||||
: this.onKickPlayer
|
||||
? html`<button
|
||||
class="remove-player-btn"
|
||||
@click=${() => this.onKickPlayer?.(client.clientID)}
|
||||
aria-label=${translateText("host_modal.remove_player", {
|
||||
username: client.username,
|
||||
})}
|
||||
>
|
||||
×
|
||||
</button>`
|
||||
: html``}
|
||||
</span>`,
|
||||
)} `;
|
||||
}
|
||||
@@ -182,23 +184,25 @@ export class LobbyTeamView extends LitElement {
|
||||
html` <div
|
||||
class="bg-gray-700/70 px-2 py-1 rounded-sm text-xs flex items-center justify-between"
|
||||
>
|
||||
<span class="truncate">${p.username}</span>
|
||||
<span class="truncate text-white">${p.username}</span>
|
||||
${p.clientID === this.lobbyCreatorClientID
|
||||
? html`<span class="ml-2 text-[11px] text-green-300"
|
||||
>(${translateText("host_modal.host_badge")})</span
|
||||
>`
|
||||
: html`<button
|
||||
class="remove-player-btn ml-2"
|
||||
@click=${() => this.onKickPlayer?.(p.clientID)}
|
||||
aria-label=${translateText(
|
||||
"host_modal.remove_player",
|
||||
{
|
||||
username: p.username,
|
||||
},
|
||||
)}
|
||||
>
|
||||
×
|
||||
</button>`}
|
||||
: this.onKickPlayer
|
||||
? html`<button
|
||||
class="remove-player-btn ml-2"
|
||||
@click=${() => this.onKickPlayer?.(p.clientID)}
|
||||
aria-label=${translateText(
|
||||
"host_modal.remove_player",
|
||||
{
|
||||
username: p.username,
|
||||
},
|
||||
)}
|
||||
>
|
||||
×
|
||||
</button>`
|
||||
: html``}
|
||||
</div>`,
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import {
|
||||
Difficulty,
|
||||
GameMapType,
|
||||
hasUnusualThumbnailSize,
|
||||
} from "../../core/game/Game";
|
||||
import { Difficulty, GameMapType } from "../../core/game/Game";
|
||||
import { terrainMapFileLoader } from "../TerrainMapFileLoader";
|
||||
import { translateText } from "../Utils";
|
||||
|
||||
@@ -68,75 +64,9 @@ export class MapDisplay extends LitElement {
|
||||
@state() private mapName: string | null = null;
|
||||
@state() private isLoading = true;
|
||||
|
||||
static styles = css`
|
||||
.option-card {
|
||||
width: 100%;
|
||||
min-width: 100px;
|
||||
max-width: 120px;
|
||||
padding: 6px 6px 10px 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: rgba(30, 30, 30, 0.95);
|
||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease-in-out;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.option-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
background: rgba(40, 40, 40, 0.95);
|
||||
}
|
||||
|
||||
.option-card.selected {
|
||||
border-color: #4a9eff;
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
}
|
||||
|
||||
.option-card-title {
|
||||
font-size: 14px;
|
||||
color: #aaa;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.option-image {
|
||||
width: 100%;
|
||||
aspect-ratio: 4/2;
|
||||
color: #aaa;
|
||||
transition: transform 0.2s ease-in-out;
|
||||
border-radius: 8px;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.medal-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.medal-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
mask: url("/images/MedalIconWhite.svg") no-repeat center / contain;
|
||||
-webkit-mask: url("/images/MedalIconWhite.svg") no-repeat center / contain;
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.medal-icon.earned {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
@@ -159,33 +89,61 @@ export class MapDisplay extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const mapType = GameMapType[this.mapKey as keyof typeof GameMapType];
|
||||
const isUnusualThumbnailSize = mapType
|
||||
? hasUnusualThumbnailSize(mapType)
|
||||
: false;
|
||||
const objectFitStyle = isUnusualThumbnailSize
|
||||
? "object-fit: cover; object-position: center;"
|
||||
: "";
|
||||
private handleKeydown(event: KeyboardEvent) {
|
||||
// Trigger the same activation logic as click when Enter or Space is pressed
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
// Dispatch a click event to maintain compatibility with parent click handlers
|
||||
(event.target as HTMLElement).click();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="option-card ${this.selected ? "selected" : ""}">
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-selected="${this.selected}"
|
||||
aria-label="${this.translation ?? this.mapName ?? this.mapKey}"
|
||||
@keydown="${this.handleKeydown}"
|
||||
class="w-full h-full p-3 flex flex-col items-center justify-between rounded-xl border cursor-pointer transition-all duration-200 gap-3 group ${this
|
||||
.selected
|
||||
? "bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.3)]"
|
||||
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20 hover:-translate-y-1 active:scale-95"}"
|
||||
>
|
||||
${this.isLoading
|
||||
? html`<div class="option-image">
|
||||
? html`<div
|
||||
class="w-full aspect-[2/1] text-white/40 transition-transform duration-200 rounded-lg bg-black/20 text-xs font-bold uppercase tracking-wider flex items-center justify-center animate-pulse"
|
||||
>
|
||||
${translateText("map_component.loading")}
|
||||
</div>`
|
||||
: this.mapWebpPath
|
||||
? html`<img
|
||||
src="${this.mapWebpPath}"
|
||||
alt="${this.mapKey}"
|
||||
class="option-image"
|
||||
style="${objectFitStyle}"
|
||||
/>`
|
||||
: html`<div class="option-image">Error</div>`}
|
||||
? html`<div
|
||||
class="w-full aspect-[2/1] relative overflow-hidden rounded-lg bg-black/20"
|
||||
>
|
||||
<img
|
||||
src="${this.mapWebpPath}"
|
||||
alt="${this.translation || this.mapName}"
|
||||
class="w-full h-full object-cover ${this.selected
|
||||
? "opacity-100"
|
||||
: "opacity-80"} group-hover:opacity-100 transition-opacity duration-200"
|
||||
/>
|
||||
</div>`
|
||||
: html`<div
|
||||
class="w-full aspect-[2/1] text-red-400 transition-transform duration-200 rounded-lg bg-red-500/10 text-xs font-bold uppercase tracking-wider flex items-center justify-center"
|
||||
>
|
||||
${translateText("map_component.error")}
|
||||
</div>`}
|
||||
${this.showMedals
|
||||
? html`<div class="medal-row">${this.renderMedals()}</div>`
|
||||
? html`<div class="flex gap-1 justify-center w-full">
|
||||
${this.renderMedals()}
|
||||
</div>`
|
||||
: null}
|
||||
<div class="option-card-title">${this.translation || this.mapName}</div>
|
||||
<div
|
||||
class="text-xs font-bold text-white uppercase tracking-wider text-center leading-tight break-words hyphens-auto"
|
||||
>
|
||||
${this.translation || this.mapName}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -206,10 +164,14 @@ export class MapDisplay extends LitElement {
|
||||
const wins = this.readWins();
|
||||
return medalOrder.map((medal) => {
|
||||
const earned = wins.has(medal);
|
||||
const mask =
|
||||
"url('/images/MedalIconWhite.svg') no-repeat center / contain";
|
||||
return html`<div
|
||||
class="medal-icon ${earned ? "earned" : ""}"
|
||||
style="background-color:${colors[medal]};"
|
||||
title=${medal}
|
||||
class="w-5 h-5 ${earned ? "opacity-100" : "opacity-25"}"
|
||||
style="background-color:${colors[
|
||||
medal
|
||||
]}; mask: ${mask}; -webkit-mask: ${mask};"
|
||||
title=${translateText(`difficulty.${medal.toLowerCase()}`)}
|
||||
></div>`;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
@customElement("modal-overlay")
|
||||
export class ModalOverlay extends LitElement {
|
||||
@property({ reflect: true }) public visible: boolean = false;
|
||||
|
||||
static styles = css`
|
||||
.overlay {
|
||||
position: absolute;
|
||||
left: 0px;
|
||||
top: 0px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
`;
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
class="overlay ${this.visible ? "" : "hidden"}"
|
||||
class="absolute left-0 top-0 w-full h-full ${this.visible
|
||||
? ""
|
||||
: "hidden"}"
|
||||
@click=${() => (this.visible = false)}
|
||||
></div>
|
||||
`;
|
||||
|
||||
@@ -15,6 +15,8 @@ export const BUTTON_WIDTH = 150;
|
||||
|
||||
@customElement("pattern-button")
|
||||
export class PatternButton extends LitElement {
|
||||
@property({ type: Boolean })
|
||||
selected: boolean = false;
|
||||
@property({ type: Object })
|
||||
pattern: Pattern | null = null;
|
||||
|
||||
@@ -70,35 +72,56 @@ export class PatternButton extends LitElement {
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="flex flex-col items-center gap-1 p-1 bg-white/10 rounded-lg max-w-50"
|
||||
class="flex flex-col items-center justify-between gap-2 p-3 bg-white/5 backdrop-blur-sm border rounded-xl w-48 h-full transition-all duration-200 ${this
|
||||
.selected
|
||||
? "border-green-500 shadow-[0_0_15px_rgba(34,197,94,0.5)]"
|
||||
: "hover:bg-white/10 hover:border-white/20 hover:shadow-xl border-white/10"}"
|
||||
>
|
||||
<button
|
||||
class="bg-white/90 border-2 border-black/10 rounded-lg cursor-pointer transition-all duration-200 w-full
|
||||
hover:bg-white hover:-translate-y-0.5 hover:shadow-lg hover:shadow-black/20
|
||||
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:translate-y-0 disabled:hover:shadow-none"
|
||||
class="group relative flex flex-col items-center w-full gap-2 rounded-lg cursor-pointer transition-all duration-200
|
||||
disabled:cursor-not-allowed flex-1"
|
||||
?disabled=${this.requiresPurchase}
|
||||
@click=${this.handleClick}
|
||||
>
|
||||
<div class="text-sm font-bold text-gray-800 mb-1 text-center">
|
||||
${isDefaultPattern
|
||||
? translateText("territory_patterns.pattern.default")
|
||||
: this.translateCosmetic(
|
||||
"territory_patterns.pattern",
|
||||
this.pattern!.name,
|
||||
)}
|
||||
</div>
|
||||
${this.colorPalette !== null
|
||||
? html`
|
||||
<div class="text-xs font-bold text-gray-800 mb-1 text-center">
|
||||
${this.translateCosmetic(
|
||||
"territory_patterns.color_palette",
|
||||
this.colorPalette!.name,
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<div
|
||||
class="text-xs font-bold text-white uppercase tracking-wider mb-1 text-center truncate w-full ${this
|
||||
.requiresPurchase
|
||||
? "opacity-50"
|
||||
: ""}"
|
||||
title="${isDefaultPattern
|
||||
? translateText("territory_patterns.pattern.default")
|
||||
: this.translateCosmetic(
|
||||
"territory_patterns.pattern",
|
||||
this.pattern!.name,
|
||||
)}"
|
||||
>
|
||||
${isDefaultPattern
|
||||
? translateText("territory_patterns.pattern.default")
|
||||
: this.translateCosmetic(
|
||||
"territory_patterns.pattern",
|
||||
this.pattern!.name,
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: null}
|
||||
</div>
|
||||
${this.colorPalette !== null
|
||||
? html`
|
||||
<div
|
||||
class="text-[10px] font-bold text-white/40 uppercase tracking-widest mb-2 text-center truncate w-full ${this
|
||||
.requiresPurchase
|
||||
? "opacity-50"
|
||||
: ""}"
|
||||
>
|
||||
${this.translateCosmetic(
|
||||
"territory_patterns.color_palette",
|
||||
this.colorPalette!.name,
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: html`<div class="h-[22px] mb-2 w-full"></div>`}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="size-30 flex items-center justify-center bg-white rounded-sm p-1 mx-auto overflow-hidden"
|
||||
class="w-full aspect-square flex items-center justify-center bg-white/5 rounded-lg p-2 border border-white/10 group-hover:border-white/20 transition-colors duration-200 overflow-hidden"
|
||||
>
|
||||
${renderPatternPreview(
|
||||
this.pattern !== null
|
||||
@@ -114,18 +137,22 @@ export class PatternButton extends LitElement {
|
||||
</div>
|
||||
</button>
|
||||
|
||||
${this.requiresPurchase
|
||||
? html`
|
||||
<button
|
||||
class="w-full px-4 py-2 bg-green-500 text-white border-0 rounded-md text-sm font-semibold cursor-pointer transition-colors duration-200
|
||||
hover:bg-green-600"
|
||||
@click=${this.handlePurchase}
|
||||
>
|
||||
${translateText("territory_patterns.purchase")}
|
||||
(${this.pattern!.product!.price})
|
||||
</button>
|
||||
`
|
||||
: null}
|
||||
<div class="w-full mt-2">
|
||||
${this.requiresPurchase && this.pattern?.product
|
||||
? html`
|
||||
<button
|
||||
class="w-full px-4 py-2 bg-green-500/20 text-green-400 border border-green-500/30 rounded-lg text-xs font-bold uppercase tracking-wider cursor-pointer transition-all duration-200
|
||||
hover:bg-green-500/30 hover:shadow-[0_0_15px_rgba(74,222,128,0.2)]"
|
||||
@click=${this.handlePurchase}
|
||||
>
|
||||
${translateText("territory_patterns.purchase")}
|
||||
<span class="ml-1 text-white/60"
|
||||
>(${this.pattern.product.price})</span
|
||||
>
|
||||
</button>
|
||||
`
|
||||
: html`<div class="h-[34px]"></div>`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -142,7 +169,6 @@ export function renderPatternPreview(
|
||||
return html`<img
|
||||
src="${generatePreviewDataUrl(pattern, width, height)}"
|
||||
alt="Pattern preview"
|
||||
<!-- pixelated should also handle crisp-edges -->
|
||||
class="w-full h-full object-contain [image-rendering:pixelated]"
|
||||
/>`;
|
||||
}
|
||||
@@ -150,11 +176,7 @@ export function renderPatternPreview(
|
||||
function renderBlankPreview(width: number, height: number): TemplateResult {
|
||||
return html`
|
||||
<div
|
||||
class="flex items-center justify-center bg-white rounded-sm box-border overflow-hidden relative border border-[#ccc] w-(--width) h-(--height)"
|
||||
style="
|
||||
--height: ${height}px;
|
||||
--width: ${width}px;
|
||||
"
|
||||
class="md:hidden flex items-center justify-center h-full w-full bg-white rounded overflow-hidden relative border border-[#ccc] box-border"
|
||||
>
|
||||
<div
|
||||
class="grid grid-cols-2 grid-rows-2 gap-0 w-[calc(100%-1px)] h-[calc(100%-2px)] box-border"
|
||||
@@ -165,6 +187,15 @@ function renderBlankPreview(width: number, height: number): TemplateResult {
|
||||
<div class="bg-white border border-black/10 box-border"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="hidden md:flex items-center justify-center h-full w-full bg-white/5 rounded overflow-hidden relative border border-white/10 box-border text-center p-1"
|
||||
>
|
||||
<span
|
||||
class="text-[10px] font-black text-white/40 uppercase leading-none break-words w-full"
|
||||
>
|
||||
${translateText("territory_patterns.select_skin")}
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user