mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 06:10:42 +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:
+659
-359
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 26 KiB |
+78
-24
@@ -7,6 +7,7 @@
|
||||
},
|
||||
"common": {
|
||||
"close": "Close",
|
||||
"back": "Back",
|
||||
"available": "Available",
|
||||
"preset_max": "Max",
|
||||
"summary_send": "Send",
|
||||
@@ -17,7 +18,9 @@
|
||||
"cap_tooltip": "Recipient’s remaining capacity",
|
||||
"target_dead": "Target eliminated",
|
||||
"target_dead_note": "You can't send resources to an eliminated player.",
|
||||
"none": "None"
|
||||
"none": "None",
|
||||
"copied": "Copied!",
|
||||
"click_to_copy": "Click to copy"
|
||||
},
|
||||
"main": {
|
||||
"title": "OpenFront (ALPHA)",
|
||||
@@ -26,18 +29,28 @@
|
||||
"checking_login": "Checking login...",
|
||||
"logged_in": "Logged in!",
|
||||
"log_out": "Log out",
|
||||
"create_lobby": "Create Lobby",
|
||||
"join_lobby": "Join Lobby",
|
||||
"single_player": "Single Player",
|
||||
"create": "Create Lobby",
|
||||
"join": "Join Lobby",
|
||||
"solo": "Solo Lobby",
|
||||
"instructions": "Instructions",
|
||||
"game_info": "Game info",
|
||||
"wiki": "Wiki",
|
||||
"privacy_policy": "Privacy Policy",
|
||||
"terms_of_service": "Terms of Service",
|
||||
"reddit": "Reddit"
|
||||
"copyright": "© OpenFront™ and Contributors",
|
||||
"reddit": "Reddit",
|
||||
"play": "Play",
|
||||
"news": "News",
|
||||
"store": "Store",
|
||||
"options": "Options",
|
||||
"keys": "Keys",
|
||||
"stats": "Stats",
|
||||
"account": "Account",
|
||||
"help": "Help",
|
||||
"menu": "Menu",
|
||||
"pick_pattern": "Pick a pattern!"
|
||||
},
|
||||
"news": {
|
||||
"see_all_releases": "See all releases",
|
||||
"github_link": "on GitHub",
|
||||
"title": "Release Notes"
|
||||
},
|
||||
@@ -136,10 +149,11 @@
|
||||
"bomb_direction": "Atom / Hydrogen bomb arc direction"
|
||||
},
|
||||
"single_modal": {
|
||||
"title": "Single Player",
|
||||
"title": "Solo",
|
||||
"random_spawn": "Random spawn",
|
||||
"allow_alliances": "Allow alliances",
|
||||
"toggle_achievements": "Toggle achievements",
|
||||
"sign_in_for_achievements": "Sign in for achievements",
|
||||
"options_title": "Options",
|
||||
"bots": "Bots: ",
|
||||
"bots_disabled": "Disabled",
|
||||
@@ -150,6 +164,8 @@
|
||||
"infinite_troops": "Infinite troops",
|
||||
"compact_map": "Compact Map",
|
||||
"max_timer": "Game length (minutes)",
|
||||
"max_timer_placeholder": "Mins",
|
||||
"max_timer_invalid": "Please enter a valid max timer value (1-120 minutes)",
|
||||
"disable_nukes": "Disable Nukes",
|
||||
"enables_title": "Enable Settings",
|
||||
"start": "Start Game"
|
||||
@@ -161,11 +177,22 @@
|
||||
},
|
||||
"account_modal": {
|
||||
"title": "Account",
|
||||
"connected_as": "Connected as",
|
||||
"stats_overview": "Stats Overview",
|
||||
"save_progress_title": "Save Your Progress",
|
||||
"save_progress_desc": "Link your account to keep your stats, rank, and cosmetics safe.",
|
||||
"link_discord": "Link Discord Account",
|
||||
"link_via_email_placeholder": "Link via Email",
|
||||
"link_button": "Link",
|
||||
"log_out": "Log Out",
|
||||
"welcome_back": "Welcome Back",
|
||||
"sign_in_desc": "Sign in to save your stats and progress",
|
||||
"or": "OR",
|
||||
"email_placeholder": "Enter your email address",
|
||||
"get_magic_link": "Get Magic Link",
|
||||
"linked_account": "Logged in as {account_name}",
|
||||
"fetching_account": "Fetching account information...",
|
||||
"logged_in_with_discord": "Logged in with Discord",
|
||||
"recovery_email_sent": "Recovery email sent to {email}",
|
||||
"player_id": "Player ID: {id}",
|
||||
"not_found": "Not Found",
|
||||
"clear_session": "Clear Session",
|
||||
"failed_to_send_recovery_email": "Failed to send recovery email",
|
||||
@@ -177,6 +204,7 @@
|
||||
"loading": "Loading...",
|
||||
"error": "Error loading clan stats",
|
||||
"no_stats": "No clan stats available",
|
||||
"no_data_yet": "No Data Yet",
|
||||
"clan": "Clan",
|
||||
"games": "Games",
|
||||
"win_score": "Win Score",
|
||||
@@ -184,7 +212,9 @@
|
||||
"loss_score": "Loss Score",
|
||||
"loss_score_tooltip": "Weighted losses based on clan participation and match difficulty",
|
||||
"win_loss_ratio": "Win/Loss",
|
||||
"rank": "Rank"
|
||||
"ratio": "Ratio",
|
||||
"rank": "Rank",
|
||||
"try_again": "Try Again"
|
||||
},
|
||||
"game_info_modal": {
|
||||
"title": "Game info",
|
||||
@@ -263,10 +293,12 @@
|
||||
"continental": "Continental",
|
||||
"regional": "Regional",
|
||||
"fantasy": "Other",
|
||||
"special": "Special",
|
||||
"arcade": "Arcade"
|
||||
},
|
||||
"map_component": {
|
||||
"loading": "Loading..."
|
||||
"loading": "Loading...",
|
||||
"error": "Error"
|
||||
},
|
||||
"private_lobby": {
|
||||
"title": "Join Private Lobby",
|
||||
@@ -277,8 +309,9 @@
|
||||
"checking": "Checking lobby...",
|
||||
"not_found": "Lobby not found. Please check the ID and try again.",
|
||||
"error": "An error occurred. Please try again or contact support.",
|
||||
"joined_waiting": "Joined successfully! Waiting for game to start...",
|
||||
"version_mismatch": "This game was created with a different version. Cannot join."
|
||||
"joined_waiting": "Lobby joined! Waiting for host to start...",
|
||||
"version_mismatch": "This game was created with a different version. Cannot join.",
|
||||
"disabled_units": "Disabled Units"
|
||||
},
|
||||
"public_lobby": {
|
||||
"join": "Join next Game",
|
||||
@@ -291,26 +324,32 @@
|
||||
"teams_hvn": "Humans vs Nations",
|
||||
"teams_hvn_detailed": "{num} Humans vs {num} Nations",
|
||||
"teams": "{num} teams",
|
||||
"players_per_team": "teams of {num}"
|
||||
"players_per_team": "of {num}",
|
||||
"started": "Started"
|
||||
},
|
||||
"matchmaking_modal": {
|
||||
"title": "1v1 Ranked Matchmaking (ALPHA)",
|
||||
"elo": "ELO: {elo}",
|
||||
"connecting": "Connecting to matchmaking server...",
|
||||
"searching": "Searching for game...",
|
||||
"waiting_for_game": "Waiting for game to start..."
|
||||
"waiting_for_game": "Waiting for game to start...",
|
||||
"elo": "Your ELO: {elo}"
|
||||
},
|
||||
"username": {
|
||||
"enter_username": "Enter your username",
|
||||
"not_string": "Username must be a string.",
|
||||
"too_short": "Username must be at least {min} characters long.",
|
||||
"too_long": "Username must not exceed {max} characters.",
|
||||
"invalid_chars": "Username can only contain letters, numbers, spaces, underscores, and [square brackets]."
|
||||
"invalid_chars": "Username can only contain letters, numbers, spaces, and underscores.",
|
||||
"tag": "TAG",
|
||||
"tag_too_short": "Clan tag must be 2-5 alphanumeric characters.",
|
||||
"tag_invalid_chars": "Clan tag can only contain letters and numbers."
|
||||
},
|
||||
"host_modal": {
|
||||
"title": "Private Lobby",
|
||||
"title": "Create Private Lobby",
|
||||
"label": "Private",
|
||||
"mode": "Mode",
|
||||
"team_count": "Number of Teams",
|
||||
"team_type": "Team Type",
|
||||
"options_title": "Options",
|
||||
"bots": "Bots: ",
|
||||
"bots_disabled": "Disabled",
|
||||
@@ -318,6 +357,7 @@
|
||||
"nations": "Nations: ",
|
||||
"disable_nations": "Disable Nations",
|
||||
"max_timer": "Game length (minutes)",
|
||||
"mins_placeholder": "Mins",
|
||||
"instant_build": "Instant build",
|
||||
"infinite_gold": "Infinite gold",
|
||||
"donate_gold": "Donate gold",
|
||||
@@ -339,7 +379,8 @@
|
||||
"remove_player": "Remove {username}",
|
||||
"teams_Duos": "Duos (teams of 2)",
|
||||
"teams_Trios": "Trios (teams of 3)",
|
||||
"teams_Quads": "Quads (teams of 4)"
|
||||
"teams_Quads": "Quads (teams of 4)",
|
||||
"teams_Humans Vs Nations": "Humans vs Nations"
|
||||
},
|
||||
"team_colors": {
|
||||
"red": "Red",
|
||||
@@ -406,6 +447,7 @@
|
||||
"anonymous_names_desc": "Hide real player names with random ones on your screen.",
|
||||
"lobby_id_visibility_label": "Hidden Lobby IDs",
|
||||
"lobby_id_visibility_desc": "Hide Lobby ID in private lobby creation",
|
||||
"toggle_visibility": "Toggle Visibility",
|
||||
"left_click_label": "Left Click to Open Menu",
|
||||
"left_click_desc": "When ON, left-click opens menu and sword button attacks. When OFF, left-click attacks directly.",
|
||||
"left_click_menu": "Left Click Menu",
|
||||
@@ -419,6 +461,7 @@
|
||||
"easter_writing_speed_desc": "Adjust how fast you pretend to code (x1–x100)",
|
||||
"easter_bug_count_label": "Bug Count",
|
||||
"easter_bug_count_desc": "How many bugs you're okay with (0–1000, emotionally)",
|
||||
"press_a_key": "Press a key",
|
||||
"view_options": "View Options",
|
||||
"toggle_view": "Toggle View",
|
||||
"toggle_view_desc": "Alternate view (terrain/countries)",
|
||||
@@ -477,7 +520,8 @@
|
||||
"exit_game_label": "Exit Game",
|
||||
"exit_game_info": "Return to main menu",
|
||||
"background_music_volume": "Background Music Volume",
|
||||
"sound_effects_volume": "Sound Effects Volume"
|
||||
"sound_effects_volume": "Sound Effects Volume",
|
||||
"keybind_conflict_error": "The key {key} is already bound to another action."
|
||||
},
|
||||
"chat": {
|
||||
"title": "Quick Chat",
|
||||
@@ -779,6 +823,7 @@
|
||||
"colors": "Colors",
|
||||
"purchase": "Purchase",
|
||||
"show_only_owned": "My Skins",
|
||||
"all_owned": "All skins owned! Check back later for new items.",
|
||||
"not_logged_in": "Not logged in",
|
||||
"blocked": {
|
||||
"login": "You must be logged in to access this skin.",
|
||||
@@ -786,7 +831,9 @@
|
||||
},
|
||||
"pattern": {
|
||||
"default": "Default"
|
||||
}
|
||||
},
|
||||
"select_skin": "Select Skin",
|
||||
"selected": "selected"
|
||||
},
|
||||
"flag_input": {
|
||||
"title": "Select Flag",
|
||||
@@ -857,7 +904,7 @@
|
||||
"mode": "Mode",
|
||||
"mode_ffa": "Free-for-All",
|
||||
"mode_team": "Team",
|
||||
"view": "View",
|
||||
"replay": "Replay",
|
||||
"details": "Details",
|
||||
"ranking": "Ranking",
|
||||
"started": "Started",
|
||||
@@ -868,13 +915,20 @@
|
||||
"player_stats_tree": {
|
||||
"public": "Public",
|
||||
"private": "Private",
|
||||
"singleplayer": "Single Player",
|
||||
"singleplayer": "Solo",
|
||||
"mode": "Mode",
|
||||
"stats_wins": "Wins",
|
||||
"stats_losses": "Losses",
|
||||
"stats_wlr": "Win:Loss Ratio",
|
||||
"stats_games_played": "Games Played",
|
||||
"mode_ffa": "Free-for-All",
|
||||
"mode_team": "Team"
|
||||
"mode_team": "Team",
|
||||
"no_stats": "No stats recorded for this selection."
|
||||
},
|
||||
"matchmaking_button": {
|
||||
"play_ranked": "1v1 Ranked Matchmaking",
|
||||
"description": "(ALPHA)",
|
||||
"login_required": "Login to play ranked!",
|
||||
"must_login": "You must be logged in to play ranked matchmaking."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
EXPERIMENTAL BUILD
|
||||
FOR INTERNAL USE ONLY
|
||||
x.xx.xx
|
||||
|
||||
+384
-228
@@ -1,5 +1,5 @@
|
||||
import { html, LitElement, TemplateResult } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import { html, TemplateResult } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import {
|
||||
PlayerGame,
|
||||
PlayerStatsTree,
|
||||
@@ -11,19 +11,16 @@ import "./components/baseComponents/stats/DiscordUserHeader";
|
||||
import "./components/baseComponents/stats/GameList";
|
||||
import "./components/baseComponents/stats/PlayerStatsTable";
|
||||
import "./components/baseComponents/stats/PlayerStatsTree";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import "./components/Difficulties";
|
||||
import "./components/PatternButton";
|
||||
import { isInIframe, translateText } from "./Utils";
|
||||
import { copyToClipboard, translateText } from "./Utils";
|
||||
|
||||
@customElement("account-modal")
|
||||
export class AccountModal extends LitElement {
|
||||
@query("o-modal") private modalEl!: HTMLElement & {
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
export class AccountModal extends BaseModal {
|
||||
@state() private email: string = "";
|
||||
@state() private isLoadingUser: boolean = false;
|
||||
@state() private showCopied: boolean = false;
|
||||
|
||||
private userMeResponse: UserMeResponse | null = null;
|
||||
private statsTree: PlayerStatsTree | null = null;
|
||||
@@ -48,63 +45,246 @@ export class AccountModal extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
private async copyIdToClipboard() {
|
||||
const id = this.userMeResponse?.player?.publicId;
|
||||
if (!id) return;
|
||||
|
||||
await copyToClipboard(
|
||||
id,
|
||||
() => (this.showCopied = true),
|
||||
() => (this.showCopied = false),
|
||||
);
|
||||
}
|
||||
|
||||
private hasAnyStats(): boolean {
|
||||
if (!this.statsTree) return false;
|
||||
// Check if statsTree has any data
|
||||
return (
|
||||
Object.keys(this.statsTree).length > 0 &&
|
||||
Object.values(this.statsTree).some(
|
||||
(gameTypeStats) =>
|
||||
gameTypeStats && Object.keys(gameTypeStats).length > 0,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const content = this.isLoadingUser
|
||||
? html`
|
||||
<div
|
||||
class="flex flex-col items-center justify-center p-12 text-white bg-black/40 backdrop-blur-md rounded-2xl border border-white/10 h-full min-h-[400px]"
|
||||
>
|
||||
<div
|
||||
class="w-12 h-12 border-4 border-blue-500/30 border-t-blue-500 rounded-full animate-spin mb-4"
|
||||
></div>
|
||||
<p class="text-white/60 font-medium tracking-wide animate-pulse">
|
||||
${translateText("account_modal.fetching_account")}
|
||||
</p>
|
||||
</div>
|
||||
`
|
||||
: this.renderInner();
|
||||
|
||||
if (this.inline) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return html`
|
||||
<o-modal
|
||||
id="account-modal"
|
||||
title="${translateText("account_modal.title") || "Account"}"
|
||||
title=""
|
||||
?hideCloseButton=${true}
|
||||
?inline=${this.inline}
|
||||
hideHeader
|
||||
>
|
||||
${this.isLoadingUser
|
||||
? html`
|
||||
<div
|
||||
class="flex flex-col items-center justify-center p-6 text-white"
|
||||
>
|
||||
<p class="mb-2">
|
||||
${translateText("account_modal.fetching_account")}
|
||||
</p>
|
||||
<div
|
||||
class="w-6 h-6 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
</div>
|
||||
`
|
||||
: this.renderInner()}
|
||||
${content}
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderInner() {
|
||||
if (this.userMeResponse?.user) {
|
||||
return this.renderAccountInfo();
|
||||
} else {
|
||||
return this.renderLoginOptions();
|
||||
}
|
||||
const isLoggedIn = !!this.userMeResponse?.user;
|
||||
const title = translateText("account_modal.title");
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="h-full flex flex-col bg-black/40 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="flex items-center mb-6 pb-2 border-b border-white/10 gap-2 shrink-0 p-6"
|
||||
>
|
||||
<div class="flex items-center gap-4 flex-1">
|
||||
<button
|
||||
@click=${() => this.close()}
|
||||
class="group flex items-center justify-center w-10 h-10 rounded-full bg-white/5 hover:bg-white/10 transition-all border border-white/10"
|
||||
aria-label="${translateText("common.back")}"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-5 h-5 text-gray-400 group-hover:text-white transition-colors"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<span
|
||||
class="text-white text-xl sm:text-2xl md:text-3xl font-bold uppercase tracking-widest break-words hyphens-auto"
|
||||
>
|
||||
${title}
|
||||
</span>
|
||||
</div>
|
||||
${isLoggedIn
|
||||
? html`
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-xs text-blue-400 font-bold uppercase tracking-wider"
|
||||
>ID:</span
|
||||
>
|
||||
<button
|
||||
@click=${this.copyIdToClipboard}
|
||||
class="text-xs text-white/60 font-mono bg-white/5 px-2 py-0.5 rounded border border-white/5 hover:bg-white/10 hover:text-white transition-colors cursor-pointer"
|
||||
title="${translateText("common.click_to_copy")}"
|
||||
>
|
||||
${this.showCopied
|
||||
? translateText("common.copied")
|
||||
: (this.userMeResponse?.player?.publicId ??
|
||||
translateText("account_modal.not_found"))}
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar mr-1">
|
||||
${isLoggedIn ? this.renderAccountInfo() : this.renderLoginOptions()}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderAccountInfo() {
|
||||
const me = this.userMeResponse?.user;
|
||||
const isLinked = me?.discord ?? me?.email;
|
||||
|
||||
if (!isLinked) {
|
||||
return html`
|
||||
<div class="p-6 flex justify-center items-start min-h-full">
|
||||
<div class="w-full max-w-2xl">${this.renderLinkAccountSection()}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="p-6">
|
||||
<div class="mb-4">
|
||||
<p class="text-white mb-4 text-center">
|
||||
${translateText("account_modal.player_id", {
|
||||
id:
|
||||
this.userMeResponse?.player?.publicId ??
|
||||
translateText("account_modal.not_found"),
|
||||
})}
|
||||
</p>
|
||||
<div class="flex flex-col gap-6">
|
||||
<!-- Top Row: Connected As -->
|
||||
<div class="bg-white/5 rounded-xl border border-white/10 p-6">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div
|
||||
class="text-xs text-white/40 uppercase tracking-widest font-bold border-b border-white/5 pb-2 px-8"
|
||||
>
|
||||
${translateText("account_modal.connected_as")}
|
||||
</div>
|
||||
<div class="flex items-center gap-8 justify-center flex-wrap">
|
||||
<discord-user-header
|
||||
.data=${this.userMeResponse?.user?.discord ?? null}
|
||||
></discord-user-header>
|
||||
${this.renderLoggedInAs()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Middle Row: Stats Section -->
|
||||
${this.hasAnyStats()
|
||||
? html`<div
|
||||
class="bg-white/5 rounded-xl border border-white/10 p-6"
|
||||
>
|
||||
<h3
|
||||
class="text-lg font-bold text-white mb-4 flex items-center gap-2"
|
||||
>
|
||||
<span class="text-blue-400">📊</span>
|
||||
${translateText("account_modal.stats_overview")}
|
||||
</h3>
|
||||
<player-stats-tree-view
|
||||
.statsTree=${this.statsTree}
|
||||
></player-stats-tree-view>
|
||||
</div>`
|
||||
: ""}
|
||||
|
||||
<!-- Bottom Row: Recent Games Section -->
|
||||
<div class="bg-white/5 rounded-xl border border-white/10 p-6">
|
||||
<h3
|
||||
class="text-lg font-bold text-white mb-4 flex items-center gap-2"
|
||||
>
|
||||
<span class="text-blue-400">🎮</span>
|
||||
${translateText("game_list.recent_games")}
|
||||
</h3>
|
||||
<game-list
|
||||
.games=${this.recentGames}
|
||||
.onViewGame=${(id: string) => this.viewGame(id)}
|
||||
></game-list>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4 text-center">
|
||||
<p class="text-white mb-4">${this.renderLoggedInAs()}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderLinkAccountSection(): TemplateResult {
|
||||
return html`
|
||||
<div class="bg-blue-500/10 rounded-xl border border-blue-500/20 p-6">
|
||||
<div class="flex items-start gap-4 mb-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-white uppercase tracking-wider">
|
||||
${translateText("account_modal.save_progress_title")}
|
||||
</h3>
|
||||
<p class="text-sm text-white/60 mt-1">
|
||||
${translateText("account_modal.save_progress_desc")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-center mt-2 mb-4">
|
||||
<discord-user-header
|
||||
.data=${this.userMeResponse?.user?.discord ?? null}
|
||||
></discord-user-header>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<button
|
||||
@click="${this.handleDiscordLogin}"
|
||||
class="w-full px-4 py-3 text-white bg-[#5865F2] hover:bg-[#4752C4] border border-transparent rounded-xl focus:outline-none transition-all duration-200 flex items-center justify-center gap-2 group relative overflow-hidden shadow-lg hover:shadow-xl"
|
||||
>
|
||||
<img
|
||||
src="/images/DiscordLogo.svg"
|
||||
alt="Discord"
|
||||
class="w-5 h-5 relative z-10"
|
||||
/>
|
||||
<span class="font-bold text-sm relative z-10 tracking-wide"
|
||||
>${translateText("main.login_discord") ||
|
||||
translateText("account_modal.link_discord")}</span
|
||||
>
|
||||
</button>
|
||||
|
||||
<div class="relative group w-full">
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="email"
|
||||
.value="${this.email}"
|
||||
@input="${this.handleEmailInput}"
|
||||
class="flex-1 min-w-0 px-4 py-2 bg-black/40 border border-white/10 rounded-lg text-white text-sm placeholder-white/30 focus:outline-none focus:border-blue-500 transition-all font-medium"
|
||||
placeholder="${translateText(
|
||||
"account_modal.link_via_email_placeholder",
|
||||
)}"
|
||||
/>
|
||||
<button
|
||||
@click="${this.handleSubmit}"
|
||||
class="px-5 py-2 text-sm font-bold text-white uppercase bg-blue-600 hover:bg-blue-500 rounded-lg transition-all border border-blue-500/50 hover:shadow-[0_0_15px_rgba(37,99,235,0.3)] shrink-0"
|
||||
>
|
||||
${translateText("account_modal.link_button")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${this.renderPlayerStats()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -112,33 +292,62 @@ export class AccountModal extends LitElement {
|
||||
private renderLoggedInAs(): TemplateResult {
|
||||
const me = this.userMeResponse?.user;
|
||||
if (me?.discord) {
|
||||
return html`<p>
|
||||
${translateText("account_modal.linked_account", {
|
||||
account_name: me.discord.global_name ?? "",
|
||||
})}
|
||||
</p>
|
||||
${this.renderLogoutButton()}`;
|
||||
return html`
|
||||
<div class="flex flex-col items-center gap-3 w-full">
|
||||
${this.renderLogoutButton()}
|
||||
</div>
|
||||
`;
|
||||
} else if (me?.email) {
|
||||
return html`<p>
|
||||
${translateText("account_modal.linked_account", {
|
||||
account_name: me.email,
|
||||
})}
|
||||
</p>
|
||||
${this.renderLogoutButton()}`;
|
||||
return html`
|
||||
<div class="flex flex-col items-center gap-3 w-full">
|
||||
<div class="text-white text-lg font-medium">
|
||||
${translateText("account_modal.linked_account", {
|
||||
account_name: me.email,
|
||||
})}
|
||||
</div>
|
||||
${this.renderLogoutButton()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return this.renderLoginOptions();
|
||||
}
|
||||
|
||||
private renderPlayerStats(): TemplateResult {
|
||||
// "Mini" Login Options for linking account
|
||||
return html`
|
||||
<player-stats-tree-view
|
||||
.statsTree=${this.statsTree}
|
||||
></player-stats-tree-view>
|
||||
<hr class="w-2/3 border-gray-600 my-2" />
|
||||
<game-list
|
||||
.games=${this.recentGames}
|
||||
.onViewGame=${(id: string) => this.viewGame(id)}
|
||||
></game-list>
|
||||
<div class="w-full space-y-3">
|
||||
<button
|
||||
@click="${this.handleDiscordLogin}"
|
||||
class="w-full px-4 py-3 text-white bg-[#5865F2] hover:bg-[#4752C4] border border-transparent rounded-xl focus:outline-none transition-colors duration-200 flex items-center justify-center gap-2 group relative overflow-hidden"
|
||||
>
|
||||
<img
|
||||
src="/images/DiscordLogo.svg"
|
||||
alt="Discord"
|
||||
class="w-5 h-5 relative z-10"
|
||||
/>
|
||||
<span class="font-bold text-sm relative z-10 tracking-wide"
|
||||
>${translateText("main.login_discord") ||
|
||||
translateText("account_modal.link_discord")}</span
|
||||
>
|
||||
</button>
|
||||
|
||||
<div class="relative group">
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="email"
|
||||
.value="${this.email}"
|
||||
@input="${this.handleEmailInput}"
|
||||
class="w-full px-4 py-2 bg-black/40 border border-white/10 rounded-lg text-white text-sm placeholder-white/30 focus:outline-none focus:border-blue-500 transition-all font-medium"
|
||||
placeholder="${translateText(
|
||||
"account_modal.link_via_email_placeholder",
|
||||
)}"
|
||||
/>
|
||||
<button
|
||||
@click="${this.handleSubmit}"
|
||||
class="px-4 py-2 text-sm font-bold text-white uppercase bg-blue-600 hover:bg-blue-500 rounded-lg transition-all"
|
||||
>
|
||||
${translateText("account_modal.link_button")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -157,86 +366,133 @@ export class AccountModal extends LitElement {
|
||||
return html`
|
||||
<button
|
||||
@click="${this.handleLogout}"
|
||||
class="px-6 py-3 text-sm font-medium text-white bg-red-600 border border-transparent rounded-md hover:bg-red-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-colors duration-200"
|
||||
class="px-6 py-2 text-sm font-bold text-white uppercase tracking-wider bg-red-600/80 hover:bg-red-600 border border-red-500/50 rounded-lg transition-all shadow-lg hover:shadow-red-900/40"
|
||||
>
|
||||
Log Out
|
||||
${translateText("account_modal.log_out")}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderLoginOptions() {
|
||||
return html`
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<!-- Discord Login Button -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center justify-center p-6 min-h-full">
|
||||
<div
|
||||
class="w-full max-w-md bg-white/5 rounded-2xl border border-white/10 p-8"
|
||||
>
|
||||
<div class="text-center mb-8">
|
||||
<div
|
||||
class="w-16 h-16 bg-gradient-to-br from-blue-500/20 to-purple-500/20 rounded-2xl flex items-center justify-center mx-auto mb-6 border border-white/10 shadow-inner"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-8 h-8 text-blue-400"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"></path>
|
||||
<polyline points="10 17 15 12 10 7"></polyline>
|
||||
<line x1="15" y1="12" x2="3" y2="12"></line>
|
||||
</svg>
|
||||
</div>
|
||||
<h3
|
||||
class="text-xl font-bold text-white uppercase tracking-widest mb-2"
|
||||
>
|
||||
${translateText("account_modal.welcome_back")}
|
||||
</h3>
|
||||
<p class="text-white/50 text-sm font-medium">
|
||||
${translateText("account_modal.sign_in_desc")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Discord Login Button -->
|
||||
<button
|
||||
@click="${this.handleDiscordLogin}"
|
||||
class="w-full px-6 py-3 text-sm font-medium text-white bg-[#5865F2] border border-transparent rounded-md hover:bg-[#4752C4] focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-[#5865F2] transition-colors duration-200 flex items-center justify-center gap-2"
|
||||
class="w-full px-6 py-4 text-white bg-[#5865F2] hover:bg-[#4752C4] border border-transparent rounded-xl focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#5865F2] transition-colors duration-200 flex items-center justify-center gap-3 group relative overflow-hidden shadow-lg hover:shadow-[#5865F2]/20"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-white/10 translate-y-full group-hover:translate-y-0 transition-transform duration-300"
|
||||
></div>
|
||||
<img
|
||||
src="/images/DiscordLogo.svg"
|
||||
alt="Discord"
|
||||
class="w-5 h-5"
|
||||
class="w-6 h-6 relative z-10"
|
||||
/>
|
||||
<span
|
||||
<span class="font-bold relative z-10 tracking-wide"
|
||||
>${translateText("main.login_discord") ||
|
||||
"Login with Discord"}</span
|
||||
translateText("account_modal.link_discord")}</span
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="relative mb-6">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div class="w-full border-t border-gray-300"></div>
|
||||
<!-- Divider -->
|
||||
<div class="flex items-center gap-4 py-2">
|
||||
<div class="h-px bg-white/10 flex-1"></div>
|
||||
<span
|
||||
class="text-[10px] uppercase tracking-widest text-white/30 font-bold"
|
||||
>
|
||||
${translateText("account_modal.or")}
|
||||
</span>
|
||||
<div class="h-px bg-white/10 flex-1"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-sm">
|
||||
<span class="px-2 bg-gray-800 text-gray-300">or</span>
|
||||
|
||||
<!-- Email Recovery -->
|
||||
<div class="space-y-3">
|
||||
<div class="relative group">
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
.value="${this.email}"
|
||||
@input="${this.handleEmailInput}"
|
||||
class="w-full pl-4 pr-12 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/20 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500/50 transition-all font-medium hover:bg-white/10"
|
||||
placeholder="${translateText(
|
||||
"account_modal.email_placeholder",
|
||||
)}"
|
||||
required
|
||||
/>
|
||||
<div
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 text-white/20"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<path
|
||||
d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"
|
||||
></path>
|
||||
<polyline points="22,6 12,13 2,6"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="${this.handleSubmit}"
|
||||
class="w-full px-6 py-3 text-sm font-bold text-white uppercase tracking-wider bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-500 hover:to-blue-600 rounded-xl transition-all shadow-lg hover:shadow-blue-900/40 border border-white/5"
|
||||
>
|
||||
${translateText("account_modal.get_magic_link")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email Recovery -->
|
||||
<div class="mb-4">
|
||||
<label
|
||||
for="email"
|
||||
class="block text-sm font-medium text-white mb-2"
|
||||
<div class="mt-8 text-center border-t border-white/10 pt-6">
|
||||
<button
|
||||
@click="${this.handleLogout}"
|
||||
class="text-[10px] font-bold text-white/20 hover:text-red-400 transition-colors uppercase tracking-widest pb-0.5"
|
||||
>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
.value="${this.email}"
|
||||
@input="${this.handleEmailInput}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-xs focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-black"
|
||||
placeholder="Enter your email address"
|
||||
required
|
||||
/>
|
||||
${translateText("account_modal.clear_session")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
@click="${this.close}"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="${this.handleSubmit}"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="${this.handleLogout}"
|
||||
class="px-3 py-1 text-xs font-medium text-white bg-red-600 border border-transparent rounded-md hover:bg-red-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-colors duration-200"
|
||||
>
|
||||
${translateText("account_modal.clear_session")}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -267,8 +523,7 @@ export class AccountModal extends LitElement {
|
||||
discordLogin();
|
||||
}
|
||||
|
||||
public open() {
|
||||
this.modalEl?.open();
|
||||
protected onOpen(): void {
|
||||
this.isLoadingUser = true;
|
||||
|
||||
void getUserMe()
|
||||
@@ -290,8 +545,10 @@ export class AccountModal extends LitElement {
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.modalEl?.close();
|
||||
protected onClose(): void {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("close", { bubbles: true, composed: true }),
|
||||
);
|
||||
}
|
||||
|
||||
private async handleLogout() {
|
||||
@@ -319,104 +576,3 @@ export class AccountModal extends LitElement {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("account-button")
|
||||
export class AccountButton extends LitElement {
|
||||
@state() private loggedInEmail: string | null = null;
|
||||
@state() private loggedInDiscord: string | null = null;
|
||||
|
||||
private isVisible = true;
|
||||
|
||||
@query("account-modal") private recoveryModal: AccountModal;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
document.addEventListener("userMeResponse", (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
|
||||
if (customEvent.detail) {
|
||||
const userMeResponse = customEvent.detail as UserMeResponse;
|
||||
if (userMeResponse.user.email) {
|
||||
this.loggedInEmail = userMeResponse.user.email;
|
||||
this.requestUpdate();
|
||||
} else if (userMeResponse.user.discord) {
|
||||
this.loggedInDiscord = userMeResponse.user.discord.id;
|
||||
this.requestUpdate();
|
||||
}
|
||||
} else {
|
||||
// Clear the logged in states when user logs out
|
||||
this.loggedInEmail = null;
|
||||
this.loggedInDiscord = null;
|
||||
this.requestUpdate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (isInIframe()) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
if (!this.isVisible) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
let buttonTitle = "";
|
||||
if (this.loggedInEmail) {
|
||||
buttonTitle = translateText("account_modal.linked_account", {
|
||||
account_name: this.loggedInEmail,
|
||||
});
|
||||
} else if (this.loggedInDiscord) {
|
||||
buttonTitle = translateText("account_modal.linked_account");
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="fixed top-4 right-4 z-9998">
|
||||
<button
|
||||
@click="${this.open}"
|
||||
class="w-12 h-12 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-2xl hover:shadow-2xl transition-all duration-200 flex items-center justify-center text-xl focus:outline-hidden focus:ring-4 focus:ring-blue-500 focus:ring-offset-4"
|
||||
title="${buttonTitle}"
|
||||
>
|
||||
${this.renderIcon()}
|
||||
</button>
|
||||
</div>
|
||||
<account-modal></account-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderIcon() {
|
||||
if (this.loggedInDiscord) {
|
||||
return html`<img
|
||||
src="/images/DiscordLogo.svg"
|
||||
alt="Discord"
|
||||
class="w-6 h-6"
|
||||
/>`;
|
||||
} else if (this.loggedInEmail) {
|
||||
return html`<img
|
||||
src="/images/EmailIcon.svg"
|
||||
alt="Email"
|
||||
class="w-6 h-6"
|
||||
/>`;
|
||||
}
|
||||
return html`<img
|
||||
src="/images/LoggedOutIcon.svg"
|
||||
alt="Logged Out"
|
||||
class="w-6 h-6"
|
||||
/>`;
|
||||
}
|
||||
|
||||
private open() {
|
||||
this.recoveryModal?.open();
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.isVisible = false;
|
||||
this.recoveryModal?.close();
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +121,26 @@ export function joinLobby(
|
||||
userSettings,
|
||||
terrainLoad,
|
||||
terrainMapFileLoader,
|
||||
).then((r) => r.start());
|
||||
)
|
||||
.then((r) => r.start())
|
||||
.catch((e) => {
|
||||
console.error("error creating client game", e);
|
||||
const startingModal = document.querySelector(
|
||||
"game-starting-modal",
|
||||
) as HTMLElement;
|
||||
if (startingModal) {
|
||||
startingModal.classList.add("hidden");
|
||||
}
|
||||
showErrorModal(
|
||||
e.message,
|
||||
e.stack,
|
||||
lobbyConfig.gameID,
|
||||
lobbyConfig.clientID,
|
||||
true,
|
||||
false,
|
||||
"error_modal.connection_error",
|
||||
);
|
||||
});
|
||||
}
|
||||
if (message.type === "error") {
|
||||
if (message.error === "full-lobby") {
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { UserSettings } from "../core/game/UserSettings";
|
||||
|
||||
@customElement("dark-mode-button")
|
||||
export class DarkModeButton extends LitElement {
|
||||
private userSettings: UserSettings = new UserSettings();
|
||||
@state() private darkMode: boolean = this.userSettings.darkMode();
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("dark-mode-changed", this.handleDarkModeChanged);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("dark-mode-changed", this.handleDarkModeChanged);
|
||||
}
|
||||
|
||||
private handleDarkModeChanged = (e: Event) => {
|
||||
const event = e as CustomEvent<{ darkMode: boolean }>;
|
||||
this.darkMode = event.detail.darkMode;
|
||||
};
|
||||
|
||||
toggleDarkMode() {
|
||||
this.userSettings.toggleDarkMode();
|
||||
this.darkMode = this.userSettings.darkMode();
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<button
|
||||
title="Toggle Dark Mode"
|
||||
class="absolute top-0 left-0 md:top-2.5 md:left-2.5 border-none bg-none cursor-pointer text-2xl"
|
||||
@click=${() => this.toggleDarkMode()}
|
||||
>
|
||||
${this.darkMode ? "☀️" : "🌙"}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
+14
-28
@@ -10,17 +10,7 @@ const flagKey: string = "flag";
|
||||
export class FlagInput extends LitElement {
|
||||
@state() public flag: string = "";
|
||||
|
||||
static styles = css`
|
||||
@media (max-width: 768px) {
|
||||
.flag-modal {
|
||||
width: 80vw;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
width: calc(100% / 3 - 15px);
|
||||
}
|
||||
}
|
||||
`;
|
||||
static styles = css``;
|
||||
|
||||
public getCurrentFlag(): string {
|
||||
return this.flag;
|
||||
@@ -70,20 +60,18 @@ export class FlagInput extends LitElement {
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="flex relative">
|
||||
<button
|
||||
id="flag-input_"
|
||||
class="w-full border rounded-lg flex cursor-pointer border-black/30
|
||||
dark:border-gray-300/60 bg-white/70 dark:bg-[rgba(55,65,81,0.7)]
|
||||
justify-center aspect-square"
|
||||
title=${translateText("flag_input.button_title")}
|
||||
>
|
||||
<span
|
||||
id="flag-preview"
|
||||
class="block w-full aspect-3/2 bg-[#333] overflow-hidden rounded-md"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
id="flag-input_"
|
||||
class="flag-btn m-0 border-0 bg-transparent hover:bg-white/10 w-full h-full flex cursor-pointer justify-center items-center focus:outline-none focus:ring-0 transition-colors duration-200"
|
||||
style="padding: 0 !important;"
|
||||
title=${translateText("flag_input.button_title")}
|
||||
>
|
||||
<span
|
||||
id="flag-preview"
|
||||
class="w-full h-full overflow-hidden"
|
||||
style="display:block;"
|
||||
></span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -100,9 +88,7 @@ export class FlagInput extends LitElement {
|
||||
} else {
|
||||
const img = document.createElement("img");
|
||||
img.src = this.flag ? `/flags/${this.flag}.svg` : `/flags/xx.svg`;
|
||||
img.style.width = "100%";
|
||||
img.style.height = "100%";
|
||||
img.style.objectFit = "contain";
|
||||
img.className = "w-full h-full object-cover drop-shadow";
|
||||
img.onerror = () => {
|
||||
if (!img.src.endsWith("/flags/xx.svg")) {
|
||||
img.src = "/flags/xx.svg";
|
||||
|
||||
+106
-69
@@ -1,31 +1,62 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { html } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import Countries from "resources/countries.json" with { type: "json" };
|
||||
import { translateText } from "./Utils";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
|
||||
@customElement("flag-input-modal")
|
||||
export class FlagInputModal extends LitElement {
|
||||
@query("o-modal") private modalEl!: HTMLElement & {
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
};
|
||||
export class FlagInputModal extends BaseModal {
|
||||
@query("#flag-input-modal") private modalRef!: HTMLElement;
|
||||
|
||||
@state() private search = "";
|
||||
@state() private isModalOpen = false;
|
||||
public returnTo = "";
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
updated(changedProperties: Map<string | number | symbol, unknown>) {
|
||||
super.updated(changedProperties);
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<o-modal alwaysMaximized title=${translateText("flag_input.title")}>
|
||||
<div class="flex justify-center w-full p-4">
|
||||
const content = html`
|
||||
<div
|
||||
class="h-full flex flex-col bg-black/40 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="flex items-center mb-4 pb-2 border-b border-white/10 gap-2 shrink-0 p-6"
|
||||
>
|
||||
<div class="flex items-center gap-4 flex-1">
|
||||
<button
|
||||
@click=${() => this.close()}
|
||||
class="group flex items-center justify-center w-10 h-10 rounded-full bg-white/5 hover:bg-white/10 transition-all border border-white/10"
|
||||
aria-label="${translateText("common.back")}"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-5 h-5 text-gray-400 group-hover:text-white transition-colors"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<span
|
||||
class="text-white text-xl sm:text-2xl md:text-3xl font-bold uppercase tracking-widest break-words hyphens-auto"
|
||||
>
|
||||
${translateText("flag_input.title")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center w-full px-6 pb-4 shrink-0">
|
||||
<input
|
||||
class="h-8 border-none border border-gray-300
|
||||
rounded-xl shadow-xs text-2xl text-center focus:outline-hidden
|
||||
focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-black
|
||||
dark:border-gray-300/60 dark:bg-gray-700 dark:text-white"
|
||||
class="h-12 w-full max-w-md border border-white/10 bg-black/40
|
||||
rounded-xl shadow-inner text-xl text-center focus:outline-none
|
||||
focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 text-white placeholder-white/30 transition-all"
|
||||
type="text"
|
||||
placeholder=${translateText("flag_input.search_flag")}
|
||||
@change=${this.handleSearch}
|
||||
@@ -34,41 +65,59 @@ export class FlagInputModal extends LitElement {
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-wrap justify-evenly gap-4 overflow-y-auto overflow-x-hidden h-[90%]"
|
||||
class="flex-1 overflow-y-auto px-6 pb-6 scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent mr-1"
|
||||
>
|
||||
${this.isModalOpen
|
||||
? Countries.filter(
|
||||
(country) =>
|
||||
!country.restricted && this.includedInSearch(country),
|
||||
).map(
|
||||
(country) => html`
|
||||
<button
|
||||
@click=${() => {
|
||||
this.setFlag(country.code);
|
||||
this.close();
|
||||
<div class="flex flex-wrap justify-center gap-4 min-h-min">
|
||||
${Countries.filter(
|
||||
(country) =>
|
||||
!country.restricted && this.includedInSearch(country),
|
||||
).map(
|
||||
(country) => html`
|
||||
<button
|
||||
@click=${() => {
|
||||
this.setFlag(country.code);
|
||||
this.close();
|
||||
}}
|
||||
class="group relative flex flex-col items-center gap-2 p-3 rounded-xl border border-white/5 bg-white/5 hover:bg-white/10 hover:border-white/20 transition-all cursor-pointer
|
||||
w-[100px] sm:w-[120px]"
|
||||
>
|
||||
<img
|
||||
class="w-full h-auto rounded shadow-sm group-hover:scale-105 transition-transform duration-200"
|
||||
src="/flags/${country.code}.svg"
|
||||
loading="lazy"
|
||||
@error=${(e: Event) => {
|
||||
const img = e.currentTarget as HTMLImageElement;
|
||||
const fallback = "/flags/xx.svg";
|
||||
if (img.src && !img.src.endsWith(fallback)) {
|
||||
img.src = fallback;
|
||||
}
|
||||
}}
|
||||
class="text-center cursor-pointer border-none bg-none opacity-70
|
||||
w-[calc(100%/2-15px)] sm:w-[calc(100%/4-15px)]
|
||||
md:w-[calc(100%/6-15px)] lg:w-[calc(100%/8-15px)]
|
||||
xl:w-[calc(100%/10-15px)] min-w-20"
|
||||
/>
|
||||
<span
|
||||
class="text-xs font-bold text-gray-300 group-hover:text-white text-center leading-tight w-full truncate"
|
||||
>${country.name}</span
|
||||
>
|
||||
<img
|
||||
class="country-flag w-full h-auto"
|
||||
src="/flags/${country.code}.svg"
|
||||
@error=${(e: Event) => {
|
||||
const img = e.currentTarget as HTMLImageElement;
|
||||
const fallback = "/flags/xx.svg";
|
||||
if (img.src && !img.src.endsWith(fallback)) {
|
||||
img.src = fallback;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span class="country-name">${country.name}</span>
|
||||
</button>
|
||||
`,
|
||||
)
|
||||
: html``}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (this.inline) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return html`
|
||||
<o-modal
|
||||
id="flag-input-modal"
|
||||
title=${translateText("flag_input.title")}
|
||||
?inline=${this.inline}
|
||||
hideHeader
|
||||
hideCloseButton
|
||||
>
|
||||
${content}
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
@@ -95,29 +144,17 @@ export class FlagInputModal extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
public open() {
|
||||
this.isModalOpen = true;
|
||||
this.modalEl?.open();
|
||||
}
|
||||
public close() {
|
||||
this.isModalOpen = false;
|
||||
this.modalEl?.close();
|
||||
protected onOpen(): void {
|
||||
// No custom logic needed
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("keydown", this.handleKeyDown);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
window.removeEventListener("keydown", this.handleKeyDown);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
private handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.code === "Escape") {
|
||||
e.preventDefault();
|
||||
this.close();
|
||||
protected onClose(): void {
|
||||
if (this.returnTo) {
|
||||
const returnEl = document.querySelector(this.returnTo) as any;
|
||||
if (returnEl?.open) {
|
||||
returnEl.open();
|
||||
}
|
||||
this.returnTo = "";
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+25
-126
@@ -1,4 +1,4 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
@@ -7,140 +7,39 @@ export class GameStartingModal extends LitElement {
|
||||
@state()
|
||||
isVisible = false;
|
||||
|
||||
static styles = css`
|
||||
.overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 9998;
|
||||
}
|
||||
|
||||
.overlay.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: rgba(30, 30, 30, 0.7);
|
||||
padding: 25px;
|
||||
border-radius: 10px;
|
||||
z-index: 9999;
|
||||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(5px);
|
||||
color: white;
|
||||
width: 300px;
|
||||
text-align: center;
|
||||
transition:
|
||||
opacity 0.3s ease-in-out,
|
||||
visibility 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.modal.visible {
|
||||
display: block;
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -48%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
.modal h2 {
|
||||
margin-bottom: 15px;
|
||||
font-size: 22px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal p {
|
||||
margin: 2px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.modal .loading {
|
||||
font-size: 16px;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.button-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.modal button {
|
||||
padding: 12px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
background: rgba(255, 100, 100, 0.7);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
transform 0.1s ease;
|
||||
}
|
||||
|
||||
.modal button:hover {
|
||||
background: rgba(255, 100, 100, 0.9);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.modal button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.copyright {
|
||||
font-size: 20px;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 10px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.modal a {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 15px;
|
||||
font-size: 20px;
|
||||
color: #4a9eff;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.modal a:hover {
|
||||
color: #6bb0ff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
render() {
|
||||
const isVisible = this.isVisible;
|
||||
return html`
|
||||
<div class="overlay ${this.isVisible ? "visible" : ""}"></div>
|
||||
<div class="modal ${this.isVisible ? "visible" : ""}">
|
||||
<div class="copyright">© OpenFront and Contributors</div>
|
||||
<div
|
||||
class="fixed inset-0 bg-black/30 backdrop-blur-[4px] z-[9998] transition-all duration-300 ${isVisible
|
||||
? "opacity-100 visible"
|
||||
: "opacity-0 invisible"}"
|
||||
></div>
|
||||
<div
|
||||
class="fixed top-1/2 left-1/2 bg-zinc-800/70 p-6 rounded-xl z-[9999] shadow-[0_0_20px_rgba(0,0,0,0.5)] backdrop-blur-[5px] text-white w-[300px] text-center transition-all duration-300 -translate-x-1/2 ${isVisible
|
||||
? "opacity-100 visible -translate-y-1/2"
|
||||
: "opacity-0 invisible -translate-y-[48%]"}"
|
||||
>
|
||||
<div class="text-xl mt-5 mb-2.5 px-0">
|
||||
© OpenFront and Contributors
|
||||
</div>
|
||||
<a
|
||||
href="https://github.com/openfrontio/OpenFrontIO/blob/main/CREDITS.md"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="block mt-2.5 mb-4 text-xl text-blue-400 no-underline transition-colors duration-200 hover:text-blue-300 hover:underline"
|
||||
>${translateText("game_starting_modal.credits")}</a
|
||||
>
|
||||
<p>${translateText("game_starting_modal.code_license")}</p>
|
||||
<p class="loading">${translateText("game_starting_modal.title")}</p>
|
||||
<p class="my-0.5 text-sm">
|
||||
${translateText("game_starting_modal.code_license")}
|
||||
</p>
|
||||
<p class="text-base my-5 bg-black/30 p-2.5 rounded">
|
||||
${translateText("game_starting_modal.title")}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
declare global {
|
||||
@@ -21,39 +21,18 @@ export class GoogleAdElement extends LitElement {
|
||||
@property({ type: String }) adFormat = "auto";
|
||||
@property({ type: Boolean }) fullWidthResponsive = true;
|
||||
@property({ type: String }) adTest = "off"; // "on" for testing, remove or set to "off" for production
|
||||
@property({ type: String }) darkBackgroundColor = "rgba(0, 0, 0, 0.2)";
|
||||
|
||||
// Disable shadow DOM so AdSense can access the elements
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
.google-ad-container {
|
||||
margin-top: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
height 0.3s ease;
|
||||
}
|
||||
.google-ad-container.hidden {
|
||||
opacity: 0;
|
||||
height: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
`;
|
||||
|
||||
render() {
|
||||
if (isElectron()) {
|
||||
return html``;
|
||||
}
|
||||
return html`
|
||||
<div class="google-ad-container">
|
||||
<div class="mt-4 rounded-lg p-2 w-full overflow-hidden">
|
||||
<ins
|
||||
class="adsbygoogle block"
|
||||
data-ad-client="${this.adClient}"
|
||||
|
||||
+1051
-611
File diff suppressed because it is too large
Load Diff
+780
-528
File diff suppressed because it is too large
Load Diff
@@ -176,13 +176,21 @@ export class InputHandler {
|
||||
saved = Object.fromEntries(
|
||||
Object.entries(parsed)
|
||||
.map(([k, v]) => {
|
||||
if (v && typeof v === "object" && "value" in (v as any)) {
|
||||
return [k, (v as any).value as string];
|
||||
// Extract value from nested object or plain string
|
||||
let val: unknown;
|
||||
if (v && typeof v === "object" && "value" in v) {
|
||||
val = (v as { value: unknown }).value;
|
||||
} else {
|
||||
val = v;
|
||||
}
|
||||
if (typeof v === "string") return [k, v];
|
||||
return [k, undefined];
|
||||
|
||||
// Map invalid values to undefined (filtered later)
|
||||
if (typeof val !== "string" || val === "Null") {
|
||||
return [k, undefined];
|
||||
}
|
||||
return [k, val];
|
||||
})
|
||||
.filter(([, v]) => typeof v === "string" && v !== "Null"),
|
||||
.filter(([, v]) => typeof v === "string"),
|
||||
) as Record<string, string>;
|
||||
} catch (e) {
|
||||
console.warn("Invalid keybinds JSON:", e);
|
||||
|
||||
@@ -1,122 +1,369 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { html, TemplateResult } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import { translateText } from "../client/Utils";
|
||||
import { GameInfo, GameRecordSchema } from "../core/Schemas";
|
||||
import { copyToClipboard, translateText } from "../client/Utils";
|
||||
import {
|
||||
ClientInfo,
|
||||
GameConfig,
|
||||
GameInfo,
|
||||
GameRecordSchema,
|
||||
} from "../core/Schemas";
|
||||
import { generateID } from "../core/Util";
|
||||
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
|
||||
import { GameMode } from "../core/game/Game";
|
||||
import { UserSettings } from "../core/game/UserSettings";
|
||||
import { getApiBase } from "./Api";
|
||||
import { JoinLobbyEvent } from "./Main";
|
||||
import "./components/baseComponents/Button";
|
||||
import "./components/baseComponents/Modal";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import "./components/Difficulties";
|
||||
import "./components/LobbyTeamView";
|
||||
@customElement("join-private-lobby-modal")
|
||||
export class JoinPrivateLobbyModal extends LitElement {
|
||||
@query("o-modal") private modalEl!: HTMLElement & {
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
};
|
||||
export class JoinPrivateLobbyModal extends BaseModal {
|
||||
@query("#lobbyIdInput") private lobbyIdInput!: HTMLInputElement;
|
||||
@state() private message: string = "";
|
||||
@state() private hasJoined = false;
|
||||
@state() private players: string[] = [];
|
||||
@state() private players: ClientInfo[] = [];
|
||||
@state() private gameConfig: GameConfig | null = null;
|
||||
@state() private lobbyCreatorClientID: string | null = null;
|
||||
@state() private lobbyIdVisible: boolean = true;
|
||||
@state() private copySuccess: boolean = false;
|
||||
@state() private currentLobbyId: string = "";
|
||||
|
||||
private playersInterval: NodeJS.Timeout | null = null;
|
||||
private userSettings: UserSettings = new UserSettings();
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("keydown", this.handleKeyDown);
|
||||
updated(changedProperties: Map<string | number | symbol, unknown>) {
|
||||
super.updated(changedProperties);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
window.removeEventListener("keydown", this.handleKeyDown);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
private handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.code === "Escape") {
|
||||
e.preventDefault();
|
||||
this.close();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<o-modal title=${translateText("private_lobby.title")}>
|
||||
<div class="lobby-id-box">
|
||||
<input
|
||||
type="text"
|
||||
id="lobbyIdInput"
|
||||
placeholder=${translateText("private_lobby.enter_id")}
|
||||
@keyup=${this.handleChange}
|
||||
/>
|
||||
<button
|
||||
@click=${this.pasteFromClipboard}
|
||||
class="lobby-id-paste-button"
|
||||
>
|
||||
<svg
|
||||
class="lobby-id-paste-button-icon"
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
stroke-width="0"
|
||||
viewBox="0 0 32 32"
|
||||
height="18px"
|
||||
width="18px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
const content = html`
|
||||
<div
|
||||
class="h-full flex flex-col bg-black/40 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden select-none"
|
||||
>
|
||||
<div
|
||||
class="flex items-center mb-6 pb-2 border-b border-white/10 gap-2 shrink-0 p-6"
|
||||
>
|
||||
<div class="flex items-center gap-4 flex-1">
|
||||
<button
|
||||
@click=${this.closeAndLeave}
|
||||
class="group flex items-center justify-center w-10 h-10 rounded-full bg-white/5 hover:bg-white/10 transition-all border border-white/10"
|
||||
aria-label=${translateText("common.close")}
|
||||
>
|
||||
<path
|
||||
d="M 15 3 C 13.742188 3 12.847656 3.890625 12.40625 5 L 5 5 L 5 28 L 13 28 L 13 30 L 27 30 L 27 14 L 25 14 L 25 5 L 17.59375 5 C 17.152344 3.890625 16.257813 3 15 3 Z M 15 5 C 15.554688 5 16 5.445313 16 6 L 16 7 L 19 7 L 19 9 L 11 9 L 11 7 L 14 7 L 14 6 C 14 5.445313 14.445313 5 15 5 Z M 7 7 L 9 7 L 9 11 L 21 11 L 21 7 L 23 7 L 23 14 L 13 14 L 13 26 L 7 26 Z M 15 16 L 25 16 L 25 28 L 15 28 Z"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="message-area ${this.message ? "show" : ""}">
|
||||
${this.message}
|
||||
</div>
|
||||
<div class="options-layout">
|
||||
${this.hasJoined && this.players.length > 0
|
||||
? html` <div class="options-section">
|
||||
<div class="option-title">
|
||||
${this.players.length}
|
||||
${this.players.length === 1
|
||||
? translateText("private_lobby.player")
|
||||
: translateText("private_lobby.players")}
|
||||
</div>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-5 h-5 text-gray-400 group-hover:text-white transition-colors"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<span
|
||||
class="text-white text-xl sm:text-2xl md:text-3xl font-bold uppercase tracking-widest break-words hyphens-auto"
|
||||
>
|
||||
${translateText("private_lobby.title")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="players-list">
|
||||
${this.players.map(
|
||||
(player) => html`<span class="player-tag">${player}</span>`,
|
||||
)}
|
||||
<!-- Lobby ID Box -->
|
||||
${this.hasJoined
|
||||
? html`<div
|
||||
class="flex items-center gap-0.5 bg-white/5 rounded-lg px-2 py-1 border border-white/10 max-w-[220px] flex-nowrap"
|
||||
>
|
||||
<button
|
||||
@click=${() => {
|
||||
this.lobbyIdVisible = !this.lobbyIdVisible;
|
||||
this.requestUpdate();
|
||||
}}
|
||||
class="p-1.5 rounded-md hover:bg-white/10 text-white/60 hover:text-white transition-colors"
|
||||
title=${translateText("toggle_visibility")}
|
||||
>
|
||||
${this.lobbyIdVisible
|
||||
? html`<svg
|
||||
viewBox="0 0 512 512"
|
||||
height="16px"
|
||||
width="16px"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M256 105c-101.8 0-188.4 62.7-224 151 35.6 88.3 122.2 151 224 151s188.4-62.7 224-151c-35.6-88.3-122.2-151-224-151zm0 251.7c-56 0-101.7-45.7-101.7-101.7S200 153.3 256 153.3 357.7 199 357.7 255 312 356.7 256 356.7zm0-161.1c-33 0-59.4 26.4-59.4 59.4s26.4 59.4 59.4 59.4 59.4-26.4 59.4-59.4-26.4-59.4-59.4-59.4z"
|
||||
></path>
|
||||
</svg>`
|
||||
: html`<svg
|
||||
viewBox="0 0 512 512"
|
||||
height="16px"
|
||||
width="16px"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M448 256s-64-128-192-128S64 256 64 256c32 64 96 128 192 128s160-64 192-128z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="32"
|
||||
></path>
|
||||
<path
|
||||
d="M144 256l224 0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="32"
|
||||
stroke-linecap="round"
|
||||
></path>
|
||||
</svg>`}
|
||||
</button>
|
||||
<div
|
||||
@click=${this.copyToClipboard}
|
||||
@dblclick=${(e: Event) => {
|
||||
(e.currentTarget as HTMLElement).classList.add(
|
||||
"select-all",
|
||||
);
|
||||
}}
|
||||
@mouseleave=${(e: Event) => {
|
||||
(e.currentTarget as HTMLElement).classList.remove(
|
||||
"select-all",
|
||||
);
|
||||
}}
|
||||
class="font-mono text-xs font-bold text-white px-2 cursor-pointer select-none min-w-[80px] text-center truncate tracking-wider"
|
||||
title="${translateText("common.click_to_copy")}"
|
||||
>
|
||||
${this.copySuccess
|
||||
? translateText("common.copied")
|
||||
: this.lobbyIdVisible
|
||||
? this.currentLobbyId
|
||||
: "••••••••"}
|
||||
</div>
|
||||
</div>`
|
||||
: ""}
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-4 mr-1">
|
||||
${!this.hasJoined
|
||||
? html` <o-button
|
||||
title=${translateText("private_lobby.join_lobby")}
|
||||
block
|
||||
@click=${this.joinLobby}
|
||||
></o-button>`
|
||||
? html`<div class="flex flex-col gap-3">
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
id="lobbyIdInput"
|
||||
placeholder=${translateText("private_lobby.enter_id")}
|
||||
@keyup=${this.handleChange}
|
||||
class="flex-1 px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all font-mono text-sm tracking-wider"
|
||||
/>
|
||||
<button
|
||||
@click=${this.pasteFromClipboard}
|
||||
class="px-4 py-3 bg-white/5 hover:bg-white/10 border border-white/10 hover:border-white/20 rounded-xl transition-all group"
|
||||
title=${translateText("common.paste")}
|
||||
>
|
||||
<svg
|
||||
class="text-white/60 group-hover:text-white transition-colors"
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
stroke-width="0"
|
||||
viewBox="0 0 32 32"
|
||||
height="18px"
|
||||
width="18px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M 15 3 C 13.742188 3 12.847656 3.890625 12.40625 5 L 5 5 L 5 28 L 13 28 L 13 30 L 27 30 L 27 14 L 25 14 L 25 5 L 17.59375 5 C 17.152344 3.890625 16.257813 3 15 3 Z M 15 5 C 15.554688 5 16 5.445313 16 6 L 16 7 L 19 7 L 19 9 L 11 9 L 11 7 L 14 7 L 14 6 C 14 5.445313 14.445313 5 15 5 Z M 7 7 L 9 7 L 9 11 L 21 11 L 21 7 L 23 7 L 23 14 L 13 14 L 13 26 L 7 26 Z M 15 16 L 25 16 L 25 28 L 15 28 Z"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<o-button
|
||||
title=${translateText("private_lobby.join_lobby")}
|
||||
block
|
||||
@click=${this.joinLobby}
|
||||
></o-button>
|
||||
</div>`
|
||||
: ""}
|
||||
${this.renderGameConfig()}
|
||||
${this.hasJoined && this.players.length > 0
|
||||
? html`
|
||||
<div class="mt-6 border-t border-white/10 pt-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div
|
||||
class="text-xs font-bold text-white/40 uppercase tracking-widest"
|
||||
>
|
||||
${this.players.length}
|
||||
${this.players.length === 1
|
||||
? translateText("private_lobby.player")
|
||||
: translateText("private_lobby.players")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<lobby-team-view
|
||||
class="block rounded-lg border border-white/10 bg-white/5 p-2"
|
||||
.gameMode=${this.gameConfig?.gameMode ?? GameMode.FFA}
|
||||
.clients=${this.players}
|
||||
.lobbyCreatorClientID=${this.lobbyCreatorClientID}
|
||||
.teamCount=${this.gameConfig?.playerTeams ?? 2}
|
||||
></lobby-team-view>
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
|
||||
${this.hasJoined && this.players.length > 0
|
||||
? html` <div
|
||||
class="p-6 pt-4 border-t border-white/10 bg-black/20 shrink-0"
|
||||
>
|
||||
<button
|
||||
class="w-full py-4 text-sm font-bold text-white uppercase tracking-widest bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed rounded-xl transition-all shadow-lg shadow-blue-900/20 hover:shadow-blue-900/40 hover:-translate-y-0.5 active:translate-y-0 disabled:transform-none"
|
||||
disabled
|
||||
>
|
||||
${translateText("private_lobby.joined_waiting")}
|
||||
</button>
|
||||
</div>`
|
||||
: ""}
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (this.inline) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return html`
|
||||
<o-modal
|
||||
?hideHeader=${true}
|
||||
?hideCloseButton=${true}
|
||||
?inline=${this.inline}
|
||||
>
|
||||
${content}
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
createRenderRoot() {
|
||||
return this; // light DOM
|
||||
private renderConfigItem(
|
||||
label: string,
|
||||
value: string | TemplateResult,
|
||||
): TemplateResult {
|
||||
return html`
|
||||
<div
|
||||
class="bg-white/5 border border-white/10 rounded-lg p-3 flex flex-col items-center justify-center gap-1 text-center min-w-[100px]"
|
||||
>
|
||||
<span
|
||||
class="text-white/40 text-[10px] font-bold uppercase tracking-wider"
|
||||
>${label}</span
|
||||
>
|
||||
<span
|
||||
class="text-white font-bold text-sm w-full break-words hyphens-auto"
|
||||
>${value}</span
|
||||
>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderGameConfig(): TemplateResult {
|
||||
if (!this.gameConfig) return html``;
|
||||
|
||||
const c = this.gameConfig;
|
||||
const mapName = translateText(
|
||||
"map." + c.gameMap.toLowerCase().replace(/ /g, ""),
|
||||
);
|
||||
const modeName =
|
||||
c.gameMode === "Free For All"
|
||||
? translateText("game_mode.ffa")
|
||||
: translateText("game_mode.teams");
|
||||
const diffName = translateText(
|
||||
"difficulty." + c.difficulty.toLowerCase().replace(/ /g, ""),
|
||||
);
|
||||
|
||||
return html`
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
${this.renderConfigItem(translateText("map.map"), mapName)}
|
||||
${this.renderConfigItem(translateText("host_modal.mode"), modeName)}
|
||||
${this.renderConfigItem(
|
||||
translateText("difficulty.difficulty"),
|
||||
diffName,
|
||||
)}
|
||||
${this.renderConfigItem(
|
||||
translateText("host_modal.bots"),
|
||||
c.bots.toString(),
|
||||
)}
|
||||
${c.gameMode !== "Free For All" && c.playerTeams
|
||||
? this.renderConfigItem(
|
||||
typeof c.playerTeams === "string"
|
||||
? translateText("host_modal.team_type")
|
||||
: translateText("host_modal.team_count"),
|
||||
typeof c.playerTeams === "string"
|
||||
? translateText("host_modal.teams_" + c.playerTeams)
|
||||
: c.playerTeams.toString(),
|
||||
)
|
||||
: html``}
|
||||
</div>
|
||||
${this.renderDisabledUnits()}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderDisabledUnits(): TemplateResult {
|
||||
if (
|
||||
!this.gameConfig ||
|
||||
!this.gameConfig.disabledUnits ||
|
||||
this.gameConfig.disabledUnits.length === 0
|
||||
) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
const unitKeys: Record<string, string> = {
|
||||
City: "unit_type.city",
|
||||
Port: "unit_type.port",
|
||||
"Defense Post": "unit_type.defense_post",
|
||||
"SAM Launcher": "unit_type.sam_launcher",
|
||||
"Missile Silo": "unit_type.missile_silo",
|
||||
Warship: "unit_type.warship",
|
||||
Factory: "unit_type.factory",
|
||||
"Atom Bomb": "unit_type.atom_bomb",
|
||||
"Hydrogen Bomb": "unit_type.hydrogen_bomb",
|
||||
MIRV: "unit_type.mirv",
|
||||
"Trade Ship": "stats_modal.unit.trade",
|
||||
Transport: "stats_modal.unit.trans",
|
||||
"MIRV Warhead": "stats_modal.unit.mirvw",
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="mt-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg">
|
||||
<div
|
||||
class="text-xs font-bold text-red-400 uppercase tracking-widest mb-2"
|
||||
>
|
||||
${translateText("private_lobby.disabled_units")}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
${this.gameConfig.disabledUnits.map((unit) => {
|
||||
const key = unitKeys[unit];
|
||||
const name = key ? translateText(key) : unit;
|
||||
return html`
|
||||
<span
|
||||
class="px-2 py-1 bg-red-500/20 text-red-200 text-xs rounded font-bold border border-red-500/30"
|
||||
>
|
||||
${name}
|
||||
</span>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
public open(id: string = "") {
|
||||
this.modalEl?.open();
|
||||
super.open();
|
||||
this.lobbyIdVisible = this.userSettings.get(
|
||||
"settings.lobbyIdVisibility",
|
||||
true,
|
||||
);
|
||||
if (id) {
|
||||
this.setLobbyId(id);
|
||||
this.joinLobby();
|
||||
}
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.lobbyIdInput.value = "";
|
||||
this.modalEl?.close();
|
||||
protected onClose(): void {
|
||||
if (this.lobbyIdInput) this.lobbyIdInput.value = "";
|
||||
this.currentLobbyId = "";
|
||||
this.gameConfig = null;
|
||||
this.players = [];
|
||||
if (this.playersInterval) {
|
||||
clearInterval(this.playersInterval);
|
||||
this.playersInterval = null;
|
||||
@@ -129,13 +376,21 @@ export class JoinPrivateLobbyModal extends LitElement {
|
||||
this.message = "";
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("leave-lobby", {
|
||||
detail: { lobby: this.lobbyIdInput.value },
|
||||
detail: { lobby: this.currentLobbyId },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async copyToClipboard() {
|
||||
await copyToClipboard(
|
||||
`${location.origin}/#join=${this.currentLobbyId}`,
|
||||
() => (this.copySuccess = true),
|
||||
() => (this.copySuccess = false),
|
||||
);
|
||||
}
|
||||
|
||||
private isValidLobbyId(value: string): boolean {
|
||||
return /^[a-zA-Z0-9]{8}$/.test(value);
|
||||
}
|
||||
@@ -188,13 +443,13 @@ export class JoinPrivateLobbyModal extends LitElement {
|
||||
private async joinLobby(): Promise<void> {
|
||||
const lobbyId = this.normalizeLobbyId(this.lobbyIdInput.value);
|
||||
if (!lobbyId) {
|
||||
this.message = translateText("private_lobby.not_found");
|
||||
this.showMessage(translateText("private_lobby.not_found"), "red");
|
||||
return;
|
||||
}
|
||||
|
||||
this.lobbyIdInput.value = lobbyId;
|
||||
this.currentLobbyId = lobbyId;
|
||||
console.log(`Joining lobby with ID: ${this.sanitizeForLog(lobbyId)}`);
|
||||
this.message = `${translateText("private_lobby.checking")}`;
|
||||
|
||||
try {
|
||||
// First, check if the game exists in active lobbies
|
||||
@@ -206,21 +461,36 @@ export class JoinPrivateLobbyModal extends LitElement {
|
||||
case "success":
|
||||
return;
|
||||
case "not_found":
|
||||
this.message = `${translateText("private_lobby.not_found")}`;
|
||||
this.showMessage(translateText("private_lobby.not_found"), "red");
|
||||
this.message = "";
|
||||
return;
|
||||
case "version_mismatch":
|
||||
this.message = `${translateText("private_lobby.version_mismatch")}`;
|
||||
this.showMessage(
|
||||
translateText("private_lobby.version_mismatch"),
|
||||
"red",
|
||||
);
|
||||
this.message = "";
|
||||
return;
|
||||
case "error":
|
||||
this.message = `${translateText("private_lobby.error")}`;
|
||||
this.showMessage(translateText("private_lobby.error"), "red");
|
||||
this.message = "";
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error checking lobby existence:", error);
|
||||
this.message = `${translateText("private_lobby.error")}`;
|
||||
this.showMessage(translateText("private_lobby.error"), "red");
|
||||
this.message = "";
|
||||
}
|
||||
}
|
||||
|
||||
private showMessage(message: string, color: "green" | "red" = "green") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
detail: { message, duration: 3000, color },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async checkActiveLobby(lobbyId: string): Promise<boolean> {
|
||||
const config = await getServerConfigFromClient();
|
||||
const url = `/${config.workerPath(lobbyId)}/api/game/${lobbyId}/exists`;
|
||||
@@ -233,7 +503,8 @@ export class JoinPrivateLobbyModal extends LitElement {
|
||||
const gameInfo = await response.json();
|
||||
|
||||
if (gameInfo.exists) {
|
||||
this.message = translateText("private_lobby.joined_waiting");
|
||||
this.showMessage(translateText("private_lobby.joined_waiting"));
|
||||
this.message = "";
|
||||
this.hasJoined = true;
|
||||
|
||||
this.dispatchEvent(
|
||||
@@ -247,6 +518,7 @@ export class JoinPrivateLobbyModal extends LitElement {
|
||||
}),
|
||||
);
|
||||
|
||||
this.pollPlayers();
|
||||
this.playersInterval = setInterval(() => this.pollPlayers(), 1000);
|
||||
return true;
|
||||
}
|
||||
@@ -323,7 +595,7 @@ export class JoinPrivateLobbyModal extends LitElement {
|
||||
}
|
||||
|
||||
private async pollPlayers() {
|
||||
const lobbyId = this.normalizeLobbyId(this.lobbyIdInput.value);
|
||||
const lobbyId = this.currentLobbyId;
|
||||
if (!lobbyId) return;
|
||||
const config = await getServerConfigFromClient();
|
||||
|
||||
@@ -335,7 +607,11 @@ export class JoinPrivateLobbyModal extends LitElement {
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data: GameInfo) => {
|
||||
this.players = data.clients?.map((p) => p.username) ?? [];
|
||||
this.lobbyCreatorClientID = data.clients?.[0]?.clientID ?? null;
|
||||
this.players = data.clients ?? [];
|
||||
if (data.gameConfig) {
|
||||
this.gameConfig = data.gameConfig;
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error polling players:", error);
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
import { html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { formatKeyForDisplay, translateText } from "../client/Utils";
|
||||
import "./components/baseComponents/setting/SettingKeybind";
|
||||
import { SettingKeybind } from "./components/baseComponents/setting/SettingKeybind";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
|
||||
const DefaultKeybinds: Record<string, string> = {
|
||||
toggleView: "Space",
|
||||
buildCity: "Digit1",
|
||||
buildFactory: "Digit2",
|
||||
buildPort: "Digit3",
|
||||
buildDefensePost: "Digit4",
|
||||
buildMissileSilo: "Digit5",
|
||||
buildSamLauncher: "Digit6",
|
||||
buildWarship: "Digit7",
|
||||
buildAtomBomb: "Digit8",
|
||||
buildHydrogenBomb: "Digit9",
|
||||
buildMIRV: "Digit0",
|
||||
attackRatioDown: "KeyT",
|
||||
attackRatioUp: "KeyY",
|
||||
boatAttack: "KeyB",
|
||||
groundAttack: "KeyG",
|
||||
zoomOut: "KeyQ",
|
||||
zoomIn: "KeyE",
|
||||
centerCamera: "KeyC",
|
||||
moveUp: "KeyW",
|
||||
moveLeft: "KeyA",
|
||||
moveDown: "KeyS",
|
||||
moveRight: "KeyD",
|
||||
};
|
||||
|
||||
@customElement("keybinds-modal")
|
||||
export class KeybindsModal extends BaseModal {
|
||||
@state() private keybinds: Record<
|
||||
string,
|
||||
{ value: string | string[]; key: string }
|
||||
> = {};
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
const savedKeybinds = localStorage.getItem("settings.keybinds");
|
||||
if (savedKeybinds) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedKeybinds);
|
||||
// Validate shape: ensure all values have 'value' and 'key' properties with correct types
|
||||
if (
|
||||
typeof parsed === "object" &&
|
||||
parsed !== null &&
|
||||
!Array.isArray(parsed)
|
||||
) {
|
||||
const isValid = Object.values(parsed).every((entry) => {
|
||||
// Ensure entry is an object (not null, not array, not primitive)
|
||||
if (
|
||||
typeof entry !== "object" ||
|
||||
entry === null ||
|
||||
Array.isArray(entry)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Ensure 'key' property exists and is a string
|
||||
if (!("key" in entry) || typeof entry.key !== "string") {
|
||||
return false;
|
||||
}
|
||||
// Ensure 'value' property exists and is either a string or an array of strings
|
||||
if (!("value" in entry)) {
|
||||
return false;
|
||||
}
|
||||
if (typeof entry.value === "string") {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(entry.value)) {
|
||||
return entry.value.every((v) => typeof v === "string");
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (isValid) {
|
||||
this.keybinds = parsed;
|
||||
} else {
|
||||
console.warn(
|
||||
"Invalid keybinds structure: entries must be objects with 'key' (string) and 'value' (string or string[]) properties. Ignoring saved data.",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
"Invalid keybinds data: expected non-array object. Ignoring saved data.",
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Invalid keybinds JSON:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleKeybindChange(
|
||||
e: CustomEvent<{
|
||||
action: string;
|
||||
value: string;
|
||||
key: string;
|
||||
prevValue?: string;
|
||||
}>,
|
||||
) {
|
||||
const { action, value, key, prevValue } = e.detail;
|
||||
|
||||
const activeKeybinds: Record<string, string> = { ...DefaultKeybinds };
|
||||
for (const [k, v] of Object.entries(this.keybinds)) {
|
||||
// Normalize value to string
|
||||
const normalizedValue = Array.isArray(v.value)
|
||||
? v.value[0] || ""
|
||||
: v.value;
|
||||
if (normalizedValue === "Null") {
|
||||
delete activeKeybinds[k];
|
||||
} else {
|
||||
activeKeybinds[k] = normalizedValue;
|
||||
}
|
||||
}
|
||||
|
||||
const values = Object.entries(activeKeybinds)
|
||||
.filter(([k]) => k !== action)
|
||||
.map(([, v]) => v);
|
||||
|
||||
if (values.includes(value) && value !== "Null") {
|
||||
// Format key for user-friendly display
|
||||
const displayKey = formatKeyForDisplay(key || value);
|
||||
// Use heads-up-message modal for error popup
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
detail: {
|
||||
message: html`
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 text-red-500 inline-block align-middle mr-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-medium">
|
||||
${(() => {
|
||||
const message = translateText(
|
||||
"user_setting.keybind_conflict_error",
|
||||
{ key: displayKey },
|
||||
);
|
||||
const parts = message.split(displayKey);
|
||||
return html`${parts[0]}<span
|
||||
class="font-mono font-bold bg-white/10 px-1.5 py-0.5 rounded text-red-200 mx-1 border border-white/10"
|
||||
>${displayKey}</span
|
||||
>${parts[1] || ""}`;
|
||||
})()}
|
||||
</span>
|
||||
`,
|
||||
color: "red",
|
||||
duration: 3000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const element = this.renderRoot.querySelector(
|
||||
`setting-keybind[action="${action}"]`,
|
||||
) as SettingKeybind;
|
||||
if (element) {
|
||||
// Restore the previous value, or use default keybind if no previous override
|
||||
element.value = prevValue ?? DefaultKeybinds[action] ?? "";
|
||||
element.requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.keybinds = { ...this.keybinds, [action]: { value: value, key: key } };
|
||||
localStorage.setItem("settings.keybinds", JSON.stringify(this.keybinds));
|
||||
}
|
||||
|
||||
private getKeyValue(action: string): string | undefined {
|
||||
const entry = this.keybinds[action];
|
||||
if (!entry) return undefined;
|
||||
// Normalize value to string
|
||||
const normalizedValue = Array.isArray(entry.value)
|
||||
? entry.value[0] || ""
|
||||
: entry.value;
|
||||
if (normalizedValue === "Null") return "";
|
||||
return normalizedValue || undefined;
|
||||
}
|
||||
|
||||
private getKeyChar(action: string): string {
|
||||
const entry = this.keybinds[action];
|
||||
if (!entry) return "";
|
||||
return entry.key || "";
|
||||
}
|
||||
|
||||
render() {
|
||||
const content = html`
|
||||
<div
|
||||
class="h-full flex flex-col ${this.inline
|
||||
? "bg-black/40 backdrop-blur-md rounded-2xl border border-white/10"
|
||||
: ""}"
|
||||
>
|
||||
<div
|
||||
class="flex items-center mb-6 pb-2 border-b border-white/10 gap-2 shrink-0 p-6"
|
||||
>
|
||||
<div class="flex items-center gap-4 flex-1">
|
||||
<button
|
||||
@click=${this.close}
|
||||
class="group flex items-center justify-center w-10 h-10 rounded-full bg-white/5 hover:bg-white/10 transition-all border border-white/10"
|
||||
aria-label="${translateText("common.back")}"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-5 h-5 text-gray-400 group-hover:text-white transition-colors"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<span
|
||||
class="text-white text-xl sm:text-2xl md:text-3xl font-bold uppercase tracking-widest break-words hyphens-auto"
|
||||
>
|
||||
${translateText("user_setting.tab_keybinds")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent px-6 pb-6 mr-1"
|
||||
>
|
||||
<div class="flex flex-col gap-2">${this.renderKeybindSettings()}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (this.inline) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return html`
|
||||
<o-modal
|
||||
title="${translateText("user_setting.tab_keybinds")}"
|
||||
?inline=${this.inline}
|
||||
hideCloseButton
|
||||
hideHeader
|
||||
>
|
||||
${content}
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderKeybindSettings() {
|
||||
return html`
|
||||
<h2
|
||||
class="text-blue-200 text-xl font-bold mt-4 mb-3 border-b border-white/10 pb-2"
|
||||
>
|
||||
${translateText("user_setting.view_options")}
|
||||
</h2>
|
||||
|
||||
<setting-keybind
|
||||
action="toggleView"
|
||||
label=${translateText("user_setting.toggle_view")}
|
||||
description=${translateText("user_setting.toggle_view_desc")}
|
||||
defaultKey="Space"
|
||||
.value=${this.getKeyValue("toggleView")}
|
||||
.display=${this.getKeyChar("toggleView")}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<h2
|
||||
class="text-blue-200 text-xl font-bold mt-8 mb-3 border-b border-white/10 pb-2"
|
||||
>
|
||||
${translateText("user_setting.build_controls")}
|
||||
</h2>
|
||||
|
||||
<setting-keybind
|
||||
action="buildCity"
|
||||
label=${translateText("user_setting.build_city")}
|
||||
description=${translateText("user_setting.build_city_desc")}
|
||||
defaultKey="Digit1"
|
||||
.value=${this.getKeyValue("buildCity")}
|
||||
.display=${this.getKeyChar("buildCity")}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="buildFactory"
|
||||
label=${translateText("user_setting.build_factory")}
|
||||
description=${translateText("user_setting.build_factory_desc")}
|
||||
defaultKey="Digit2"
|
||||
.value=${this.getKeyValue("buildFactory")}
|
||||
.display=${this.getKeyChar("buildFactory")}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="buildPort"
|
||||
label=${translateText("user_setting.build_port")}
|
||||
description=${translateText("user_setting.build_port_desc")}
|
||||
defaultKey="Digit3"
|
||||
.value=${this.getKeyValue("buildPort")}
|
||||
.display=${this.getKeyChar("buildPort")}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="buildDefensePost"
|
||||
label=${translateText("user_setting.build_defense_post")}
|
||||
description=${translateText("user_setting.build_defense_post_desc")}
|
||||
defaultKey="Digit4"
|
||||
.value=${this.getKeyValue("buildDefensePost")}
|
||||
.display=${this.getKeyChar("buildDefensePost")}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="buildMissileSilo"
|
||||
label=${translateText("user_setting.build_missile_silo")}
|
||||
description=${translateText("user_setting.build_missile_silo_desc")}
|
||||
defaultKey="Digit5"
|
||||
.value=${this.getKeyValue("buildMissileSilo")}
|
||||
.display=${this.getKeyChar("buildMissileSilo")}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="buildSamLauncher"
|
||||
label=${translateText("user_setting.build_sam_launcher")}
|
||||
description=${translateText("user_setting.build_sam_launcher_desc")}
|
||||
defaultKey="Digit6"
|
||||
.value=${this.getKeyValue("buildSamLauncher")}
|
||||
.display=${this.getKeyChar("buildSamLauncher")}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="buildWarship"
|
||||
label=${translateText("user_setting.build_warship")}
|
||||
description=${translateText("user_setting.build_warship_desc")}
|
||||
defaultKey="Digit7"
|
||||
.value=${this.getKeyValue("buildWarship")}
|
||||
.display=${this.getKeyChar("buildWarship")}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="buildAtomBomb"
|
||||
label=${translateText("user_setting.build_atom_bomb")}
|
||||
description=${translateText("user_setting.build_atom_bomb_desc")}
|
||||
defaultKey="Digit8"
|
||||
.value=${this.getKeyValue("buildAtomBomb")}
|
||||
.display=${this.getKeyChar("buildAtomBomb")}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="buildHydrogenBomb"
|
||||
label=${translateText("user_setting.build_hydrogen_bomb")}
|
||||
description=${translateText("user_setting.build_hydrogen_bomb_desc")}
|
||||
defaultKey="Digit9"
|
||||
.value=${this.getKeyValue("buildHydrogenBomb")}
|
||||
.display=${this.getKeyChar("buildHydrogenBomb")}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="buildMIRV"
|
||||
label=${translateText("user_setting.build_mirv")}
|
||||
description=${translateText("user_setting.build_mirv_desc")}
|
||||
defaultKey="Digit0"
|
||||
.value=${this.getKeyValue("buildMIRV")}
|
||||
.display=${this.getKeyChar("buildMIRV")}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<h2
|
||||
class="text-blue-200 text-xl font-bold mt-8 mb-3 border-b border-white/10 pb-2"
|
||||
>
|
||||
${translateText("user_setting.attack_ratio_controls")}
|
||||
</h2>
|
||||
|
||||
<setting-keybind
|
||||
action="attackRatioDown"
|
||||
label=${translateText("user_setting.attack_ratio_down")}
|
||||
description=${translateText("user_setting.attack_ratio_down_desc")}
|
||||
defaultKey="KeyT"
|
||||
.value=${this.getKeyValue("attackRatioDown")}
|
||||
.display=${this.getKeyChar("attackRatioDown")}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="attackRatioUp"
|
||||
label=${translateText("user_setting.attack_ratio_up")}
|
||||
description=${translateText("user_setting.attack_ratio_up_desc")}
|
||||
defaultKey="KeyY"
|
||||
.value=${this.getKeyValue("attackRatioUp")}
|
||||
.display=${this.getKeyChar("attackRatioUp")}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<h2
|
||||
class="text-blue-200 text-xl font-bold mt-8 mb-3 border-b border-white/10 pb-2"
|
||||
>
|
||||
${translateText("user_setting.attack_keybinds")}
|
||||
</h2>
|
||||
|
||||
<setting-keybind
|
||||
action="boatAttack"
|
||||
label=${translateText("user_setting.boat_attack")}
|
||||
description=${translateText("user_setting.boat_attack_desc")}
|
||||
defaultKey="KeyB"
|
||||
.value=${this.getKeyValue("boatAttack")}
|
||||
.display=${this.getKeyChar("boatAttack")}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="groundAttack"
|
||||
label=${translateText("user_setting.ground_attack")}
|
||||
description=${translateText("user_setting.ground_attack_desc")}
|
||||
defaultKey="KeyG"
|
||||
.value=${this.getKeyValue("groundAttack")}
|
||||
.display=${this.getKeyChar("groundAttack")}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<h2
|
||||
class="text-blue-200 text-xl font-bold mt-8 mb-3 border-b border-white/10 pb-2"
|
||||
>
|
||||
${translateText("user_setting.zoom_controls")}
|
||||
</h2>
|
||||
|
||||
<setting-keybind
|
||||
action="zoomOut"
|
||||
label=${translateText("user_setting.zoom_out")}
|
||||
description=${translateText("user_setting.zoom_out_desc")}
|
||||
defaultKey="KeyQ"
|
||||
.value=${this.getKeyValue("zoomOut")}
|
||||
.display=${this.getKeyChar("zoomOut")}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="zoomIn"
|
||||
label=${translateText("user_setting.zoom_in")}
|
||||
description=${translateText("user_setting.zoom_in_desc")}
|
||||
defaultKey="KeyE"
|
||||
.value=${this.getKeyValue("zoomIn")}
|
||||
.display=${this.getKeyChar("zoomIn")}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<h2
|
||||
class="text-blue-200 text-xl font-bold mt-8 mb-3 border-b border-white/10 pb-2"
|
||||
>
|
||||
${translateText("user_setting.camera_movement")}
|
||||
</h2>
|
||||
|
||||
<setting-keybind
|
||||
action="centerCamera"
|
||||
label=${translateText("user_setting.center_camera")}
|
||||
description=${translateText("user_setting.center_camera_desc")}
|
||||
defaultKey="KeyC"
|
||||
.value=${this.getKeyValue("centerCamera")}
|
||||
.display=${this.getKeyChar("centerCamera")}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="moveUp"
|
||||
label=${translateText("user_setting.move_up")}
|
||||
description=${translateText("user_setting.move_up_desc")}
|
||||
defaultKey="KeyW"
|
||||
.value=${this.getKeyValue("moveUp")}
|
||||
.display=${this.getKeyChar("moveUp")}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="moveLeft"
|
||||
label=${translateText("user_setting.move_left")}
|
||||
description=${translateText("user_setting.move_left_desc")}
|
||||
defaultKey="KeyA"
|
||||
.value=${this.getKeyValue("moveLeft")}
|
||||
.display=${this.getKeyChar("moveLeft")}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="moveDown"
|
||||
label=${translateText("user_setting.move_down")}
|
||||
description=${translateText("user_setting.move_down_desc")}
|
||||
defaultKey="KeyS"
|
||||
.value=${this.getKeyValue("moveDown")}
|
||||
.display=${this.getKeyChar("moveDown")}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="moveRight"
|
||||
label=${translateText("user_setting.move_right")}
|
||||
description=${translateText("user_setting.move_right_desc")}
|
||||
defaultKey="KeyD"
|
||||
.value=${this.getKeyValue("moveRight")}
|
||||
.display=${this.getKeyChar("moveRight")}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
`;
|
||||
}
|
||||
|
||||
protected onOpen(): void {
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
+41
-15
@@ -1,6 +1,7 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import "./LanguageModal";
|
||||
import { LanguageModal } from "./LanguageModal";
|
||||
|
||||
import en from "../../resources/lang/en.json";
|
||||
import metadata from "../../resources/lang/metadata.json";
|
||||
@@ -18,7 +19,6 @@ export class LangSelector extends LitElement {
|
||||
@state() public defaultTranslations: Record<string, string> | undefined;
|
||||
@state() public currentLang: string = "en";
|
||||
@state() private languageList: any[] = [];
|
||||
@state() private showModal: boolean = false;
|
||||
@state() private debugMode: boolean = false;
|
||||
@state() isVisible = true;
|
||||
|
||||
@@ -34,8 +34,26 @@ export class LangSelector extends LitElement {
|
||||
super.connectedCallback();
|
||||
this.setupDebugKey();
|
||||
this.initializeLanguage();
|
||||
window.addEventListener(
|
||||
"language-selected",
|
||||
this.handleLanguageSelected as EventListener,
|
||||
);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener(
|
||||
"language-selected",
|
||||
this.handleLanguageSelected as EventListener,
|
||||
);
|
||||
}
|
||||
|
||||
private handleLanguageSelected = (e: CustomEvent) => {
|
||||
if (e.detail && e.detail.lang) {
|
||||
this.changeLanguage(e.detail.lang);
|
||||
}
|
||||
};
|
||||
|
||||
private setupDebugKey() {
|
||||
window.addEventListener("keydown", (e) => {
|
||||
if (e.key?.toLowerCase() === "t") this.debugKeyPressed = true;
|
||||
@@ -172,7 +190,6 @@ export class LangSelector extends LitElement {
|
||||
this.translations = await this.loadLanguage(lang);
|
||||
this.currentLang = lang;
|
||||
this.applyTranslation();
|
||||
this.showModal = false;
|
||||
}
|
||||
|
||||
private applyTranslation() {
|
||||
@@ -197,6 +214,13 @@ export class LangSelector extends LitElement {
|
||||
"o-button",
|
||||
"territory-patterns-modal",
|
||||
"fluent-slider",
|
||||
"news-modal",
|
||||
"news-button",
|
||||
"account-modal",
|
||||
"keybinds-modal",
|
||||
"stats-modal",
|
||||
"flag-input-modal",
|
||||
"flag-input",
|
||||
];
|
||||
|
||||
document.title = this.translateText("main.title") ?? document.title;
|
||||
@@ -245,12 +269,21 @@ export class LangSelector extends LitElement {
|
||||
|
||||
private async openModal() {
|
||||
this.debugMode = this.debugKeyPressed;
|
||||
this.showModal = true;
|
||||
await this.loadLanguageList();
|
||||
|
||||
const languageModal = document.getElementById(
|
||||
"page-language",
|
||||
) as LanguageModal;
|
||||
|
||||
if (languageModal) {
|
||||
languageModal.languageList = [...this.languageList];
|
||||
languageModal.currentLang = this.currentLang;
|
||||
// Use the navigation system
|
||||
window.showPage?.("page-language");
|
||||
}
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.showModal = false;
|
||||
this.isVisible = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
@@ -279,24 +312,17 @@ export class LangSelector extends LitElement {
|
||||
id="lang-selector"
|
||||
title="Change Language"
|
||||
@click=${this.openModal}
|
||||
class="fixed bottom-4 left-4 z-50 border-none bg-none cursor-pointer"
|
||||
class="border-none bg-none cursor-pointer p-0 flex items-center justify-center"
|
||||
style="width: 28px; height: 28px;"
|
||||
>
|
||||
<img
|
||||
id="lang-flag"
|
||||
class="w-20 h-14"
|
||||
class="object-contain hover:scale-110 transition-transform duration-200"
|
||||
style="width: 28px; height: 28px;"
|
||||
src="/flags/${currentLang.svg}.svg"
|
||||
alt="flag"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<language-modal
|
||||
.visible=${this.showModal}
|
||||
.languageList=${this.languageList}
|
||||
.currentLang=${this.currentLang}
|
||||
@language-selected=${(e: CustomEvent) =>
|
||||
this.changeLanguage(e.detail.lang)}
|
||||
@close-modal=${() => (this.showModal = false)}
|
||||
></language-modal>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
+108
-70
@@ -1,54 +1,21 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { translateText } from "../client/Utils";
|
||||
import "./components/baseComponents/Modal";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
|
||||
interface LanguageOption {
|
||||
code: string;
|
||||
svg: string;
|
||||
native: string;
|
||||
en: string;
|
||||
}
|
||||
|
||||
@customElement("language-modal")
|
||||
export class LanguageModal extends LitElement {
|
||||
@property({ type: Boolean }) visible = false;
|
||||
@property({ type: Array }) languageList: any[] = [];
|
||||
export class LanguageModal extends BaseModal {
|
||||
@property({ type: Array }) languageList: LanguageOption[] = [];
|
||||
@property({ type: String }) currentLang = "en";
|
||||
|
||||
createRenderRoot() {
|
||||
return this; // Use Light DOM for TailwindCSS classes
|
||||
}
|
||||
|
||||
private close = () => {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("close-modal", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
updated(changedProps: Map<string, unknown>) {
|
||||
if (changedProps.has("visible")) {
|
||||
if (this.visible) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "auto";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("keydown", this.handleKeyDown);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("keydown", this.handleKeyDown);
|
||||
document.body.style.overflow = "auto";
|
||||
}
|
||||
|
||||
private handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.code === "Escape") {
|
||||
e.preventDefault();
|
||||
this.close();
|
||||
}
|
||||
};
|
||||
|
||||
private selectLanguage = (lang: string) => {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("language-selected", {
|
||||
@@ -57,49 +24,73 @@ export class LanguageModal extends LitElement {
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
this.close();
|
||||
};
|
||||
|
||||
render() {
|
||||
if (!this.visible) return null;
|
||||
|
||||
return html`
|
||||
<aside
|
||||
class="fixed p-4 z-9999 inset-0 bg-black/50 overflow-y-auto flex items-center justify-center"
|
||||
const content = html`
|
||||
<div
|
||||
class="h-full flex flex-col ${
|
||||
this.inline
|
||||
? "bg-black/40 backdrop-blur-md rounded-2xl border border-white/10 p-6"
|
||||
: "bg-[#232323] text-white"
|
||||
}"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="bg-gray-800/80 dark:bg-gray-900/90 backdrop-blur-md rounded-lg min-w-85 max-w-120 w-full"
|
||||
class="flex items-center mb-6 pb-2 border-b border-white/10 gap-2 shrink-0"
|
||||
>
|
||||
<header
|
||||
class="relative rounded-t-md text-lg bg-black/60 dark:bg-black/80 text-center text-white px-6 py-4 pr-10"
|
||||
>
|
||||
${translateText("select_lang.title")}
|
||||
<div
|
||||
class="cursor-pointer absolute right-4 top-4 font-bold hover:text-gray-300"
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
@click=${this.close}
|
||||
class="group flex items-center justify-center w-10 h-10 rounded-full bg-white/5 hover:bg-white/10 transition-all border border-white/10"
|
||||
aria-label="${translateText("common.back")}"
|
||||
>
|
||||
✕
|
||||
</div>
|
||||
</header>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-5 h-5 text-gray-400 group-hover:text-white transition-colors"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<span
|
||||
class="text-white text-xl sm:text-2xl md:text-3xl font-bold uppercase tracking-widest break-words hyphens-auto"
|
||||
>
|
||||
${translateText("select_lang.title")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section
|
||||
class="relative text-white dark:text-gray-100 p-6 max-h-[60dvh] overflow-y-auto"
|
||||
<div
|
||||
class="flex-1 overflow-y-auto custom-scrollbar pr-2 mr-1"
|
||||
>
|
||||
<div
|
||||
class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3"
|
||||
>
|
||||
${this.languageList.map((lang) => {
|
||||
const isActive = this.currentLang === lang.code;
|
||||
const isDebug = lang.code === "debug";
|
||||
|
||||
let buttonClasses =
|
||||
"w-full flex items-center gap-2 p-2 mb-2 rounded-md transition-colors duration-300 border";
|
||||
"relative group rounded-xl border transition-all duration-200 flex items-center p-3 gap-3 w-full cursor-pointer";
|
||||
|
||||
if (isDebug) {
|
||||
buttonClasses +=
|
||||
" animate-pulse font-bold text-white border-2 border-dashed border-cyan-400 shadow-lg shadow-cyan-400/25 bg-gradient-to-r from-red-600 via-yellow-600 via-green-600 via-blue-600 to-purple-600";
|
||||
" animate-pulse font-bold text-white border-2 border-dashed border-cyan-400 shadow-[0_0_15px_rgba(34,211,238,0.2)] bg-gradient-to-r from-red-600 via-yellow-600 via-green-600 via-blue-600 to-purple-600";
|
||||
} else if (isActive) {
|
||||
buttonClasses +=
|
||||
" bg-gray-400 dark:bg-gray-500 border-gray-300 dark:border-gray-400 text-black dark:text-white";
|
||||
" bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.2)]";
|
||||
} else {
|
||||
buttonClasses +=
|
||||
" bg-gray-600 dark:bg-gray-700 border-gray-500 dark:border-gray-600 text-white dark:text-gray-100 hover:bg-gray-500 dark:hover:bg-gray-600";
|
||||
" bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20";
|
||||
}
|
||||
|
||||
return html`
|
||||
@@ -109,16 +100,63 @@ export class LanguageModal extends LitElement {
|
||||
>
|
||||
<img
|
||||
src="/flags/${lang.svg}.svg"
|
||||
class="w-6 h-4 object-contain"
|
||||
class="w-8 h-6 object-contain shadow-sm rounded-sm shrink-0"
|
||||
alt="${lang.code}"
|
||||
/>
|
||||
<span>${lang.native} (${lang.en})</span>
|
||||
<div class="flex flex-col items-start min-w-0">
|
||||
<span
|
||||
class="text-sm font-bold uppercase tracking-wider truncate w-full text-left ${isActive
|
||||
? "text-white"
|
||||
: "text-gray-200 group-hover:text-white"}"
|
||||
>${lang.native}</span
|
||||
>
|
||||
<span
|
||||
class="text-xs text-white/40 uppercase tracking-widest group-hover:text-white/60 transition-colors truncate w-full text-left"
|
||||
>${lang.en}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
${isActive
|
||||
? html`
|
||||
<div class="ml-auto text-blue-400 shrink-0">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm13.36-1.814a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
</button>
|
||||
`;
|
||||
})}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (this.inline) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return html`
|
||||
<o-modal
|
||||
title=${translateText("select_lang.title")}
|
||||
?inline=${this.inline}
|
||||
.onClose=${this.close.bind(this)}
|
||||
hideHeader
|
||||
hideCloseButton
|
||||
>
|
||||
${content}
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
export function initLayout() {
|
||||
const hb = document.getElementById("hamburger-btn");
|
||||
const sidebar = document.getElementById("sidebar-menu");
|
||||
const backdrop = document.getElementById("mobile-menu-backdrop");
|
||||
|
||||
// Force sidebar visibility style to ensure it's not hidden by other CSS
|
||||
if (sidebar && window.innerWidth < 768) {
|
||||
sidebar.style.display = "flex";
|
||||
}
|
||||
|
||||
if (!hb) {
|
||||
console.error("Hamburger button not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable fallback inline handler now that JS is loaded
|
||||
hb.onclick = null;
|
||||
|
||||
if (!sidebar) {
|
||||
console.error("Sidebar menu not found");
|
||||
return;
|
||||
}
|
||||
if (!backdrop) {
|
||||
console.error("Mobile menu backdrop not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const setMenuState = (open: boolean) => {
|
||||
sidebar.classList.toggle("open", open);
|
||||
backdrop.classList.toggle("open", open);
|
||||
document.documentElement.classList.toggle("overflow-hidden", open);
|
||||
hb.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
};
|
||||
|
||||
const closeMenu = () => setMenuState(false);
|
||||
const openMenu = () => setMenuState(true);
|
||||
|
||||
const toggle = (e: Event) => {
|
||||
e.stopPropagation();
|
||||
// Only prevent default if it's a touchstart to avoid ghost clicks
|
||||
if ((e as any).type === "touchstart") {
|
||||
(e as Event).preventDefault();
|
||||
}
|
||||
|
||||
const opening = !sidebar.classList.contains("open");
|
||||
if (opening) {
|
||||
openMenu();
|
||||
} else {
|
||||
closeMenu();
|
||||
}
|
||||
};
|
||||
|
||||
hb.addEventListener("click", toggle);
|
||||
|
||||
backdrop.addEventListener("click", closeMenu);
|
||||
|
||||
// Close menu when clicking a menu link or button (Mobile only)
|
||||
sidebar.addEventListener("click", (e) => {
|
||||
// On desktop, we want the menu to stay open unless explicitly toggled
|
||||
if (window.innerWidth >= 768) return;
|
||||
|
||||
// If the click happened on or inside an anchor/button/menu item, close the menu
|
||||
const clickedElement = (e.target as Element).closest
|
||||
? (e.target as Element).closest(
|
||||
'a, button, [role="menuitem"], .nav-menu-item',
|
||||
)
|
||||
: null;
|
||||
|
||||
if (clickedElement) {
|
||||
closeMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// Close on Escape (Mobile only)
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (window.innerWidth >= 768) return;
|
||||
if (e.key === "Escape" && sidebar.classList.contains("open")) {
|
||||
closeMenu();
|
||||
}
|
||||
});
|
||||
}
|
||||
+217
-52
@@ -12,10 +12,9 @@ import { userAuth } from "./Auth";
|
||||
import { joinLobby } from "./ClientGameRunner";
|
||||
import { fetchCosmetics } from "./Cosmetics";
|
||||
import { crazyGamesSDK } from "./CrazyGamesSDK";
|
||||
import "./DarkModeButton";
|
||||
import { DarkModeButton } from "./DarkModeButton";
|
||||
import "./FlagInput";
|
||||
import { FlagInput } from "./FlagInput";
|
||||
import "./FlagInputModal";
|
||||
import { FlagInputModal } from "./FlagInputModal";
|
||||
import { GameInfoModal } from "./GameInfoModal";
|
||||
import { GameStartingModal } from "./GameStartingModal";
|
||||
@@ -24,11 +23,13 @@ import { GutterAds } from "./GutterAds";
|
||||
import { HelpModal } from "./HelpModal";
|
||||
import { HostLobbyModal as HostPrivateLobbyModal } from "./HostLobbyModal";
|
||||
import { JoinPrivateLobbyModal } from "./JoinPrivateLobbyModal";
|
||||
import "./KeybindsModal";
|
||||
import "./LangSelector";
|
||||
import { LangSelector } from "./LangSelector";
|
||||
import { LanguageModal } from "./LanguageModal";
|
||||
import { initLayout } from "./Layout";
|
||||
import "./Matchmaking";
|
||||
import { MatchmakingModal } from "./Matchmaking";
|
||||
import { initNavigation } from "./Navigation";
|
||||
import "./NewsModal";
|
||||
import "./PublicLobby";
|
||||
import { PublicLobby } from "./PublicLobby";
|
||||
@@ -47,16 +48,12 @@ import { incrementGamesPlayed, isInIframe } from "./Utils";
|
||||
import "./components/baseComponents/Button";
|
||||
import "./components/baseComponents/Modal";
|
||||
import "./styles.css";
|
||||
import "./styles/components/button.css";
|
||||
import "./styles/components/controls.css";
|
||||
import "./styles/components/modal.css";
|
||||
import "./styles/components/setting.css";
|
||||
import "./styles/core/flag-animation.css";
|
||||
import "./styles/core/typography.css";
|
||||
import "./styles/core/variables.css";
|
||||
import "./styles/layout/container.css";
|
||||
import "./styles/layout/header.css";
|
||||
import "./styles/modal/chat.css";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile: any;
|
||||
@@ -83,6 +80,7 @@ declare global {
|
||||
};
|
||||
spaNewPage: (url: string) => void;
|
||||
};
|
||||
showPage?: (pageId: string) => void;
|
||||
}
|
||||
|
||||
// Extend the global interfaces to include your custom events
|
||||
@@ -108,7 +106,6 @@ class Client {
|
||||
|
||||
private usernameInput: UsernameInput | null = null;
|
||||
private flagInput: FlagInput | null = null;
|
||||
private darkModeButton: DarkModeButton | null = null;
|
||||
|
||||
private joinModal: JoinPrivateLobbyModal;
|
||||
private publicLobby: PublicLobby;
|
||||
@@ -132,39 +129,31 @@ class Client {
|
||||
// the user joins a lobby.
|
||||
this.turnstileTokenPromise = getTurnstileToken();
|
||||
|
||||
const gameVersion = document.getElementById(
|
||||
"game-version",
|
||||
) as HTMLDivElement;
|
||||
if (!gameVersion) {
|
||||
const versionElements = document.querySelectorAll(
|
||||
"#game-version, .game-version-display",
|
||||
);
|
||||
if (versionElements.length === 0) {
|
||||
console.warn("Game version element not found");
|
||||
} else {
|
||||
const trimmed = version.trim();
|
||||
const displayVersion = trimmed.startsWith("v") ? trimmed : `v${trimmed}`;
|
||||
versionElements.forEach((el) => {
|
||||
el.textContent = displayVersion;
|
||||
});
|
||||
}
|
||||
gameVersion.innerText = version;
|
||||
|
||||
const langSelector = document.querySelector(
|
||||
"lang-selector",
|
||||
) as LangSelector;
|
||||
const languageModal = document.querySelector(
|
||||
"language-modal",
|
||||
) as LanguageModal;
|
||||
if (!langSelector) {
|
||||
console.warn("Lang selector element not found");
|
||||
}
|
||||
if (!languageModal) {
|
||||
console.warn("Language modal element not found");
|
||||
}
|
||||
|
||||
this.flagInput = document.querySelector("flag-input") as FlagInput;
|
||||
if (!this.flagInput) {
|
||||
console.warn("Flag input element not found");
|
||||
}
|
||||
|
||||
this.darkModeButton = document.querySelector(
|
||||
"dark-mode-button",
|
||||
) as DarkModeButton;
|
||||
if (!this.darkModeButton) {
|
||||
console.warn("Dark mode button element not found");
|
||||
}
|
||||
|
||||
this.usernameInput = document.querySelector(
|
||||
"username-input",
|
||||
) as UsernameInput;
|
||||
@@ -206,7 +195,17 @@ class Client {
|
||||
if (singlePlayer === null) throw new Error("Missing single-player");
|
||||
singlePlayer.addEventListener("click", () => {
|
||||
if (this.usernameInput?.isValid()) {
|
||||
spModal.open();
|
||||
window.showPage?.("page-single-player");
|
||||
} else {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
detail: {
|
||||
message: this.usernameInput?.validationError,
|
||||
color: "red",
|
||||
duration: 3000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -219,10 +218,13 @@ class Client {
|
||||
console.warn("Game info modal element not found");
|
||||
}
|
||||
const helpButton = document.getElementById("help-button");
|
||||
if (helpButton === null) throw new Error("Missing help-button");
|
||||
helpButton.addEventListener("click", () => {
|
||||
hlpModal.open();
|
||||
});
|
||||
if (helpButton) {
|
||||
helpButton.addEventListener("click", () => {
|
||||
if (hlpModal && hlpModal instanceof HelpModal) {
|
||||
hlpModal.open();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const flagInputModal = document.querySelector(
|
||||
"flag-input-modal",
|
||||
@@ -231,13 +233,28 @@ class Client {
|
||||
console.warn("Flag input modal element not found");
|
||||
}
|
||||
|
||||
const flgInput = document.getElementById("flag-input_");
|
||||
if (flgInput === null) throw new Error("Missing flag-input_");
|
||||
flgInput.addEventListener("click", () => {
|
||||
flagInputModal.open();
|
||||
// Wait for the flag-input component to be fully ready
|
||||
customElements.whenDefined("flag-input").then(() => {
|
||||
// Use a small delay to ensure the component has rendered
|
||||
setTimeout(() => {
|
||||
const flagButton = document.querySelector(
|
||||
"#flag-input-component #flag-input_",
|
||||
);
|
||||
if (!flagButton) {
|
||||
console.warn("Flag button not found inside component");
|
||||
return;
|
||||
}
|
||||
flagButton.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (flagInputModal && flagInputModal instanceof FlagInputModal) {
|
||||
flagInputModal.open();
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
});
|
||||
|
||||
this.patternsModal = document.querySelector(
|
||||
this.patternsModal = document.getElementById(
|
||||
"territory-patterns-modal",
|
||||
) as TerritoryPatternsModal;
|
||||
if (
|
||||
@@ -253,6 +270,44 @@ class Client {
|
||||
patternButton.style.display = "none";
|
||||
}
|
||||
|
||||
// Move button to desktop wrapper on large screens
|
||||
const desktopWrapper = document.getElementById(
|
||||
"territory-patterns-preview-desktop-wrapper",
|
||||
);
|
||||
if (desktopWrapper && patternButton) {
|
||||
const moveButtonBasedOnScreenSize = () => {
|
||||
if (window.innerWidth >= 1024) {
|
||||
// Desktop: move to wrapper
|
||||
if (
|
||||
patternButton.parentElement?.id !==
|
||||
"territory-patterns-preview-desktop-wrapper"
|
||||
) {
|
||||
patternButton.className =
|
||||
"w-full h-[60px] border border-white/20 bg-white/5 hover:bg-white/10 active:bg-white/20 rounded-lg cursor-pointer focus:outline-none transition-all duration-200 hover:scale-105 overflow-hidden";
|
||||
patternButton.style.backgroundSize = "auto 100%";
|
||||
patternButton.style.backgroundRepeat = "repeat-x";
|
||||
desktopWrapper.appendChild(patternButton);
|
||||
}
|
||||
} else {
|
||||
// Mobile: move back to bar
|
||||
const mobileParent = document.querySelector(".lg\\:col-span-9.flex");
|
||||
if (
|
||||
mobileParent &&
|
||||
patternButton.parentElement?.id ===
|
||||
"territory-patterns-preview-desktop-wrapper"
|
||||
) {
|
||||
patternButton.className =
|
||||
"aspect-square h-[40px] sm:h-[50px] lg:hidden border border-white/20 bg-white/5 hover:bg-white/10 active:bg-white/20 rounded-lg cursor-pointer focus:outline-none transition-all duration-200 hover:scale-105 overflow-hidden shrink-0";
|
||||
patternButton.style.backgroundSize = "";
|
||||
patternButton.style.backgroundRepeat = "";
|
||||
mobileParent.appendChild(patternButton);
|
||||
}
|
||||
}
|
||||
};
|
||||
moveButtonBasedOnScreenSize();
|
||||
window.addEventListener("resize", moveButtonBasedOnScreenSize);
|
||||
}
|
||||
|
||||
if (
|
||||
!this.patternsModal ||
|
||||
!(this.patternsModal instanceof TerritoryPatternsModal)
|
||||
@@ -263,8 +318,30 @@ class Client {
|
||||
throw new Error("territory-patterns-input-preview-button");
|
||||
this.patternsModal.previewButton = patternButton;
|
||||
this.patternsModal.refresh();
|
||||
// Listen for pattern selection to update preview button
|
||||
this.patternsModal.addEventListener("pattern-selected", () => {
|
||||
this.patternsModal.refresh();
|
||||
});
|
||||
|
||||
window.addEventListener("showPage", (e: any) => {
|
||||
if (typeof e?.detail === "string" && e.detail === "page-play") {
|
||||
setTimeout(() => {
|
||||
this.patternsModal.refresh();
|
||||
}, 50);
|
||||
}
|
||||
});
|
||||
|
||||
patternButton.addEventListener("click", () => {
|
||||
this.patternsModal.open();
|
||||
window.showPage?.("page-item-store");
|
||||
const skinStoreModal = document.getElementById(
|
||||
"page-item-store",
|
||||
) as HTMLElement & { open?: (opts: any) => void };
|
||||
if (skinStoreModal) {
|
||||
skinStoreModal.classList.remove("hidden");
|
||||
if (typeof skinStoreModal.open === "function") {
|
||||
skinStoreModal.open({ showOnlyOwned: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.tokenLoginModal = document.querySelector(
|
||||
@@ -286,8 +363,50 @@ class Client {
|
||||
) {
|
||||
console.warn("Matchmaking modal element not found");
|
||||
}
|
||||
const matchmakingButton = document.getElementById("matchmaking-button");
|
||||
const matchmakingButtonLoggedOut = document.getElementById(
|
||||
"matchmaking-button-logged-out",
|
||||
);
|
||||
|
||||
const updateMatchmakingButton = (loggedIn: boolean) => {
|
||||
if (!loggedIn) {
|
||||
matchmakingButton?.classList.add("hidden");
|
||||
matchmakingButtonLoggedOut?.classList.remove("hidden");
|
||||
} else {
|
||||
matchmakingButton?.classList.remove("hidden");
|
||||
matchmakingButtonLoggedOut?.classList.add("hidden");
|
||||
}
|
||||
};
|
||||
|
||||
if (matchmakingButton) {
|
||||
matchmakingButton.addEventListener("click", () => {
|
||||
if (this.usernameInput?.isValid()) {
|
||||
window.showPage?.("page-matchmaking");
|
||||
this.publicLobby.leaveLobby();
|
||||
} else {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
detail: {
|
||||
message: this.usernameInput?.validationError,
|
||||
color: "red",
|
||||
duration: 3000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const onUserMe = async (userMeResponse: UserMeResponse | false) => {
|
||||
// Check if user has actual authentication (discord or email), not just a publicId
|
||||
const loggedIn =
|
||||
userMeResponse !== false &&
|
||||
userMeResponse !== null &&
|
||||
typeof userMeResponse === "object" &&
|
||||
userMeResponse.user &&
|
||||
(userMeResponse.user.discord !== undefined ||
|
||||
userMeResponse.user.email !== undefined);
|
||||
updateMatchmakingButton(loggedIn);
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("userMeResponse", {
|
||||
detail: userMeResponse,
|
||||
@@ -323,7 +442,9 @@ class Client {
|
||||
document
|
||||
.getElementById("settings-button")
|
||||
?.addEventListener("click", () => {
|
||||
settingsModal.open();
|
||||
if (settingsModal && settingsModal instanceof UserSettingModal) {
|
||||
settingsModal.open();
|
||||
}
|
||||
});
|
||||
|
||||
const hostModal = document.querySelector(
|
||||
@@ -336,8 +457,18 @@ class Client {
|
||||
if (hostLobbyButton === null) throw new Error("Missing host-lobby-button");
|
||||
hostLobbyButton.addEventListener("click", () => {
|
||||
if (this.usernameInput?.isValid()) {
|
||||
hostModal.open();
|
||||
window.showPage?.("page-host-lobby");
|
||||
this.publicLobby.leaveLobby();
|
||||
} else {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
detail: {
|
||||
message: this.usernameInput?.validationError,
|
||||
color: "red",
|
||||
duration: 3000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -354,7 +485,17 @@ class Client {
|
||||
throw new Error("Missing join-private-lobby-button");
|
||||
joinPrivateLobbyButton.addEventListener("click", () => {
|
||||
if (this.usernameInput?.isValid()) {
|
||||
this.joinModal.open();
|
||||
window.showPage?.("page-join-private-lobby");
|
||||
} else {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
detail: {
|
||||
message: this.usernameInput?.validationError,
|
||||
color: "red",
|
||||
duration: 3000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -367,9 +508,17 @@ class Client {
|
||||
// Attempt to join lobby
|
||||
this.handleUrl();
|
||||
|
||||
let preventHashUpdate = false;
|
||||
|
||||
const onHashUpdate = () => {
|
||||
// Prevent double-handling when both popstate and hashchange fire
|
||||
if (preventHashUpdate) {
|
||||
preventHashUpdate = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset the UI to its initial state
|
||||
this.joinModal.close();
|
||||
this.joinModal?.close();
|
||||
if (this.gameStop !== null) {
|
||||
this.handleLeaveLobby();
|
||||
}
|
||||
@@ -379,7 +528,10 @@ class Client {
|
||||
};
|
||||
|
||||
// Handle browser navigation & manual hash edits
|
||||
window.addEventListener("popstate", onHashUpdate);
|
||||
window.addEventListener("popstate", () => {
|
||||
preventHashUpdate = true;
|
||||
onHashUpdate();
|
||||
});
|
||||
window.addEventListener("hashchange", onHashUpdate);
|
||||
|
||||
function updateSliderProgress(slider: HTMLInputElement) {
|
||||
@@ -407,7 +559,8 @@ class Client {
|
||||
if (crazyGamesSDK.isOnCrazyGames()) {
|
||||
const lobbyId = crazyGamesSDK.getInviteGameId();
|
||||
if (lobbyId && ID.safeParse(lobbyId).success) {
|
||||
this.joinModal.open(lobbyId);
|
||||
window.showPage?.("page-join-private-lobby");
|
||||
this.joinModal?.open(lobbyId);
|
||||
console.log(`CrazyGames: joining lobby ${lobbyId} from invite param`);
|
||||
return;
|
||||
}
|
||||
@@ -485,7 +638,8 @@ class Client {
|
||||
if (decodedHash.startsWith("#join=")) {
|
||||
const lobbyId = decodedHash.substring(6); // Remove "#join="
|
||||
if (lobbyId && ID.safeParse(lobbyId).success) {
|
||||
this.joinModal.open(lobbyId);
|
||||
window.showPage?.("page-join-private-lobby");
|
||||
this.joinModal?.open(lobbyId);
|
||||
console.log(`joining lobby ${lobbyId}`);
|
||||
}
|
||||
}
|
||||
@@ -493,7 +647,7 @@ class Client {
|
||||
const affiliateCode = decodedHash.replace("#affiliate=", "");
|
||||
strip();
|
||||
if (affiliateCode) {
|
||||
this.patternsModal.open(affiliateCode);
|
||||
this.patternsModal?.open(affiliateCode);
|
||||
}
|
||||
}
|
||||
if (decodedHash.startsWith("#refresh")) {
|
||||
@@ -507,6 +661,7 @@ class Client {
|
||||
if (this.gameStop !== null) {
|
||||
console.log("joining lobby, stopping existing game");
|
||||
this.gameStop();
|
||||
document.body.classList.remove("in-game");
|
||||
}
|
||||
const config = await getServerConfigFromClient();
|
||||
|
||||
@@ -549,16 +704,15 @@ class Client {
|
||||
"host-lobby-modal",
|
||||
"join-private-lobby-modal",
|
||||
"game-starting-modal",
|
||||
"game-top-bar",
|
||||
"help-modal",
|
||||
"user-setting",
|
||||
|
||||
"territory-patterns-modal",
|
||||
"language-modal",
|
||||
"news-modal",
|
||||
"flag-input-modal",
|
||||
"account-button",
|
||||
"stats-button",
|
||||
"token-login",
|
||||
|
||||
"matchmaking-modal",
|
||||
"lang-selector",
|
||||
].forEach((tag) => {
|
||||
@@ -589,7 +743,7 @@ class Client {
|
||||
this.gutterAds.hide();
|
||||
},
|
||||
() => {
|
||||
this.joinModal.close();
|
||||
this.joinModal?.close();
|
||||
this.publicLobby.stop();
|
||||
incrementGamesPlayed();
|
||||
|
||||
@@ -599,6 +753,7 @@ class Client {
|
||||
|
||||
crazyGamesSDK.loadingStop();
|
||||
crazyGamesSDK.gameplayStart();
|
||||
document.body.classList.add("in-game");
|
||||
|
||||
// Ensure there's a homepage entry in history before adding the lobby entry
|
||||
if (window.location.hash === "" || window.location.hash === "#") {
|
||||
@@ -617,6 +772,8 @@ class Client {
|
||||
this.gameStop();
|
||||
this.gameStop = null;
|
||||
|
||||
document.body.classList.remove("in-game");
|
||||
|
||||
crazyGamesSDK.gameplayStop();
|
||||
|
||||
this.gutterAds.hide();
|
||||
@@ -699,9 +856,17 @@ class Client {
|
||||
}
|
||||
|
||||
// Initialize the client when the DOM is loaded
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const bootstrap = () => {
|
||||
initLayout();
|
||||
new Client().initialize();
|
||||
});
|
||||
initNavigation();
|
||||
};
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", bootstrap);
|
||||
} else {
|
||||
bootstrap();
|
||||
}
|
||||
|
||||
async function getTurnstileToken(): Promise<{
|
||||
token: string;
|
||||
|
||||
+127
-36
@@ -1,31 +1,27 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import { UserMeResponse } from "src/core/ApiSchemas";
|
||||
import { UserMeResponse } from "../core/ApiSchemas";
|
||||
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
|
||||
import { generateID } from "../core/Util";
|
||||
import { getUserMe } from "./Api";
|
||||
import { getPlayToken } from "./Auth";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import "./components/Difficulties";
|
||||
import "./components/PatternButton";
|
||||
import { JoinLobbyEvent } from "./Main";
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
@customElement("matchmaking-modal")
|
||||
export class MatchmakingModal extends LitElement {
|
||||
export class MatchmakingModal extends BaseModal {
|
||||
private gameCheckInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private connected = false;
|
||||
private elo = "unknown";
|
||||
@state() private connected = false;
|
||||
@state() private socket: WebSocket | null = null;
|
||||
|
||||
@state() private gameID: string | null = null;
|
||||
@query("o-modal") private modalEl!: HTMLElement & {
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
onClose?: () => void;
|
||||
isModalOpen: boolean;
|
||||
};
|
||||
private elo = "unknown";
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.id = "page-matchmaking";
|
||||
document.addEventListener("userMeResponse", (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
if (customEvent.detail) {
|
||||
@@ -43,33 +39,106 @@ export class MatchmakingModal extends LitElement {
|
||||
}
|
||||
|
||||
render() {
|
||||
const eloDisplay = html`
|
||||
<p class="text-center mt-2 mb-4 text-white/60">
|
||||
${translateText("matchmaking_modal.elo", { elo: this.elo })}
|
||||
</p>
|
||||
`;
|
||||
|
||||
const content = html`
|
||||
<div
|
||||
class="h-full flex flex-col ${this.inline
|
||||
? "bg-black/40 backdrop-blur-md rounded-2xl border border-white/10"
|
||||
: ""}"
|
||||
>
|
||||
<div
|
||||
class="flex items-center mb-4 pb-2 border-b border-white/10 gap-2 shrink-0 p-6"
|
||||
>
|
||||
<div class="flex items-center gap-4 flex-1">
|
||||
<button
|
||||
@click=${this.close}
|
||||
class="group flex items-center justify-center w-10 h-10 rounded-full bg-white/5 hover:bg-white/10 transition-all border border-white/10"
|
||||
aria-label="${translateText("common.back")}"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-5 h-5 text-gray-400 group-hover:text-white transition-colors"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<span
|
||||
class="text-white text-xl sm:text-2xl md:text-3xl font-bold uppercase tracking-widest break-words hyphens-auto"
|
||||
>
|
||||
${translateText("matchmaking_modal.title")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col items-center justify-center gap-6 p-6">
|
||||
${eloDisplay} ${this.renderInner()}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (this.inline) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return html`
|
||||
<o-modal
|
||||
id="matchmaking-modal"
|
||||
title="${translateText("matchmaking_modal.title")}"
|
||||
hideCloseButton
|
||||
hideHeader
|
||||
>
|
||||
<p class="text-center mt-4 mb-8">
|
||||
${translateText("matchmaking_modal.elo", { elo: this.elo })}
|
||||
</p>
|
||||
${this.renderInner()}
|
||||
${content}
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderInner() {
|
||||
if (!this.connected) {
|
||||
return html`<p class="text-center">
|
||||
${translateText("matchmaking_modal.connecting")}
|
||||
</p>`;
|
||||
return html`
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div
|
||||
class="w-12 h-12 border-4 border-blue-500/30 border-t-blue-500 rounded-full animate-spin"
|
||||
></div>
|
||||
<p class="text-center text-white/80">
|
||||
${translateText("matchmaking_modal.connecting")}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
if (this.gameID === null) {
|
||||
return html`<p class="text-center">
|
||||
${translateText("matchmaking_modal.searching")}
|
||||
</p>`;
|
||||
return html`
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div
|
||||
class="w-12 h-12 border-4 border-green-500/30 border-t-green-500 rounded-full animate-spin"
|
||||
></div>
|
||||
<p class="text-center text-white/80">
|
||||
${translateText("matchmaking_modal.searching")}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
return html`<p class="text-center">
|
||||
${translateText("matchmaking_modal.waiting_for_game")}
|
||||
</p>`;
|
||||
return html`
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div
|
||||
class="w-12 h-12 border-4 border-yellow-500/30 border-t-yellow-500 rounded-full animate-spin"
|
||||
></div>
|
||||
<p class="text-center text-white/80">
|
||||
${translateText("matchmaking_modal.waiting_for_game")}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,29 +173,51 @@ export class MatchmakingModal extends LitElement {
|
||||
this.socket.onerror = (event: ErrorEvent) => {
|
||||
console.error("WebSocket error occurred:", event);
|
||||
};
|
||||
this.socket.onclose = (event) => {
|
||||
this.socket.onclose = () => {
|
||||
console.log("Matchmaking server closed connection");
|
||||
};
|
||||
}
|
||||
|
||||
public close() {
|
||||
protected async onOpen(): Promise<void> {
|
||||
const userMe = await getUserMe();
|
||||
|
||||
// Early return if modal was closed during async operation
|
||||
if (!this.isModalOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isLoggedIn =
|
||||
userMe &&
|
||||
userMe.user &&
|
||||
(userMe.user.discord !== undefined || userMe.user.email !== undefined);
|
||||
if (!isLoggedIn) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
detail: {
|
||||
message: translateText("matchmaking_button.must_login"),
|
||||
color: "red",
|
||||
duration: 3000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
this.connected = false;
|
||||
this.gameID = null;
|
||||
this.connect();
|
||||
this.gameCheckInterval = setInterval(() => this.checkGame(), 3000);
|
||||
}
|
||||
|
||||
protected onClose(): void {
|
||||
this.connected = false;
|
||||
this.socket?.close();
|
||||
this.modalEl?.close();
|
||||
if (this.gameCheckInterval) {
|
||||
clearInterval(this.gameCheckInterval);
|
||||
this.gameCheckInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
public async open() {
|
||||
this.modalEl!.onClose = () => this.close();
|
||||
this.modalEl?.open();
|
||||
this.requestUpdate();
|
||||
this.connect();
|
||||
this.gameCheckInterval = setInterval(() => this.checkGame(), 3000);
|
||||
}
|
||||
|
||||
private async checkGame() {
|
||||
if (this.gameID === null) {
|
||||
return;
|
||||
@@ -171,7 +262,7 @@ export class MatchmakingModal extends LitElement {
|
||||
|
||||
@customElement("matchmaking-button")
|
||||
export class MatchmakingButton extends LitElement {
|
||||
@query("matchmaking-modal") private matchmakingModal: MatchmakingModal;
|
||||
@query("matchmaking-modal") private matchmakingModal?: MatchmakingModal;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
export function initNavigation() {
|
||||
const showPage = (pageId: string) => {
|
||||
// Hide all pages
|
||||
document.querySelectorAll(".page-content").forEach((el) => {
|
||||
el.classList.add("hidden");
|
||||
el.classList.remove("block");
|
||||
});
|
||||
document.getElementById("page-play")?.classList.add("hidden");
|
||||
|
||||
const target = document.getElementById(pageId);
|
||||
if (target) {
|
||||
target.classList.remove("hidden");
|
||||
// Modals need block display explicitly
|
||||
if (target.classList.contains("page-content")) {
|
||||
target.classList.add("block");
|
||||
}
|
||||
|
||||
// If the target itself is a modal component with inline attribute, open it
|
||||
if (
|
||||
target.hasAttribute("inline") &&
|
||||
typeof (target as any).open === "function"
|
||||
) {
|
||||
(target as any).open();
|
||||
}
|
||||
}
|
||||
|
||||
// Update active state on menu items
|
||||
document.querySelectorAll(".nav-menu-item").forEach((item) => {
|
||||
if ((item as HTMLElement).dataset.page === pageId) {
|
||||
item.classList.add("active");
|
||||
} else {
|
||||
item.classList.remove("active");
|
||||
}
|
||||
});
|
||||
|
||||
// Dispatch CustomEvent to notify listeners of page change
|
||||
window.dispatchEvent(new CustomEvent("showPage", { detail: pageId }));
|
||||
};
|
||||
|
||||
window.showPage = showPage;
|
||||
|
||||
document.querySelectorAll(".nav-menu-item[data-page]").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
const pageId = (el as HTMLElement).dataset.page;
|
||||
if (pageId) showPage(pageId);
|
||||
});
|
||||
});
|
||||
|
||||
// Handle clicks on main container to close open modals (navigate back)
|
||||
const mainEl = document.querySelector("main");
|
||||
if (mainEl) {
|
||||
mainEl.addEventListener("click", (e: Event) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const isPlayPageHidden = document
|
||||
.getElementById("page-play")
|
||||
?.classList.contains("hidden");
|
||||
|
||||
// Only proceed if we are NOT on the play page (meaning a modal page is open)
|
||||
if (isPlayPageHidden) {
|
||||
// If clicking on the main container directly (e.g. padding/background)
|
||||
// or the max-width wrapper div directly
|
||||
const wrapper = mainEl.firstElementChild as HTMLElement;
|
||||
if (target === mainEl || (wrapper && target === wrapper)) {
|
||||
showPage("page-play");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Set default active if not set
|
||||
const initialPage = document.querySelector(
|
||||
'.nav-menu-item[data-page="page-play"]',
|
||||
);
|
||||
if (initialPage && !initialPage.classList.contains("active")) {
|
||||
showPage("page-play");
|
||||
}
|
||||
}
|
||||
+83
-107
@@ -1,114 +1,102 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { resolveMarkdown } from "lit-markdown";
|
||||
import { customElement, property, query } from "lit/decorators.js";
|
||||
import version from "resources/version.txt?raw";
|
||||
import { translateText } from "../client/Utils";
|
||||
import "./components/baseComponents/Button";
|
||||
import "./components/baseComponents/Modal";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import changelog from "/changelog.md?url";
|
||||
import megaphone from "/images/Megaphone.svg?url";
|
||||
|
||||
@customElement("news-modal")
|
||||
export class NewsModal extends LitElement {
|
||||
@query("o-modal") private modalEl!: HTMLElement & {
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("keydown", this.handleKeyDown);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
window.removeEventListener("keydown", this.handleKeyDown);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
private handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.code === "Escape") {
|
||||
e.preventDefault();
|
||||
this.close();
|
||||
}
|
||||
};
|
||||
|
||||
export class NewsModal extends BaseModal {
|
||||
@property({ type: String }) markdown = "Loading...";
|
||||
|
||||
private initialized: boolean = false;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.news-container {
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.news-content {
|
||||
color: #ddd;
|
||||
line-height: 1.5;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.news-content a {
|
||||
color: #4a9eff !important;
|
||||
text-decoration: underline !important;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.news-content a:hover {
|
||||
color: #6fb3ff !important;
|
||||
}
|
||||
`;
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<o-modal title=${translateText("news.title")}>
|
||||
<div class="options-layout">
|
||||
<div class="options-section">
|
||||
<div class="news-container">
|
||||
<div class="news-content">
|
||||
${resolveMarkdown(this.markdown, {
|
||||
includeImages: true,
|
||||
includeCodeBlockClassNames: true,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
const content = html`
|
||||
<div
|
||||
class="h-full flex flex-col ${this.inline
|
||||
? "bg-black/40 backdrop-blur-md rounded-2xl border border-white/10"
|
||||
: ""}"
|
||||
>
|
||||
<div
|
||||
class="flex items-center mb-4 pb-2 border-b border-white/10 gap-2 shrink-0 p-6"
|
||||
>
|
||||
<div class="flex items-center gap-4 flex-1">
|
||||
<button
|
||||
@click=${this.close}
|
||||
class="group flex items-center justify-center w-10 h-10 rounded-full bg-white/5 hover:bg-white/10 transition-all border border-white/10"
|
||||
aria-label="${translateText("common.back")}"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-5 h-5 text-gray-400 group-hover:text-white transition-colors"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<span
|
||||
class="text-white text-xl sm:text-2xl md:text-3xl font-bold uppercase tracking-widest break-words hyphens-auto"
|
||||
>
|
||||
${translateText("news.title")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
${translateText("news.see_all_releases")}
|
||||
<a
|
||||
href="https://github.com/openfrontio/OpenFrontIO/releases"
|
||||
target="_blank"
|
||||
>${translateText("news.github_link")}</a
|
||||
>.
|
||||
<div
|
||||
class="prose prose-invert prose-sm max-w-none overflow-y-auto px-6 pb-6 mr-1
|
||||
[&_a]:text-blue-400 [&_a:hover]:text-blue-300 transition-colors
|
||||
[&_h1]:text-2xl [&_h1]:font-bold [&_h1]:mb-4 [&_h1]:text-white [&_h1]:border-b [&_h1]:border-white/10 [&_h1]:pb-2
|
||||
[&_h2]:text-xl [&_h2]:font-bold [&_h2]:mt-6 [&_h2]:mb-3 [&_h2]:text-blue-200
|
||||
[&_h3]:text-lg [&_h3]:font-semibold [&_h3]:mt-4 [&_h3]:mb-2 [&_h3]:text-blue-100
|
||||
[&_ul]:pl-5 [&_ul]:list-disc [&_ul]:space-y-1
|
||||
[&_li]:text-gray-300 [&_li]:leading-relaxed
|
||||
[&_p]:text-gray-300 [&_p]:mb-3 [&_strong]:text-white [&_strong]:font-bold
|
||||
scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent"
|
||||
>
|
||||
${resolveMarkdown(this.markdown, {
|
||||
includeImages: true,
|
||||
includeCodeBlockClassNames: true,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
<o-button
|
||||
title=${translateText("common.close")}
|
||||
@click=${this.close}
|
||||
blockDesktop
|
||||
></o-button>
|
||||
if (this.inline) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return html`
|
||||
<o-modal
|
||||
title=${translateText("news.title")}
|
||||
?inline=${this.inline}
|
||||
hideCloseButton
|
||||
hideHeader
|
||||
>
|
||||
${content}
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
public open() {
|
||||
protected onOpen(): void {
|
||||
if (!this.initialized) {
|
||||
this.initialized = true;
|
||||
fetch(changelog)
|
||||
.then((response) => (response.ok ? response.text() : "Failed to load"))
|
||||
.then((markdown) =>
|
||||
markdown
|
||||
// Convert bold header lines (e.g. "**Title**") into real Markdown headers
|
||||
// Exclude lines starting with - or * to avoid converting bullet points
|
||||
.replace(/^([^\-*\s].*?) \*\*(.+?)\*\*$/gm, "## $1 $2")
|
||||
.replace(
|
||||
/(?<!\()\bhttps:\/\/github\.com\/openfrontio\/OpenFrontIO\/pull\/(\d+)\b/g,
|
||||
(_match, prNumber) =>
|
||||
@@ -122,12 +110,6 @@ export class NewsModal extends LitElement {
|
||||
)
|
||||
.then((markdown) => (this.markdown = markdown));
|
||||
}
|
||||
this.requestUpdate();
|
||||
this.modalEl?.open();
|
||||
}
|
||||
|
||||
private close() {
|
||||
this.modalEl?.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,35 +126,29 @@ export class NewsButton extends LitElement {
|
||||
const lastSeenVersion = localStorage.getItem("last-seen-version");
|
||||
if (lastSeenVersion !== null && lastSeenVersion !== version) {
|
||||
setTimeout(() => {
|
||||
this.openNewsModel();
|
||||
this.open();
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
private openNewsModel() {
|
||||
public open() {
|
||||
localStorage.setItem("last-seen-version", version);
|
||||
this.newsModal.open();
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="flex relative">
|
||||
<button
|
||||
class="border p-1 rounded-lg flex cursor-pointer border-black/30 dark:border-gray-300/60 bg-white/70 dark:bg-[rgba(55,65,81,0.7)]"
|
||||
@click=${this.openNewsModel}
|
||||
>
|
||||
<img
|
||||
class="size-12 dark:invert"
|
||||
src="${megaphone}"
|
||||
alt=${translateText("news.title")}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="border p-[4px] rounded-lg flex cursor-pointer border-black/30 dark:border-gray-300/60 bg-white/70 dark:bg-[rgba(55,65,81,0.7)] hidden"
|
||||
@click=${this.open}
|
||||
>
|
||||
<img
|
||||
class="size-[48px] dark:invert"
|
||||
src="${megaphone}"
|
||||
alt=${translateText("news.title")}
|
||||
/>
|
||||
</button>
|
||||
<news-modal></news-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
+119
-62
@@ -5,7 +5,6 @@ import {
|
||||
Duos,
|
||||
GameMapType,
|
||||
GameMode,
|
||||
hasUnusualThumbnailSize,
|
||||
HumansVsNations,
|
||||
PublicGameModifiers,
|
||||
Quads,
|
||||
@@ -120,78 +119,117 @@ export class PublicLobby extends LitElement {
|
||||
);
|
||||
|
||||
const mapImageSrc = this.mapImages.get(lobby.gameID);
|
||||
const isUnusualThumbnailSize = hasUnusualThumbnailSize(
|
||||
lobby.gameConfig.gameMap,
|
||||
);
|
||||
|
||||
return html`
|
||||
<button
|
||||
@click=${() => this.lobbyClicked(lobby)}
|
||||
?disabled=${this.isButtonDebounced}
|
||||
class="isolate grid h-40 grid-cols-[100%] grid-rows-[100%] place-content-stretch w-full overflow-hidden ${this
|
||||
class="group relative isolate flex flex-col w-full h-80 lg:h-96 overflow-hidden rounded-2xl transition-all duration-300 ${this
|
||||
.isLobbyHighlighted
|
||||
? "bg-linear-to-r via-none from-green-600 to-green-500"
|
||||
: "bg-linear-to-r via-none from-blue-600 to-blue-500"} text-white font-medium rounded-xl transition-opacity duration-200 hover:opacity-90 ${this
|
||||
.isButtonDebounced
|
||||
? "ring-2 ring-blue-600 scale-[1.01] opacity-70"
|
||||
: "hover:scale-[1.01] hover:border-white/30"} ${this.isButtonDebounced
|
||||
? "opacity-70 cursor-not-allowed"
|
||||
: ""}"
|
||||
>
|
||||
${mapImageSrc
|
||||
? html`<img
|
||||
src="${mapImageSrc}"
|
||||
alt="${lobby.gameConfig.gameMap}"
|
||||
class="place-self-start col-span-full row-span-full h-full -z-10 mask-[linear-gradient(to_left,transparent,#fff)] ${isUnusualThumbnailSize
|
||||
? "object-cover object-center"
|
||||
: ""}"
|
||||
/>`
|
||||
: html`<div
|
||||
class="place-self-start col-span-full row-span-full h-full -z-10 bg-gray-300"
|
||||
></div>`}
|
||||
<div
|
||||
class="flex flex-col justify-between h-full col-span-full row-span-full p-4 md:p-6 text-right z-0"
|
||||
>
|
||||
<div>
|
||||
<div class="text-lg md:text-2xl font-semibold">
|
||||
${this.currLobby
|
||||
? isStarting
|
||||
? html`${translateText("public_lobby.starting_game")}`
|
||||
: html`${translateText("public_lobby.waiting_for_players")}
|
||||
${[0, 1, 2]
|
||||
.map((i) => (i === this.joiningDotIndex ? "•" : "·"))
|
||||
.join("")}`
|
||||
: translateText("public_lobby.join")}
|
||||
</div>
|
||||
<div
|
||||
class="text-md font-medium text-white-400 flex flex-wrap justify-end items-center gap-1"
|
||||
>
|
||||
<span
|
||||
class="text-sm whitespace-nowrap ${this.isLobbyHighlighted
|
||||
? "text-green-600"
|
||||
: "text-blue-600"} bg-white rounded-xs px-1"
|
||||
>${fullModeLabel}</span
|
||||
>
|
||||
${modifierLabel.map(
|
||||
(label) =>
|
||||
html`<span
|
||||
class="text-sm whitespace-nowrap ${this.isLobbyHighlighted
|
||||
? "text-green-600"
|
||||
: "text-blue-600"} bg-white rounded-xs px-1"
|
||||
>${label}</span
|
||||
>`,
|
||||
)}
|
||||
<span class="whitespace-nowrap"
|
||||
>${translateText(
|
||||
`map.${lobby.gameConfig.gameMap.toLowerCase().replace(/[\s.]+/g, "")}`,
|
||||
)}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Map Image Area -->
|
||||
<div class="flex-1 w-full relative overflow-hidden bg-blue-500/85">
|
||||
${mapImageSrc
|
||||
? html`<img
|
||||
src="${mapImageSrc}"
|
||||
alt="${lobby.gameConfig.gameMap}"
|
||||
class="w-full h-full object-cover filter drop-shadow-2xl"
|
||||
/>`
|
||||
: html`<div class="w-full h-full bg-gray-800 rounded-lg"></div>`}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-md font-medium text-blue-100">
|
||||
${lobby.numClients} / ${lobby.gameConfig.maxPlayers}
|
||||
<!-- Content Banner -->
|
||||
<div
|
||||
class="relative w-full p-5 flex flex-col gap-1 text-left z-10 bg-slate-900/95 backdrop-blur-xl border-t border-white/10"
|
||||
>
|
||||
<div class="flex justify-between items-end w-full">
|
||||
<div class="flex flex-col gap-1">
|
||||
<!-- Header: Status or Join -->
|
||||
<div
|
||||
class="text-sm font-bold uppercase tracking-widest text-blue-400 mb-1"
|
||||
>
|
||||
${this.currLobby
|
||||
? isStarting
|
||||
? html`<span class="text-green-400 animate-pulse"
|
||||
>${translateText("public_lobby.starting_game")}</span
|
||||
>`
|
||||
: html`${translateText("public_lobby.waiting_for_players")}
|
||||
${[0, 1, 2]
|
||||
.map((i) => (i === this.joiningDotIndex ? "•" : "·"))
|
||||
.join("")}`
|
||||
: html`<span
|
||||
class="group-hover:text-blue-300 transition-colors"
|
||||
>${translateText("public_lobby.join")}</span
|
||||
>`}
|
||||
</div>
|
||||
|
||||
<!-- Map Name & Mode -->
|
||||
<div
|
||||
class="text-3xl font-black text-white leading-none tracking-tight"
|
||||
>
|
||||
${translateText(
|
||||
`map.${lobby.gameConfig.gameMap.toLowerCase().replace(/[\s.]+/g, "")}`,
|
||||
)}
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 mt-2">
|
||||
${fullModeLabel
|
||||
? html`<span
|
||||
class="px-2 py-1 rounded text-xs font-bold uppercase tracking-wider ${this
|
||||
.isLobbyHighlighted
|
||||
? "bg-green-500/20 text-green-300 border border-green-500/30"
|
||||
: "bg-white/10 text-white border border-white/10"} backdrop-blur-sm"
|
||||
>
|
||||
${fullModeLabel}
|
||||
</span>`
|
||||
: ""}
|
||||
${modifierLabel.map(
|
||||
(label) =>
|
||||
html`<span
|
||||
class="px-2 py-1 rounded text-xs font-bold uppercase tracking-wider ${this
|
||||
.isLobbyHighlighted
|
||||
? "bg-green-500/20 text-green-300 border border-green-500/30"
|
||||
: "bg-white/10 text-white border border-white/10"} backdrop-blur-sm"
|
||||
>
|
||||
${label}
|
||||
</span>`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Player Count & Time -->
|
||||
<div class="flex flex-col items-end gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-2xl font-bold text-white"
|
||||
>${lobby.numClients}/${lobby.gameConfig.maxPlayers}</span
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 text-white/50"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.973 0 004 15v3H1v-3a3 3 0 013.75-2.906z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
${timeRemaining > 0
|
||||
? html`
|
||||
<div
|
||||
class="text-sm font-mono font-medium text-blue-200 bg-blue-500/20 px-2 py-0.5 rounded border border-blue-500/30"
|
||||
>
|
||||
${timeDisplay}
|
||||
</div>
|
||||
`
|
||||
: html`<div
|
||||
class="text-sm font-bold text-green-200 bg-green-500/20 border border-green-500/30 px-2 py-0.5 rounded uppercase tracking-wider"
|
||||
>
|
||||
${translateText("public_lobby.started")}
|
||||
</div>`}
|
||||
</div>
|
||||
<div class="text-md font-medium text-blue-100">${timeDisplay}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -335,6 +373,25 @@ export class PublicLobby extends LitElement {
|
||||
}, this.debounceDelay);
|
||||
|
||||
if (this.currLobby === null) {
|
||||
// Validate username only when joining a new lobby
|
||||
const usernameInput = document.querySelector("username-input") as any;
|
||||
if (
|
||||
usernameInput &&
|
||||
typeof usernameInput.isValid === "function" &&
|
||||
!usernameInput.isValid()
|
||||
) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
detail: {
|
||||
message: usernameInput.validationError,
|
||||
color: "red",
|
||||
duration: 3000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLobbyHighlighted = true;
|
||||
this.currLobby = lobby;
|
||||
this.startJoiningAnimation();
|
||||
|
||||
+622
-384
File diff suppressed because it is too large
Load Diff
+313
-125
@@ -1,41 +1,75 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import { html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import {
|
||||
ClanLeaderboardEntry,
|
||||
ClanLeaderboardResponse,
|
||||
ClanLeaderboardResponseSchema,
|
||||
} from "../core/ApiSchemas";
|
||||
import { getApiBase } from "./Api";
|
||||
import { translateText } from "./Utils";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
|
||||
@customElement("stats-modal")
|
||||
export class StatsModal extends LitElement {
|
||||
@query("o-modal")
|
||||
private modalEl!: HTMLElement & {
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
export class StatsModal extends BaseModal {
|
||||
@state() private isLoading: boolean = false;
|
||||
@state() private error: string | null = null;
|
||||
@state() private data: ClanLeaderboardResponse | null = null;
|
||||
@state() private sortBy: "rank" | "games" | "wins" | "losses" | "ratio" =
|
||||
"rank";
|
||||
@state() private sortOrder: "asc" | "desc" = "asc";
|
||||
|
||||
private hasLoaded = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
private handleSort(column: "rank" | "games" | "wins" | "losses" | "ratio") {
|
||||
if (this.sortBy === column) {
|
||||
this.sortOrder = this.sortOrder === "asc" ? "desc" : "asc";
|
||||
} else {
|
||||
this.sortBy = column;
|
||||
this.sortOrder = column === "rank" ? "asc" : "desc";
|
||||
}
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
public open() {
|
||||
this.modalEl?.open();
|
||||
private getSortedClans(clans: ClanLeaderboardEntry[]) {
|
||||
const sorted = [...clans];
|
||||
sorted.sort((a, b) => {
|
||||
let aVal: number, bVal: number;
|
||||
|
||||
switch (this.sortBy) {
|
||||
case "games":
|
||||
aVal = a.games;
|
||||
bVal = b.games;
|
||||
break;
|
||||
case "wins":
|
||||
aVal = a.weightedWins;
|
||||
bVal = b.weightedWins;
|
||||
break;
|
||||
case "losses":
|
||||
aVal = a.weightedLosses;
|
||||
bVal = b.weightedLosses;
|
||||
break;
|
||||
case "ratio":
|
||||
aVal = a.weightedWLRatio;
|
||||
bVal = b.weightedWLRatio;
|
||||
break;
|
||||
case "rank":
|
||||
default:
|
||||
// Original order
|
||||
return 0;
|
||||
}
|
||||
|
||||
return this.sortOrder === "asc" ? aVal - bVal : bVal - aVal;
|
||||
});
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
protected onOpen(): void {
|
||||
if (!this.hasLoaded && !this.isLoading) {
|
||||
void this.loadLeaderboard();
|
||||
}
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.modalEl?.close();
|
||||
}
|
||||
|
||||
private async loadLeaderboard() {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
@@ -75,26 +109,52 @@ export class StatsModal extends LitElement {
|
||||
private renderBody() {
|
||||
if (this.isLoading) {
|
||||
return html`
|
||||
<div class="flex flex-col items-center justify-center p-6 text-white">
|
||||
<p class="mb-2 text-lg font-semibold">
|
||||
<div
|
||||
class="flex flex-col items-center justify-center p-12 text-white h-full"
|
||||
>
|
||||
<div
|
||||
class="w-12 h-12 border-4 border-blue-500/30 border-t-blue-500 rounded-full animate-spin mb-6"
|
||||
></div>
|
||||
<p
|
||||
class="text-blue-200/80 text-sm font-bold tracking-[0.2em] uppercase"
|
||||
>
|
||||
${translateText("stats_modal.loading")}
|
||||
</p>
|
||||
<div
|
||||
class="w-6 h-6 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (this.error) {
|
||||
return html`
|
||||
<div class="flex flex-col items-center justify-center p-6 text-white">
|
||||
<p class="mb-4 text-center">${this.error}</p>
|
||||
<div
|
||||
class="flex flex-col items-center justify-center p-12 text-white h-full"
|
||||
>
|
||||
<div
|
||||
class="bg-red-500/10 p-6 rounded-full mb-6 border border-red-500/20 shadow-[0_0_30px_rgba(239,68,68,0.2)]"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-12 w-12 text-red-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="mb-8 text-center text-red-100/80 max-w-xs font-medium">
|
||||
${this.error}
|
||||
</p>
|
||||
<button
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-sm text-sm font-medium"
|
||||
class="px-8 py-3 bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 hover:border-red-500/50 text-red-200 rounded-xl text-sm font-bold uppercase tracking-wider transition-all cursor-pointer hover:shadow-lg hover:shadow-red-500/10 active:scale-95"
|
||||
@click=${() => this.loadLeaderboard()}
|
||||
>
|
||||
Retry
|
||||
${translateText("stats_modal.try_again")}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
@@ -102,85 +162,195 @@ export class StatsModal extends LitElement {
|
||||
|
||||
if (!this.data || this.data.clans.length === 0) {
|
||||
return html`
|
||||
<div class="p-6 text-center text-gray-200">
|
||||
<p class="text-lg font-semibold mb-2">
|
||||
<div
|
||||
class="p-12 text-center text-white/40 flex flex-col items-center h-full justify-center"
|
||||
>
|
||||
<div class="bg-white/5 p-6 rounded-full mb-6 border border-white/5">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-16 w-16 text-white/20"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1"
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-white/60 mb-2">
|
||||
${translateText("stats_modal.no_data_yet")}
|
||||
</h3>
|
||||
<p class="text-white/30 text-sm max-w-[200px]">
|
||||
${translateText("stats_modal.no_stats")}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const { start, end, clans } = this.data;
|
||||
const startDate = new Date(start);
|
||||
const endDate = new Date(end);
|
||||
const { clans } = this.data;
|
||||
const maxGames = Math.max(...clans.map((c) => c.games), 1);
|
||||
|
||||
return html`
|
||||
<div class="p-4 md:p-6 text-gray-200">
|
||||
<div class="w-full">
|
||||
<div
|
||||
class="flex flex-col md:flex-row md:items-center md:justify-between mb-4 gap-2"
|
||||
class="overflow-x-auto rounded-xl border border-white/5 bg-black/20"
|
||||
>
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">
|
||||
${translateText("stats_modal.clan_stats")}
|
||||
</h2>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
${startDate.toLocaleDateString()} ·
|
||||
${endDate.toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-xs md:text-sm">
|
||||
<table class="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-700 text-gray-300">
|
||||
<th class="py-2 pr-3 text-left">
|
||||
<tr
|
||||
class="text-white/40 text-xs uppercase tracking-wider border-b border-white/5 bg-white/[0.02]"
|
||||
>
|
||||
<th class="py-4 px-4 text-center font-bold w-16">
|
||||
${translateText("stats_modal.rank")}
|
||||
</th>
|
||||
<th class="py-2 pr-3 text-left">
|
||||
<th class="py-4 px-4 text-left font-bold">
|
||||
${translateText("stats_modal.clan")}
|
||||
</th>
|
||||
<th class="py-2 px-2 text-right">
|
||||
${translateText("stats_modal.games")}
|
||||
<th
|
||||
@click=${() => this.handleSort("games")}
|
||||
class="py-4 px-4 text-right font-bold w-32 cursor-pointer hover:text-white/60 transition-colors select-none"
|
||||
>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
${translateText("stats_modal.games")}
|
||||
${this.sortBy === "games"
|
||||
? this.sortOrder === "asc"
|
||||
? html`<span class="text-blue-400">↑</span>`
|
||||
: html`<span class="text-blue-400">↓</span>`
|
||||
: html`<span class="text-white/20">↕</span>`}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
class="py-2 px-2 text-right"
|
||||
@click=${() => this.handleSort("wins")}
|
||||
class="py-4 px-4 text-right font-bold hidden md:table-cell cursor-pointer hover:text-white/60 transition-colors select-none"
|
||||
title=${translateText("stats_modal.win_score_tooltip")}
|
||||
>
|
||||
${translateText("stats_modal.win_score")}
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
${translateText("stats_modal.win_score")}
|
||||
${this.sortBy === "wins"
|
||||
? this.sortOrder === "asc"
|
||||
? html`<span class="text-blue-400">↑</span>`
|
||||
: html`<span class="text-blue-400">↓</span>`
|
||||
: html`<span class="text-white/20">↕</span>`}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
class="py-2 px-2 text-right"
|
||||
@click=${() => this.handleSort("losses")}
|
||||
class="py-4 px-4 text-right font-bold hidden md:table-cell cursor-pointer hover:text-white/60 transition-colors select-none"
|
||||
title=${translateText("stats_modal.loss_score_tooltip")}
|
||||
>
|
||||
${translateText("stats_modal.loss_score")}
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
${translateText("stats_modal.loss_score")}
|
||||
${this.sortBy === "losses"
|
||||
? this.sortOrder === "asc"
|
||||
? html`<span class="text-blue-400">↑</span>`
|
||||
: html`<span class="text-blue-400">↓</span>`
|
||||
: html`<span class="text-white/20">↕</span>`}
|
||||
</div>
|
||||
</th>
|
||||
<th class="py-2 pl-2 text-right">
|
||||
${translateText("stats_modal.win_loss_ratio")}
|
||||
<th
|
||||
@click=${() => this.handleSort("ratio")}
|
||||
class="py-4 px-4 text-right font-bold pr-6 cursor-pointer hover:text-white/60 transition-colors select-none"
|
||||
>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
${translateText("stats_modal.win_loss_ratio")}
|
||||
${this.sortBy === "ratio"
|
||||
? this.sortOrder === "asc"
|
||||
? html`<span class="text-blue-400">↑</span>`
|
||||
: html`<span class="text-blue-400">↓</span>`
|
||||
: html`<span class="text-white/20">↕</span>`}
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${clans.map(
|
||||
(clan, index) => html`
|
||||
<tr class="border-b border-gray-800 last:border-b-0">
|
||||
<td class="py-2 pr-3 text-center">
|
||||
${(index + 1).toLocaleString()}
|
||||
${this.getSortedClans(clans).map((clan, index) => {
|
||||
const rankColor =
|
||||
index === 0
|
||||
? "text-yellow-400 bg-yellow-400/10 ring-1 ring-yellow-400/20"
|
||||
: index === 1
|
||||
? "text-slate-300 bg-slate-400/10 ring-1 ring-slate-400/20"
|
||||
: index === 2
|
||||
? "text-amber-600 bg-amber-600/10 ring-1 ring-amber-600/20"
|
||||
: "text-white/40 bg-white/5";
|
||||
|
||||
const rankIcon =
|
||||
index === 0
|
||||
? "👑"
|
||||
: index === 1
|
||||
? "🥈"
|
||||
: index === 2
|
||||
? "🥉"
|
||||
: String(index + 1);
|
||||
|
||||
return html`
|
||||
<tr
|
||||
class="border-b border-white/5 hover:bg-white/[0.07] transition-colors group"
|
||||
>
|
||||
<td class="py-3 px-4 text-center">
|
||||
<div
|
||||
class="w-10 h-10 mx-auto flex items-center justify-center rounded-lg font-bold font-mono text-lg ${rankColor}"
|
||||
>
|
||||
${rankIcon}
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-2 pr-3 font-semibold text-left">
|
||||
${clan.clanTag}
|
||||
<td class="py-3 px-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="px-2.5 py-1 rounded bg-blue-500/10 border border-blue-500/20 text-blue-300 font-bold text-xs tracking-wide group-hover:bg-blue-500/20 transition-colors"
|
||||
>
|
||||
${clan.clanTag}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-2 px-2 text-right">
|
||||
${clan.games.toLocaleString()}
|
||||
<td class="py-3 px-4 text-right">
|
||||
<div class="flex flex-col items-end gap-1">
|
||||
<span class="text-white font-mono font-medium"
|
||||
>${clan.games.toLocaleString()}</span
|
||||
>
|
||||
<div
|
||||
class="w-24 h-1 bg-white/10 rounded-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="h-full bg-blue-500/50 rounded-full"
|
||||
style="width: ${(clan.games / maxGames) * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-2 px-2 text-right">${clan.weightedWins}</td>
|
||||
<td class="py-2 px-2 text-right">${clan.weightedLosses}</td>
|
||||
<td class="py-2 pl-2 text-right">
|
||||
${clan.weightedWLRatio}
|
||||
<td
|
||||
class="py-3 px-4 text-right font-mono text-green-400/90 hidden md:table-cell"
|
||||
>
|
||||
${clan.weightedWins}
|
||||
</td>
|
||||
<td
|
||||
class="py-3 px-4 text-right font-mono text-red-400/90 hidden md:table-cell"
|
||||
>
|
||||
${clan.weightedLosses}
|
||||
</td>
|
||||
<td class="py-3 px-4 text-right pr-6">
|
||||
<div class="inline-flex flex-col items-end">
|
||||
<span
|
||||
class="font-mono font-bold ${Number(
|
||||
clan.weightedWLRatio,
|
||||
) >= 1
|
||||
? "text-green-400"
|
||||
: "text-red-400"}"
|
||||
>
|
||||
${clan.weightedWLRatio}
|
||||
</span>
|
||||
<span
|
||||
class="text-[10px] uppercase text-white/30 font-bold tracking-wider"
|
||||
>${translateText("stats_modal.ratio")}</span
|
||||
>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`,
|
||||
)}
|
||||
`;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -189,61 +359,79 @@ export class StatsModal extends LitElement {
|
||||
}
|
||||
|
||||
render() {
|
||||
let dateRange = html``;
|
||||
if (this.data) {
|
||||
const start = new Date(this.data.start).toLocaleDateString();
|
||||
const end = new Date(this.data.end).toLocaleDateString();
|
||||
dateRange = html`<span
|
||||
class="text-sm font-normal text-white/40 ml-2 break-words"
|
||||
>(${start} - ${end})</span
|
||||
>`;
|
||||
}
|
||||
|
||||
const content = html`
|
||||
<div
|
||||
class="h-full flex flex-col ${this.inline
|
||||
? "bg-black/40 backdrop-blur-md rounded-2xl border border-white/10"
|
||||
: ""}"
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap items-center mb-6 pb-2 border-b border-white/10 gap-2 shrink-0 p-6"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-4 flex-1">
|
||||
<button
|
||||
@click=${this.close}
|
||||
class="group flex items-center justify-center w-10 h-10 rounded-full bg-white/5 hover:bg-white/10 transition-all border border-white/10"
|
||||
aria-label=${translateText("common.close")}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-5 h-5 text-gray-400 group-hover:text-white transition-colors"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="text-white text-xl sm:text-2xl md:text-3xl font-bold uppercase tracking-widest break-words hyphens-auto"
|
||||
>
|
||||
${translateText("stats_modal.clan_stats")}
|
||||
</span>
|
||||
${dateRange}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent px-6 pb-6 mr-1"
|
||||
>
|
||||
${this.renderBody()}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (this.inline) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return html`
|
||||
<o-modal id="stats-modal" title="${translateText("stats_modal.title")}">
|
||||
${this.renderBody()}
|
||||
<o-modal
|
||||
id="stats-modal"
|
||||
title="${translateText("stats_modal.clan_stats")}"
|
||||
?inline=${this.inline}
|
||||
hideCloseButton
|
||||
hideHeader
|
||||
>
|
||||
${content}
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("stats-button")
|
||||
export class StatsButton extends LitElement {
|
||||
@query("stats-modal") private statsModal: StatsModal;
|
||||
@state() private isVisible: boolean = true;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.isVisible) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="fixed top-20 right-4 z-9998">
|
||||
<button
|
||||
@click="${this.open}"
|
||||
class="w-12 h-12 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-2xl hover:shadow-2xl transition-all duration-200 flex items-center justify-center text-xl focus:outline-hidden focus:ring-4 focus:ring-blue-500 focus:ring-offset-4"
|
||||
title="${translateText("stats_modal.title")}"
|
||||
>
|
||||
<img src="/icons/stats.svg" alt="Stats" class="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
<stats-modal></stats-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
private open() {
|
||||
this.isVisible = true;
|
||||
this.requestUpdate();
|
||||
this.statsModal?.open();
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.statsModal?.close();
|
||||
this.isVisible = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { html, LitElement, render } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import { html, render } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { UserMeResponse } from "../core/ApiSchemas";
|
||||
import { ColorPalette, Cosmetics, Pattern } from "../core/CosmeticSchemas";
|
||||
import { UserSettings } from "../core/game/UserSettings";
|
||||
import { PlayerPattern } from "../core/Schemas";
|
||||
import { hasLinkedAccount } from "./Api";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import "./components/Difficulties";
|
||||
import "./components/PatternButton";
|
||||
import { renderPatternPreview } from "./components/PatternButton";
|
||||
@@ -17,12 +18,7 @@ import {
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
@customElement("territory-patterns-modal")
|
||||
export class TerritoryPatternsModal extends LitElement {
|
||||
@query("o-modal") private modalEl!: HTMLElement & {
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
export class TerritoryPatternsModal extends BaseModal {
|
||||
public previewButton: HTMLElement | null = null;
|
||||
|
||||
@state() private selectedPattern: PlayerPattern | null;
|
||||
@@ -41,6 +37,11 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
|
||||
private userMeResponse: UserMeResponse | false = false;
|
||||
|
||||
private _onPatternSelected = () => {
|
||||
this.updateFromSettings();
|
||||
this.refresh();
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
@@ -53,6 +54,20 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
this.onUserMe(event.detail);
|
||||
},
|
||||
);
|
||||
window.addEventListener("pattern-selected", this._onPatternSelected);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("pattern-selected", this._onPatternSelected);
|
||||
}
|
||||
|
||||
private updateFromSettings() {
|
||||
this.selectedPattern =
|
||||
this.cosmetics !== null
|
||||
? this.userSettings.getSelectedPatternName(this.cosmetics)
|
||||
: null;
|
||||
this.selectedColor = this.userSettings.getSelectedColor() ?? null;
|
||||
}
|
||||
|
||||
async onUserMe(userMeResponse: UserMeResponse | false) {
|
||||
@@ -64,60 +79,111 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
}
|
||||
this.userMeResponse = userMeResponse;
|
||||
this.cosmetics = await fetchCosmetics();
|
||||
this.selectedPattern =
|
||||
this.cosmetics !== null
|
||||
? this.userSettings.getSelectedPatternName(this.cosmetics)
|
||||
: null;
|
||||
this.selectedColor = this.userSettings.getSelectedColor() ?? null;
|
||||
this.updateFromSettings();
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
private renderTabNavigation(): TemplateResult {
|
||||
return html`
|
||||
<div class="flex border-b border-gray-600 mb-4 justify-center">
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium transition-colors duration-200 ${this
|
||||
.activeTab === "patterns"
|
||||
? "text-blue-400 border-b-2 border-blue-400 bg-blue-400/10"
|
||||
: "text-gray-400 hover:text-white"}"
|
||||
@click=${() => (this.activeTab = "patterns")}
|
||||
>
|
||||
${translateText("territory_patterns.title")}
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium transition-colors duration-200 ${this
|
||||
.activeTab === "colors"
|
||||
? "text-blue-400 border-b-2 border-blue-400 bg-blue-400/10"
|
||||
: "text-gray-400 hover:text-white"}"
|
||||
@click=${() => (this.activeTab = "colors")}
|
||||
>
|
||||
${translateText("territory_patterns.colors")}
|
||||
</button>
|
||||
<div
|
||||
class="relative flex flex-col mb-6 border-b border-white/10 pb-4 shrink-0"
|
||||
>
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<button
|
||||
@click=${this.close}
|
||||
class="group flex items-center justify-center w-10 h-10 rounded-full bg-white/5 hover:bg-white/10 transition-all border border-white/10 shrink-0"
|
||||
aria-label="${translateText("common.back")}"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-5 h-5 text-gray-400 group-hover:text-white transition-colors"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<span
|
||||
class="text-white text-xl sm:text-2xl md:text-3xl font-bold uppercase tracking-widest break-words hyphens-auto"
|
||||
>
|
||||
${translateText("territory_patterns.title")}
|
||||
</span>
|
||||
|
||||
${!hasLinkedAccount(this.userMeResponse)
|
||||
? html`<div class="ml-auto flex items-center">
|
||||
${this.renderNotLoggedInWarning()}
|
||||
</div>`
|
||||
: html``}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 justify-center">
|
||||
<button
|
||||
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
|
||||
.activeTab === "patterns"
|
||||
? "bg-blue-500/20 text-blue-400 border border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
|
||||
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
|
||||
@click=${() => (this.activeTab = "patterns")}
|
||||
>
|
||||
${translateText("territory_patterns.title")}
|
||||
</button>
|
||||
<button
|
||||
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
|
||||
.activeTab === "colors"
|
||||
? "bg-blue-500/20 text-blue-400 border border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
|
||||
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
|
||||
@click=${() => (this.activeTab = "colors")}
|
||||
>
|
||||
${translateText("territory_patterns.colors")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPatternGrid(): TemplateResult {
|
||||
const buttons: TemplateResult[] = [];
|
||||
for (const pattern of Object.values(this.cosmetics?.patterns ?? {})) {
|
||||
const colorPalettes = [...(pattern.colorPalettes ?? []), null];
|
||||
const patterns: (Pattern | null)[] = [
|
||||
null,
|
||||
...Object.values(this.cosmetics?.patterns ?? {}),
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
const colorPalettes = pattern
|
||||
? [...(pattern.colorPalettes ?? []), null]
|
||||
: [null];
|
||||
for (const colorPalette of colorPalettes) {
|
||||
const rel = patternRelationship(
|
||||
pattern,
|
||||
colorPalette,
|
||||
this.userMeResponse,
|
||||
this.affiliateCode,
|
||||
);
|
||||
let rel = "owned";
|
||||
if (pattern) {
|
||||
rel = patternRelationship(
|
||||
pattern,
|
||||
colorPalette,
|
||||
this.userMeResponse,
|
||||
this.affiliateCode,
|
||||
);
|
||||
}
|
||||
if (rel === "blocked") {
|
||||
continue;
|
||||
}
|
||||
if (this.showOnlyOwned && rel !== "owned") {
|
||||
continue;
|
||||
if (this.showOnlyOwned) {
|
||||
if (rel !== "owned") continue;
|
||||
} else {
|
||||
// Store mode: hide owned items
|
||||
if (rel === "owned") continue;
|
||||
}
|
||||
// Determine if this pattern/color is selected
|
||||
const isDefaultPattern = pattern === null;
|
||||
const isSelected =
|
||||
(isDefaultPattern && this.selectedPattern === null) ||
|
||||
(!isDefaultPattern &&
|
||||
this.selectedPattern &&
|
||||
this.selectedPattern.name === pattern?.name &&
|
||||
(this.selectedPattern.colorPalette?.name ?? null) ===
|
||||
(colorPalette?.name ?? null));
|
||||
buttons.push(html`
|
||||
<pattern-button
|
||||
.pattern=${pattern}
|
||||
@@ -125,6 +191,7 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
colorPalette?.name ?? ""
|
||||
] ?? null}
|
||||
.requiresPurchase=${rel === "purchasable"}
|
||||
.selected=${isSelected}
|
||||
.onSelect=${(p: PlayerPattern | null) => this.selectPattern(p)}
|
||||
.onPurchase=${(p: Pattern, colorPalette: ColorPalette | null) =>
|
||||
handlePurchase(p, colorPalette)}
|
||||
@@ -134,33 +201,35 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex justify-center">
|
||||
${hasLinkedAccount(this.userMeResponse)
|
||||
? this.renderMySkinsButton()
|
||||
: this.renderNotLoggedInWarning()}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-4 p-2 justify-center items-start">
|
||||
${this.affiliateCode === null
|
||||
? html`
|
||||
<pattern-button
|
||||
.pattern=${null}
|
||||
.onSelect=${(p: Pattern | null) => this.selectPattern(null)}
|
||||
></pattern-button>
|
||||
`
|
||||
: html``}
|
||||
${buttons}
|
||||
</div>
|
||||
${!this.showOnlyOwned && buttons.length === 0
|
||||
? html`<div
|
||||
class="text-white/40 text-sm font-bold uppercase tracking-wider text-center py-8"
|
||||
>
|
||||
${translateText("territory_patterns.all_owned")}
|
||||
</div>`
|
||||
: html`
|
||||
<div
|
||||
class="flex flex-wrap gap-4 p-2 justify-center items-stretch content-start"
|
||||
>
|
||||
${buttons}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderMySkinsButton(): TemplateResult {
|
||||
return html`<button
|
||||
class="px-4 py-2 text-sm font-medium transition-colors duration-200 rounded-lg ${this
|
||||
class="px-4 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-wider border mb-4 ${this
|
||||
.showOnlyOwned
|
||||
? "bg-blue-500 text-white hover:bg-blue-600"
|
||||
: "bg-gray-700 text-gray-300 hover:bg-gray-600"}"
|
||||
? "bg-blue-500/20 text-blue-400 border-blue-500/50 shadow-[0_0_10px_rgba(59,130,246,0.3)]"
|
||||
: "bg-white/5 text-white/60 border-white/10 hover:bg-white/10 hover:text-white"}"
|
||||
@click=${() => {
|
||||
this.showOnlyOwned = !this.showOnlyOwned;
|
||||
}}
|
||||
@@ -170,11 +239,11 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
}
|
||||
|
||||
private renderNotLoggedInWarning(): TemplateResult {
|
||||
return html`<label
|
||||
class="px-4 py-2 text-sm font-medium transition-colors duration-200 rounded-lg bg-red-500 text-white"
|
||||
return html`<div
|
||||
class="px-4 py-2 text-xs font-bold uppercase tracking-wider transition-colors duration-200 rounded-lg bg-red-500/20 text-red-400 border border-red-500/30"
|
||||
>
|
||||
${translateText("territory_patterns.not_logged_in")}
|
||||
</label>`;
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private renderColorSwatchGrid(): TemplateResult {
|
||||
@@ -190,11 +259,15 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
${hexCodes.map(
|
||||
(hexCode) => html`
|
||||
<div
|
||||
class="w-12 h-12 rounded-lg border-2 border-white/30 bg-(--bg) cursor-pointer transition-all duration-200 hover:scale-110 hover:shadow-lg"
|
||||
style="--bg: ${hexCode};"
|
||||
class="w-12 h-12 rounded-xl border-2 border-white/10 cursor-pointer transition-all duration-200 hover:scale-110 hover:shadow-[0_0_15px_rgba(255,255,255,0.3)] hover:border-white relative group"
|
||||
style="background-color: ${hexCode};"
|
||||
title="${hexCode}"
|
||||
@click=${() => this.selectColor(hexCode)}
|
||||
></div>
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 rounded-xl ring-2 ring-inset ring-black/20"
|
||||
></div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
@@ -202,32 +275,67 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.isActive) return html``;
|
||||
if (!this.isActive && !this.inline) return html``;
|
||||
|
||||
const content = html`
|
||||
<div
|
||||
class="h-full flex flex-col bg-black/40 backdrop-blur-md rounded-2xl border border-white/10 p-6"
|
||||
>
|
||||
${this.renderTabNavigation()}
|
||||
<div class="overflow-y-auto pr-2 custom-scrollbar mr-1">
|
||||
${this.activeTab === "patterns"
|
||||
? this.renderPatternGrid()
|
||||
: this.renderColorSwatchGrid()}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (this.inline) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return html`
|
||||
<o-modal
|
||||
id="territoryPatternsModal"
|
||||
title="${this.activeTab === "patterns"
|
||||
? translateText("territory_patterns.title")
|
||||
: translateText("territory_patterns.colors")}"
|
||||
?inline=${this.inline}
|
||||
?hideHeader=${true}
|
||||
?hideCloseButton=${true}
|
||||
>
|
||||
${this.renderTabNavigation()}
|
||||
${this.activeTab === "patterns"
|
||||
? this.renderPatternGrid()
|
||||
: this.renderColorSwatchGrid()}
|
||||
${content}
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
public async open(affiliateCode?: string) {
|
||||
public async open(
|
||||
options?: string | { affiliateCode?: string; showOnlyOwned?: boolean },
|
||||
) {
|
||||
this.isActive = true;
|
||||
this.affiliateCode = affiliateCode ?? null;
|
||||
if (typeof options === "string") {
|
||||
this.affiliateCode = options;
|
||||
this.showOnlyOwned = false;
|
||||
} else if (
|
||||
options !== null &&
|
||||
typeof options === "object" &&
|
||||
!Array.isArray(options)
|
||||
) {
|
||||
this.affiliateCode = options.affiliateCode ?? null;
|
||||
this.showOnlyOwned = options.showOnlyOwned ?? false;
|
||||
} else {
|
||||
this.affiliateCode = null;
|
||||
this.showOnlyOwned = false;
|
||||
}
|
||||
|
||||
await this.refresh();
|
||||
super.open();
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.isActive = false;
|
||||
this.affiliateCode = null;
|
||||
this.modalEl?.close();
|
||||
super.close();
|
||||
}
|
||||
|
||||
private selectPattern(pattern: PlayerPattern | null) {
|
||||
@@ -240,14 +348,43 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
pattern.colorPalette?.name === undefined
|
||||
? pattern.name
|
||||
: `${pattern.name}:${pattern.colorPalette.name}`;
|
||||
|
||||
this.userSettings.setSelectedPatternName(`pattern:${name}`);
|
||||
}
|
||||
this.selectedPattern = pattern;
|
||||
this.refresh();
|
||||
// Dispatch event so Main.ts can refresh the preview button
|
||||
this.dispatchEvent(new CustomEvent("pattern-selected", { bubbles: true }));
|
||||
// Show popup/modal for skin selection
|
||||
this.showSkinSelectedPopup();
|
||||
// Close the skin store
|
||||
this.close();
|
||||
}
|
||||
|
||||
private showSkinSelectedPopup() {
|
||||
// Use unified heads-up-message for feedback
|
||||
let skinName = translateText("territory_patterns.pattern.default");
|
||||
if (this.selectedPattern && this.selectedPattern.name) {
|
||||
skinName = this.selectedPattern.name
|
||||
.split("_")
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(" ");
|
||||
if (
|
||||
this.selectedPattern.colorPalette &&
|
||||
this.selectedPattern.colorPalette.name
|
||||
) {
|
||||
skinName += ` (${this.selectedPattern.colorPalette.name})`;
|
||||
}
|
||||
}
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
detail: {
|
||||
message: `${skinName} ${translateText("territory_patterns.selected")}`,
|
||||
duration: 2000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private selectColor(hexCode: string) {
|
||||
this.selectedPattern = null;
|
||||
this.userSettings.setSelectedPatternName(undefined);
|
||||
@@ -264,29 +401,41 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
): TemplateResult {
|
||||
return html`
|
||||
<div
|
||||
class="rounded-sm size-(--size) bg-(--bg)"
|
||||
style="--size: ${width}px; --bg: ${hexCode};"
|
||||
class="w-full h-full rounded"
|
||||
style="background-color: ${hexCode};"
|
||||
></div>
|
||||
`;
|
||||
}
|
||||
|
||||
public async refresh() {
|
||||
this.requestUpdate();
|
||||
|
||||
const preview = this.selectedColor
|
||||
? this.renderColorPreview(this.selectedColor, 48, 48)
|
||||
: renderPatternPreview(this.selectedPattern ?? null, 48, 48);
|
||||
this.requestUpdate();
|
||||
|
||||
// Wait for the DOM to be updated and the o-modal element to be available
|
||||
await this.updateComplete;
|
||||
|
||||
// Now modalEl should be available
|
||||
if (this.modalEl) {
|
||||
this.modalEl.open();
|
||||
} else {
|
||||
console.warn("modalEl is still null after updateComplete");
|
||||
if (
|
||||
this.previewButton === null ||
|
||||
!document.body.contains(this.previewButton)
|
||||
) {
|
||||
this.previewButton = document.getElementById(
|
||||
"territory-patterns-input-preview-button",
|
||||
);
|
||||
}
|
||||
|
||||
if (this.previewButton === null) return;
|
||||
|
||||
// Check if the element is still in the DOM to avoid lit-html errors
|
||||
if (!document.body.contains(this.previewButton)) {
|
||||
console.warn(
|
||||
"TerritoryPatternsModal: previewButton is disconnected from DOM, skipping render",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear and re-render using Lit
|
||||
render(preview, this.previewButton);
|
||||
this.previewButton.style.padding = "4px";
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
+118
-326
@@ -1,60 +1,37 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import { html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { translateText } from "../client/Utils";
|
||||
import { UserSettings } from "../core/game/UserSettings";
|
||||
import "./components/baseComponents/setting/SettingKeybind";
|
||||
import { SettingKeybind } from "./components/baseComponents/setting/SettingKeybind";
|
||||
import "./components/baseComponents/setting/SettingNumber";
|
||||
import "./components/baseComponents/setting/SettingSlider";
|
||||
import "./components/baseComponents/setting/SettingToggle";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import "./FlagInputModal";
|
||||
|
||||
interface FlagInputModalElement extends HTMLElement {
|
||||
open(): void;
|
||||
returnTo?: string;
|
||||
}
|
||||
|
||||
@customElement("user-setting")
|
||||
export class UserSettingModal extends LitElement {
|
||||
export class UserSettingModal extends BaseModal {
|
||||
private userSettings: UserSettings = new UserSettings();
|
||||
|
||||
@state() private settingsMode: "basic" | "keybinds" = "basic";
|
||||
@state() private keybinds: Record<string, { value: string; key: string }> =
|
||||
{};
|
||||
|
||||
@state() private keySequence: string[] = [];
|
||||
@state() private showEasterEggSettings = false;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("keydown", this.handleKeyDown);
|
||||
|
||||
const savedKeybinds = localStorage.getItem("settings.keybinds");
|
||||
if (savedKeybinds) {
|
||||
try {
|
||||
this.keybinds = JSON.parse(savedKeybinds);
|
||||
} catch (e) {
|
||||
console.warn("Invalid keybinds JSON:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@query("o-modal") private modalEl!: HTMLElement & {
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
isModalOpen: boolean;
|
||||
};
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
window.removeEventListener("keydown", this.handleKeyDown);
|
||||
window.removeEventListener("keydown", this.handleEasterEggKey);
|
||||
super.disconnectedCallback();
|
||||
document.body.style.overflow = "auto";
|
||||
}
|
||||
|
||||
private handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!this.modalEl?.isModalOpen || this.showEasterEggSettings) return;
|
||||
private handleEasterEggKey = (e: KeyboardEvent) => {
|
||||
if (!this.isModalOpen || this.showEasterEggSettings) return;
|
||||
|
||||
if (e.code === "Escape") {
|
||||
e.preventDefault();
|
||||
this.close();
|
||||
// Validate that the event target is inside this component
|
||||
const target = e.target as Node;
|
||||
if (!this.contains(target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = e.key.toLowerCase();
|
||||
@@ -71,7 +48,8 @@ export class UserSettingModal extends LitElement {
|
||||
console.log("🪺 Setting~ unlocked by EVAN combo!");
|
||||
this.showEasterEggSettings = true;
|
||||
const popup = document.createElement("div");
|
||||
popup.className = "easter-egg-popup";
|
||||
popup.className =
|
||||
"fixed top-10 left-1/2 p-4 px-6 bg-black/80 text-white text-xl rounded-xl animate-fadePop z-[9999]";
|
||||
popup.textContent = "🎉 You found a secret setting!";
|
||||
document.body.appendChild(popup);
|
||||
|
||||
@@ -205,73 +183,114 @@ export class UserSettingModal extends LitElement {
|
||||
this.userSettings.set("settings.performanceOverlay", enabled);
|
||||
}
|
||||
|
||||
private handleKeybindChange(
|
||||
e: CustomEvent<{ action: string; value: string; key: string }>,
|
||||
) {
|
||||
console.log("Keybind change event:", e);
|
||||
const { action, value, key } = e.detail;
|
||||
const prevValue = this.keybinds[action]?.value ?? "";
|
||||
|
||||
const values = Object.entries(this.keybinds)
|
||||
.filter(([k]) => k !== action)
|
||||
.map(([, v]) => v.value);
|
||||
if (values.includes(value) && value !== "Null") {
|
||||
const popup = document.createElement("div");
|
||||
popup.className = "setting-popup";
|
||||
popup.textContent = `The key "${value}" is already assigned to another action.`;
|
||||
document.body.appendChild(popup);
|
||||
const element = this.renderRoot.querySelector(
|
||||
`setting-keybind[action="${action}"]`,
|
||||
) as SettingKeybind;
|
||||
if (element) {
|
||||
element.value = prevValue;
|
||||
element.requestUpdate();
|
||||
}
|
||||
return;
|
||||
private openFlagSelector = () => {
|
||||
const flagInputModal =
|
||||
document.querySelector<FlagInputModalElement>("#flag-input-modal");
|
||||
if (flagInputModal?.open) {
|
||||
this.close();
|
||||
flagInputModal.returnTo = "#" + (this.id || "page-options");
|
||||
flagInputModal.open();
|
||||
}
|
||||
this.keybinds = { ...this.keybinds, [action]: { value: value, key: key } };
|
||||
localStorage.setItem("settings.keybinds", JSON.stringify(this.keybinds));
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<o-modal title="${translateText("user_setting.title")}">
|
||||
<div class="modal-overlay">
|
||||
<div class="modal-content user-setting-modal">
|
||||
<div class="flex mb-4 w-full justify-center">
|
||||
<button
|
||||
class="w-1/2 text-center px-3 py-1 rounded-l
|
||||
${this.settingsMode === "basic"
|
||||
? "bg-white/10 text-white"
|
||||
: "bg-transparent text-gray-400"}"
|
||||
@click=${() => (this.settingsMode = "basic")}
|
||||
const content = html`
|
||||
<div
|
||||
class="h-full flex flex-col ${this.inline
|
||||
? "bg-black/40 backdrop-blur-md rounded-2xl border border-white/10"
|
||||
: ""}"
|
||||
>
|
||||
<div
|
||||
class="flex items-center mb-6 pb-2 border-b border-white/10 gap-2 shrink-0 p-6"
|
||||
>
|
||||
<div class="flex items-center gap-4 flex-1 flex-wrap">
|
||||
<button
|
||||
@click=${this.close}
|
||||
class="group flex items-center justify-center w-10 h-10 rounded-full bg-white/5 hover:bg-white/10 transition-all border border-white/10 shrink-0"
|
||||
aria-label="${translateText("common.back")}"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-5 h-5 text-gray-400 group-hover:text-white transition-colors"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
${translateText("user_setting.tab_basic")}
|
||||
</button>
|
||||
<button
|
||||
class="w-1/2 text-center px-3 py-1 rounded-r
|
||||
${this.settingsMode === "keybinds"
|
||||
? "bg-white/10 text-white"
|
||||
: "bg-transparent text-gray-400"}"
|
||||
@click=${() => (this.settingsMode = "keybinds")}
|
||||
>
|
||||
${translateText("user_setting.tab_keybinds")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-list">
|
||||
${this.settingsMode === "basic"
|
||||
? this.renderBasicSettings()
|
||||
: this.renderKeybindSettings()}
|
||||
</div>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<span
|
||||
class="text-white text-xl sm:text-2xl md:text-3xl font-bold uppercase tracking-widest break-all hyphens-auto min-w-0"
|
||||
>
|
||||
${translateText("user_setting.title")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent px-6 pb-6 mr-1"
|
||||
>
|
||||
<div class="flex flex-col gap-2">${this.renderBasicSettings()}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (this.inline) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return html`
|
||||
<o-modal
|
||||
title="${translateText("user_setting.title")}"
|
||||
?inline=${this.inline}
|
||||
hideCloseButton
|
||||
hideHeader
|
||||
>
|
||||
${content}
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
protected onClose(): void {
|
||||
window.removeEventListener("keydown", this.handleEasterEggKey);
|
||||
}
|
||||
|
||||
private renderBasicSettings() {
|
||||
return html`
|
||||
<!-- 🚩 Flag Selector -->
|
||||
<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 cursor-pointer"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click=${this.openFlagSelector}
|
||||
@keydown=${(e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
this.openFlagSelector();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col flex-1 min-w-0 mr-4">
|
||||
<div class="text-white font-bold text-base block mb-1">
|
||||
${translateText("flag_input.title")}
|
||||
</div>
|
||||
<div class="text-white/50 text-sm leading-snug">
|
||||
${translateText("flag_input.button_title")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative inline-block w-12 h-8 shrink-0 rounded overflow-hidden border border-white/20"
|
||||
>
|
||||
<flag-input class="w-full h-full pointer-events-none"></flag-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 🌙 Dark Mode -->
|
||||
<setting-toggle
|
||||
label="${translateText("user_setting.dark_mode_label")}"
|
||||
@@ -429,238 +448,11 @@ export class UserSettingModal extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderKeybindSettings() {
|
||||
return html`
|
||||
<div class="text-center text-white text-base font-semibold mt-5 mb-2">
|
||||
${translateText("user_setting.view_options")}
|
||||
</div>
|
||||
|
||||
<setting-keybind
|
||||
action="toggleView"
|
||||
label=${translateText("user_setting.toggle_view")}
|
||||
description=${translateText("user_setting.toggle_view_desc")}
|
||||
defaultKey="Space"
|
||||
.value=${this.keybinds["toggleView"]?.key ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<div class="text-center text-white text-base font-semibold mt-5 mb-2">
|
||||
${translateText("user_setting.build_controls")}
|
||||
</div>
|
||||
|
||||
<setting-keybind
|
||||
action="buildCity"
|
||||
label=${translateText("user_setting.build_city")}
|
||||
description=${translateText("user_setting.build_city_desc")}
|
||||
defaultKey="Digit1"
|
||||
.value=${this.keybinds["buildCity"]?.key ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="buildFactory"
|
||||
label=${translateText("user_setting.build_factory")}
|
||||
description=${translateText("user_setting.build_factory_desc")}
|
||||
defaultKey="Digit2"
|
||||
.value=${this.keybinds["buildFactory"]?.key ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="buildPort"
|
||||
label=${translateText("user_setting.build_port")}
|
||||
description=${translateText("user_setting.build_port_desc")}
|
||||
defaultKey="Digit3"
|
||||
.value=${this.keybinds["buildPort"]?.key ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="buildDefensePost"
|
||||
label=${translateText("user_setting.build_defense_post")}
|
||||
description=${translateText("user_setting.build_defense_post_desc")}
|
||||
defaultKey="Digit4"
|
||||
.value=${this.keybinds["buildDefensePost"]?.key ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="buildMissileSilo"
|
||||
label=${translateText("user_setting.build_missile_silo")}
|
||||
description=${translateText("user_setting.build_missile_silo_desc")}
|
||||
defaultKey="Digit5"
|
||||
.value=${this.keybinds["buildMissileSilo"]?.key ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="buildSamLauncher"
|
||||
label=${translateText("user_setting.build_sam_launcher")}
|
||||
description=${translateText("user_setting.build_sam_launcher_desc")}
|
||||
defaultKey="Digit6"
|
||||
.value=${this.keybinds["buildSamLauncher"]?.key ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="buildWarship"
|
||||
label=${translateText("user_setting.build_warship")}
|
||||
description=${translateText("user_setting.build_warship_desc")}
|
||||
defaultKey="Digit7"
|
||||
.value=${this.keybinds["buildWarship"]?.key ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="buildAtomBomb"
|
||||
label=${translateText("user_setting.build_atom_bomb")}
|
||||
description=${translateText("user_setting.build_atom_bomb_desc")}
|
||||
defaultKey="Digit8"
|
||||
.value=${this.keybinds["buildAtomBomb"]?.key ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="buildHydrogenBomb"
|
||||
label=${translateText("user_setting.build_hydrogen_bomb")}
|
||||
description=${translateText("user_setting.build_hydrogen_bomb_desc")}
|
||||
defaultKey="Digit9"
|
||||
.value=${this.keybinds["buildHydrogenBomb"]?.key ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="buildMIRV"
|
||||
label=${translateText("user_setting.build_mirv")}
|
||||
description=${translateText("user_setting.build_mirv_desc")}
|
||||
defaultKey="Digit0"
|
||||
.value=${this.keybinds["buildMIRV"]?.key ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<div class="text-center text-white text-base font-semibold mt-5 mb-2">
|
||||
${translateText("user_setting.attack_ratio_controls")}
|
||||
</div>
|
||||
|
||||
<setting-keybind
|
||||
action="attackRatioDown"
|
||||
label=${translateText("user_setting.attack_ratio_down")}
|
||||
description=${translateText("user_setting.attack_ratio_down_desc")}
|
||||
defaultKey="KeyT"
|
||||
.value=${this.keybinds["attackRatioDown"]?.key ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="attackRatioUp"
|
||||
label=${translateText("user_setting.attack_ratio_up")}
|
||||
description=${translateText("user_setting.attack_ratio_up_desc")}
|
||||
defaultKey="KeyY"
|
||||
.value=${this.keybinds["attackRatioUp"]?.key ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<div class="text-center text-white text-base font-semibold mt-5 mb-2">
|
||||
${translateText("user_setting.attack_keybinds")}
|
||||
</div>
|
||||
|
||||
<setting-keybind
|
||||
action="boatAttack"
|
||||
label=${translateText("user_setting.boat_attack")}
|
||||
description=${translateText("user_setting.boat_attack_desc")}
|
||||
defaultKey="KeyB"
|
||||
.value=${this.keybinds["boatAttack"]?.key ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="groundAttack"
|
||||
label=${translateText("user_setting.ground_attack")}
|
||||
description=${translateText("user_setting.ground_attack_desc")}
|
||||
defaultKey="KeyG"
|
||||
.value=${this.keybinds["groundAttack"]?.key ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<div class="text-center text-white text-base font-semibold mt-5 mb-2">
|
||||
${translateText("user_setting.zoom_controls")}
|
||||
</div>
|
||||
|
||||
<setting-keybind
|
||||
action="zoomOut"
|
||||
label=${translateText("user_setting.zoom_out")}
|
||||
description=${translateText("user_setting.zoom_out_desc")}
|
||||
defaultKey="KeyQ"
|
||||
.value=${this.keybinds["zoomOut"]?.key ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="zoomIn"
|
||||
label=${translateText("user_setting.zoom_in")}
|
||||
description=${translateText("user_setting.zoom_in_desc")}
|
||||
defaultKey="KeyE"
|
||||
.value=${this.keybinds["zoomIn"]?.key ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<div class="text-center text-white text-base font-semibold mt-5 mb-2">
|
||||
${translateText("user_setting.camera_movement")}
|
||||
</div>
|
||||
|
||||
<setting-keybind
|
||||
action="centerCamera"
|
||||
label=${translateText("user_setting.center_camera")}
|
||||
description=${translateText("user_setting.center_camera_desc")}
|
||||
defaultKey="KeyC"
|
||||
.value=${this.keybinds["centerCamera"]?.key ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="moveUp"
|
||||
label=${translateText("user_setting.move_up")}
|
||||
description=${translateText("user_setting.move_up_desc")}
|
||||
defaultKey="KeyW"
|
||||
.value=${this.keybinds["moveUp"]?.key ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="moveLeft"
|
||||
label=${translateText("user_setting.move_left")}
|
||||
description=${translateText("user_setting.move_left_desc")}
|
||||
defaultKey="KeyA"
|
||||
.value=${this.keybinds["moveLeft"]?.key ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="moveDown"
|
||||
label=${translateText("user_setting.move_down")}
|
||||
description=${translateText("user_setting.move_down_desc")}
|
||||
defaultKey="KeyS"
|
||||
.value=${this.keybinds["moveDown"]?.key ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="moveRight"
|
||||
label=${translateText("user_setting.move_right")}
|
||||
description=${translateText("user_setting.move_right_desc")}
|
||||
defaultKey="KeyD"
|
||||
.value=${this.keybinds["moveRight"]?.key ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
`;
|
||||
protected onOpen(): void {
|
||||
window.addEventListener("keydown", this.handleEasterEggKey);
|
||||
}
|
||||
|
||||
public open() {
|
||||
this.requestUpdate();
|
||||
this.modalEl?.open();
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.modalEl?.close();
|
||||
super.open();
|
||||
}
|
||||
}
|
||||
|
||||
+112
-17
@@ -2,8 +2,10 @@ import { LitElement, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { translateText } from "../client/Utils";
|
||||
import { getClanTagOriginalCase, sanitizeClanTag } from "../core/Util";
|
||||
import {
|
||||
MAX_USERNAME_LENGTH,
|
||||
MIN_USERNAME_LENGTH,
|
||||
validateUsername,
|
||||
} from "../core/validations/username";
|
||||
|
||||
@@ -11,7 +13,9 @@ const usernameKey: string = "username";
|
||||
|
||||
@customElement("username-input")
|
||||
export class UsernameInput extends LitElement {
|
||||
@state() private username: string = "";
|
||||
@state() private baseUsername: string = "";
|
||||
@state() private clanTag: string = "";
|
||||
|
||||
@property({ type: String }) validationError: string = "";
|
||||
private _isValid: boolean = true;
|
||||
|
||||
@@ -23,29 +27,57 @@ export class UsernameInput extends LitElement {
|
||||
}
|
||||
|
||||
public getCurrentUsername(): string {
|
||||
return this.username;
|
||||
return this.constructFullUsername();
|
||||
}
|
||||
|
||||
private constructFullUsername(): string {
|
||||
if (this.clanTag.length >= 2) {
|
||||
return `[${this.clanTag}] ${this.baseUsername}`;
|
||||
}
|
||||
return this.baseUsername;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.username = this.getStoredUsername();
|
||||
const stored = this.getStoredUsername();
|
||||
this.parseAndSetUsername(stored);
|
||||
}
|
||||
|
||||
private parseAndSetUsername(fullUsername: string) {
|
||||
const tag = getClanTagOriginalCase(fullUsername);
|
||||
if (tag) {
|
||||
this.clanTag = tag.toUpperCase();
|
||||
this.baseUsername = fullUsername.replace(`[${tag}]`, "").trim();
|
||||
} else {
|
||||
this.clanTag = "";
|
||||
this.baseUsername = fullUsername;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<input
|
||||
type="text"
|
||||
.value=${this.username}
|
||||
@input=${this.handleChange}
|
||||
@change=${this.handleChange}
|
||||
placeholder="${translateText("username.enter_username")}"
|
||||
maxlength="${MAX_USERNAME_LENGTH}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-xl shadow-xs text-2xl text-center focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:border-gray-300/60 dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
<div class="flex items-center w-full h-full gap-2">
|
||||
<input
|
||||
type="text"
|
||||
.value=${this.clanTag}
|
||||
@input=${this.handleClanTagChange}
|
||||
placeholder="${translateText("username.tag")}"
|
||||
maxlength="5"
|
||||
class="w-20 bg-transparent border-b border-white/20 text-white placeholder-white/30 text-xl font-bold text-center focus:outline-none focus:border-white/50 transition-colors uppercase shrink-0"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
.value=${this.baseUsername}
|
||||
@input=${this.handleUsernameChange}
|
||||
placeholder="${translateText("username.enter_username")}"
|
||||
maxlength="${MAX_USERNAME_LENGTH}"
|
||||
class="flex-1 min-w-0 bg-transparent border-0 text-white placeholder-white/30 text-2xl font-bold text-left focus:outline-none focus:ring-0 focus:bg-white/5 transition-colors overflow-x-auto whitespace-nowrap text-ellipsis pr-2"
|
||||
/>
|
||||
</div>
|
||||
${this.validationError
|
||||
? html`<div
|
||||
id="username-validation-error"
|
||||
class="absolute z-10 w-full mt-2 px-3 py-1 text-lg border rounded-sm bg-white text-red-600 border-red-600 dark:bg-gray-700 dark:text-red-300 dark:border-red-300"
|
||||
class="absolute top-full left-0 z-50 w-full mt-1 px-3 py-2 text-sm font-medium border border-red-500/50 rounded-lg bg-red-900/90 text-red-200 backdrop-blur-md shadow-lg"
|
||||
>
|
||||
${this.validationError}
|
||||
</div>`
|
||||
@@ -53,13 +85,76 @@ export class UsernameInput extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private handleChange(e: Event) {
|
||||
private handleClanTagChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
this.username = input.value.trim();
|
||||
const result = validateUsername(this.username);
|
||||
const originalValue = input.value;
|
||||
const val = sanitizeClanTag(originalValue);
|
||||
// Only show toast if characters were actually removed (not just uppercased)
|
||||
if (originalValue.toUpperCase() !== val) {
|
||||
input.value = val;
|
||||
// Show toast when invalid characters are removed
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
detail: {
|
||||
message: translateText("username.tag_invalid_chars"),
|
||||
color: "red",
|
||||
duration: 2000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else if (originalValue !== val) {
|
||||
// Just update the input without toast if only case changed
|
||||
input.value = val;
|
||||
}
|
||||
this.clanTag = val;
|
||||
this.validateAndStore();
|
||||
}
|
||||
|
||||
private handleUsernameChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const originalValue = input.value;
|
||||
const val = originalValue.replace(/[[\]]/g, "");
|
||||
if (originalValue !== val) {
|
||||
input.value = val;
|
||||
// Show toast when brackets are removed
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
detail: {
|
||||
message: translateText("username.invalid_chars"),
|
||||
color: "red",
|
||||
duration: 2000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
this.baseUsername = val;
|
||||
this.validateAndStore();
|
||||
}
|
||||
|
||||
private validateAndStore() {
|
||||
// Prevent empty username even if clan tag is present
|
||||
if (!this.baseUsername.trim()) {
|
||||
this._isValid = false;
|
||||
this.validationError = translateText("username.too_short", {
|
||||
min: MIN_USERNAME_LENGTH,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate clan tag if present
|
||||
if (this.clanTag.length > 0 && this.clanTag.length < 2) {
|
||||
this._isValid = false;
|
||||
this.validationError = translateText("username.tag_too_short");
|
||||
return;
|
||||
}
|
||||
|
||||
const full = this.constructFullUsername();
|
||||
const trimmedFull = full.trim();
|
||||
|
||||
const result = validateUsername(trimmedFull);
|
||||
this._isValid = result.isValid;
|
||||
if (result.isValid) {
|
||||
this.storeUsername(this.username);
|
||||
this.storeUsername(trimmedFull);
|
||||
this.validationError = "";
|
||||
} else {
|
||||
this.validationError = result.error ?? "";
|
||||
|
||||
@@ -16,6 +16,25 @@ export function renderTroops(troops: number): string {
|
||||
return renderNumber(troops / 10);
|
||||
}
|
||||
|
||||
export async function copyToClipboard(
|
||||
text: string,
|
||||
onSuccess?: () => void,
|
||||
onReset?: () => void,
|
||||
timeout = 2000,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
if (onSuccess) onSuccess();
|
||||
if (onReset) {
|
||||
setTimeout(() => {
|
||||
onReset();
|
||||
}, timeout);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Failed to copy to clipboard", err);
|
||||
}
|
||||
}
|
||||
|
||||
export function renderNumber(
|
||||
num: number | bigint,
|
||||
fixedPoints?: number,
|
||||
@@ -42,6 +61,42 @@ export function renderNumber(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a keyboard key code for user-friendly display.
|
||||
* Handles empty values, spaces, and normalizes key codes like "Digit1" and "KeyA".
|
||||
*
|
||||
* @param value - The key code to format (e.g., "Digit1", "KeyA", "Space")
|
||||
* @returns The formatted key for display (e.g., "1", "A", "Space")
|
||||
*
|
||||
* @example
|
||||
* formatKeyForDisplay("Digit5") // returns "5"
|
||||
* formatKeyForDisplay("KeyA") // returns "A"
|
||||
* formatKeyForDisplay("Space") // returns "Space"
|
||||
* formatKeyForDisplay(" ") // returns "Space"
|
||||
* formatKeyForDisplay("ArrowUp") // returns "Arrowup"
|
||||
* formatKeyForDisplay("") // returns ""
|
||||
*/
|
||||
export function formatKeyForDisplay(value: string): string {
|
||||
// Handle empty string
|
||||
if (!value) return "";
|
||||
|
||||
// Handle space character or "Space" key
|
||||
if (value === " " || value === "Space") return "Space";
|
||||
|
||||
// Handle DigitN pattern (e.g., "Digit1" -> "1")
|
||||
if (/^Digit\d$/.test(value)) {
|
||||
return value.replace("Digit", "");
|
||||
}
|
||||
|
||||
// Handle KeyX pattern (e.g., "KeyA" -> "A")
|
||||
if (/^Key[A-Z]$/.test(value)) {
|
||||
return value.replace("Key", "");
|
||||
}
|
||||
|
||||
// Fallback: capitalize first letter
|
||||
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||
}
|
||||
|
||||
export function createCanvas(): HTMLCanvasElement {
|
||||
const canvas = document.createElement("canvas");
|
||||
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,10 +16,57 @@ export class HeadsUpMessage extends LitElement implements Layer {
|
||||
@state()
|
||||
private isPaused = false;
|
||||
|
||||
@state()
|
||||
private toastMessage: string | import("lit").TemplateResult | null = null;
|
||||
@state()
|
||||
private toastColor: "green" | "red" = "green";
|
||||
private toastTimeout: number | null = null;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener(
|
||||
"show-message",
|
||||
this.handleShowMessage as EventListener,
|
||||
);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener(
|
||||
"show-message",
|
||||
this.handleShowMessage as EventListener,
|
||||
);
|
||||
if (this.toastTimeout) {
|
||||
clearTimeout(this.toastTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
private handleShowMessage = (event: CustomEvent) => {
|
||||
const { message, duration, color } = event.detail ?? {};
|
||||
if (
|
||||
typeof message === "string" ||
|
||||
(message && typeof message.values === "object")
|
||||
) {
|
||||
this.toastMessage = message;
|
||||
this.toastColor = color === "red" ? "red" : "green";
|
||||
this.requestUpdate();
|
||||
if (this.toastTimeout) {
|
||||
clearTimeout(this.toastTimeout);
|
||||
}
|
||||
this.toastTimeout = window.setTimeout(
|
||||
() => {
|
||||
this.toastMessage = null;
|
||||
this.requestUpdate();
|
||||
},
|
||||
typeof duration === "number" ? (duration ?? 2000) : 2000,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
init() {
|
||||
this.isVisible = true;
|
||||
this.requestUpdate();
|
||||
@@ -50,21 +97,45 @@ export class HeadsUpMessage extends LitElement implements Layer {
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.isVisible) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
const message = this.getMessage();
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="flex items-center relative
|
||||
w-full justify-evenly h-8 lg:h-10 md:top-17.5 left-0 lg:left-4
|
||||
bg-gray-900/60 rounded-md lg:rounded-lg
|
||||
backdrop-blur-md text-white text-md lg:text-xl p-1 lg:p-2"
|
||||
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
|
||||
>
|
||||
${message}
|
||||
<div style="pointer-events: none;">
|
||||
${this.toastMessage
|
||||
? html`
|
||||
<div
|
||||
class="fixed top-6 left-1/2 -translate-x-1/2 z-[11001] px-6 py-4 rounded-xl transition-all duration-300 animate-fade-in-out"
|
||||
style="max-width: 90vw; min-width: 200px; text-align: center;
|
||||
background: ${this.toastColor === "red"
|
||||
? "rgba(239,68,68,0.1)"
|
||||
: "rgba(34,197,94,0.1)"};
|
||||
border: 1px solid ${this.toastColor === "red"
|
||||
? "rgba(239,68,68,0.5)"
|
||||
: "rgba(34,197,94,0.5)"};
|
||||
color: white;
|
||||
box-shadow: 0 0 30px 0 ${this.toastColor === "red"
|
||||
? "rgba(239,68,68,0.3)"
|
||||
: "rgba(34,197,94,0.3)"};
|
||||
backdrop-filter: blur(12px);"
|
||||
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
|
||||
>
|
||||
${typeof this.toastMessage === "string"
|
||||
? html`<span class="font-medium">${this.toastMessage}</span>`
|
||||
: this.toastMessage}
|
||||
</div>
|
||||
`
|
||||
: null}
|
||||
${this.isVisible
|
||||
? html`
|
||||
<div
|
||||
class="flex items-center relative
|
||||
w-full justify-evenly h-8 lg:h-10 md:top-17.5 left-0 lg:left-4
|
||||
bg-gray-900/60 rounded-md lg:rounded-lg
|
||||
backdrop-blur-md text-white text-md lg:text-xl p-1 lg:p-2"
|
||||
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
|
||||
>
|
||||
${this.getMessage()}
|
||||
</div>
|
||||
`
|
||||
: null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -95,13 +95,11 @@ export class SettingsModal extends LitElement implements Layer {
|
||||
|
||||
public openModal() {
|
||||
this.isVisible = true;
|
||||
document.body.style.overflow = "hidden";
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
public closeModal() {
|
||||
this.isVisible = false;
|
||||
document.body.style.overflow = "";
|
||||
this.requestUpdate();
|
||||
this.pauseGame(false);
|
||||
}
|
||||
@@ -193,7 +191,7 @@ export class SettingsModal extends LitElement implements Layer {
|
||||
@contextmenu=${(e: Event) => e.preventDefault()}
|
||||
>
|
||||
<div
|
||||
class="bg-slate-800 border border-slate-600 rounded-lg shadow-xl max-w-md w-full max-h-[80vh] overflow-y-auto"
|
||||
class="bg-slate-800 border border-slate-600 rounded-lg max-w-md w-full max-h-[80vh] overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between p-4 border-b border-slate-600"
|
||||
|
||||
@@ -226,6 +226,10 @@ export class UnitDisplay extends LitElement implements Layer {
|
||||
}
|
||||
const selected = this.uiState.ghostStructure === unitType;
|
||||
const hovered = this._hoveredUnit === unitType;
|
||||
const displayHotkey = hotkey
|
||||
.replace("Digit", "")
|
||||
.replace("Key", "")
|
||||
.toUpperCase();
|
||||
|
||||
return html`
|
||||
<div
|
||||
@@ -247,7 +251,7 @@ export class UnitDisplay extends LitElement implements Layer {
|
||||
<div class="font-bold text-sm mb-1">
|
||||
${translateText(
|
||||
"unit_type." + structureKey,
|
||||
)}${` [${hotkey.toUpperCase()}]`}
|
||||
)}${` [${displayHotkey}]`}
|
||||
</div>
|
||||
<div class="p-2">
|
||||
${translateText("build_menu.desc." + structureKey)}
|
||||
@@ -299,7 +303,7 @@ export class UnitDisplay extends LitElement implements Layer {
|
||||
this.eventBus?.emit(new ToggleStructureEvent(null))}
|
||||
>
|
||||
${html`<div class="ml-1 text-xs relative -top-1.5 text-gray-400">
|
||||
${hotkey.toUpperCase()}
|
||||
${displayHotkey}
|
||||
</div>`}
|
||||
<div class="flex items-center gap-1 pt-1">
|
||||
<img src=${icon} alt=${structureKey} class="align-middle size-6" />
|
||||
|
||||
+18
-102
@@ -38,19 +38,35 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure the main page never scrolls; modals handle internal scrolling */
|
||||
html {
|
||||
height: 100%; /* Fallback */
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
body {
|
||||
height: 100%;
|
||||
overflow: hidden !important;
|
||||
/* Safe area padding for notched devices */
|
||||
padding: env(safe-area-inset-top) env(safe-area-inset-right)
|
||||
env(safe-area-inset-bottom) env(safe-area-inset-left);
|
||||
}
|
||||
|
||||
* {
|
||||
-webkit-box-sizing: border-box;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
scrollbar-width: auto;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
|
||||
}
|
||||
|
||||
/* Add custom scrollbar styles */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@@ -59,10 +75,6 @@
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.hide-scrollbar {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE/Edge */
|
||||
@@ -376,102 +388,6 @@ label.option-card:hover {
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
#helpModal .city-icon {
|
||||
mask: url("../../resources/images/CityIconWhite.svg") no-repeat center / cover;
|
||||
}
|
||||
|
||||
#helpModal .factory-icon {
|
||||
mask: url("../../resources/images/FactoryIconWhite.svg") no-repeat center /
|
||||
cover;
|
||||
}
|
||||
|
||||
#helpModal .defense-post-icon {
|
||||
mask: url("../../resources/images/ShieldIconWhite.svg") no-repeat center /
|
||||
cover;
|
||||
}
|
||||
|
||||
#helpModal .port-icon {
|
||||
mask: url("../../resources/images/PortIcon.svg") no-repeat center / cover;
|
||||
}
|
||||
|
||||
#helpModal .warship-icon {
|
||||
mask: url("../../resources/images/BattleshipIconWhite.svg") no-repeat center /
|
||||
cover;
|
||||
}
|
||||
|
||||
#helpModal .missile-silo-icon {
|
||||
mask: url("../../resources/images/MissileSiloIconWhite.svg") no-repeat
|
||||
center / cover;
|
||||
}
|
||||
|
||||
#helpModal .sam-launcher-icon {
|
||||
mask: url("../../resources/images/SamLauncherIconWhite.svg") no-repeat
|
||||
center / cover;
|
||||
}
|
||||
|
||||
#helpModal .atom-bomb-icon {
|
||||
mask: url("../../resources/images/NukeIconWhite.svg") no-repeat center / cover;
|
||||
}
|
||||
|
||||
#helpModal .hydrogen-bomb-icon {
|
||||
mask: url("../../resources/images/MushroomCloudIconWhite.svg") no-repeat
|
||||
center / cover;
|
||||
}
|
||||
|
||||
#helpModal .mirv-icon {
|
||||
mask: url("../../resources/images/MIRVIcon.svg") no-repeat center / cover;
|
||||
}
|
||||
|
||||
#helpModal .chat-icon {
|
||||
mask: url("../../resources/images/ChatIconWhite.svg") no-repeat center / cover;
|
||||
}
|
||||
|
||||
#helpModal .target-icon {
|
||||
mask: url("../../resources/images/TargetIcon.svg") no-repeat center / cover;
|
||||
}
|
||||
|
||||
#helpModal .alliance-icon {
|
||||
mask: url("../../resources/images/AllianceIconWhite.svg") no-repeat center /
|
||||
cover;
|
||||
}
|
||||
|
||||
#helpModal .emoji-icon {
|
||||
mask: url("../../resources/images/EmojiIconWhite.svg") no-repeat center /
|
||||
cover;
|
||||
}
|
||||
|
||||
#helpModal .betray-icon {
|
||||
mask: url("../../resources/images/TraitorIconWhite.svg") no-repeat center /
|
||||
cover;
|
||||
}
|
||||
|
||||
#helpModal .donate-icon {
|
||||
mask: url("../../resources/images/DonateTroopIconWhite.svg") no-repeat
|
||||
center / cover;
|
||||
}
|
||||
|
||||
#helpModal .donate-gold-icon {
|
||||
mask: url("../../resources/images/DonateGoldIconWhite.svg") no-repeat center /
|
||||
cover;
|
||||
}
|
||||
|
||||
#helpModal .build-icon {
|
||||
mask: url("../../resources/images/BuildIconWhite.svg") no-repeat center /
|
||||
cover;
|
||||
}
|
||||
|
||||
#helpModal .info-icon {
|
||||
mask: url("../../resources/images/InfoIcon.svg") no-repeat center / cover;
|
||||
}
|
||||
|
||||
#helpModal .boat-icon {
|
||||
mask: url("../../resources/images/BoatIcon.svg") no-repeat center / cover;
|
||||
}
|
||||
|
||||
#helpModal .cancel-icon {
|
||||
mask: url("../../resources/images/XIcon.svg") no-repeat center / cover;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
#helpModal .modal-content {
|
||||
max-height: 90vh;
|
||||
|
||||
@@ -5,10 +5,13 @@
|
||||
outline: none;
|
||||
display: inline-block;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border: 1px solid transparent;
|
||||
text-align: center;
|
||||
padding: 0.8rem 1rem;
|
||||
border-radius: 8px;
|
||||
border-radius: 0.75rem;
|
||||
transition: var(--transition);
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
@@ -21,6 +24,7 @@
|
||||
.c-button:focus {
|
||||
background: var(--primaryColorHover);
|
||||
transition: var(--transition);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.c-button:disabled {
|
||||
|
||||
@@ -1,53 +1,9 @@
|
||||
.c-modal {
|
||||
position: fixed;
|
||||
padding: 1rem;
|
||||
z-index: 1000;
|
||||
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;
|
||||
}
|
||||
/* Deprecated global modal styles.
|
||||
The component-scoped styles in src/client/components/baseComponents/Modal.ts
|
||||
are the single source of truth now. Removing global overrides so the
|
||||
component can control layout and internal scrolling behavior. */
|
||||
|
||||
.c-modal__wrapper {
|
||||
background: #23232382;
|
||||
border-radius: 8px;
|
||||
min-width: 340px;
|
||||
max-width: 860px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.c-modal__close {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: 1rem;
|
||||
}
|
||||
|
||||
.c-modal__content {
|
||||
position: relative;
|
||||
color: #fff;
|
||||
padding: 1.4rem;
|
||||
max-height: 60dvh;
|
||||
overflow-y: scroll;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
/*This will be removed in future*/
|
||||
/* Keep small helper rule for legacy button layout, remove global .c-modal rules */
|
||||
o-modal o-button {
|
||||
@media (min-width: 1024px) {
|
||||
margin: 0 auto;
|
||||
|
||||
@@ -1,27 +1,68 @@
|
||||
.settings-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 12px;
|
||||
align-items: center;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
|
||||
gap: 16px 16px;
|
||||
padding: 12px 8px 20px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.settings-section-title {
|
||||
grid-column: 1 / -1;
|
||||
text-align: left;
|
||||
color: #e5e7eb;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
margin: 4px 6px 2px;
|
||||
}
|
||||
|
||||
.settings-section-heading {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
color: #e5e7eb;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
margin: 14px 0 8px;
|
||||
}
|
||||
|
||||
.settings-section-paragraph {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0 6px 10px;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #333;
|
||||
border-radius: 10px;
|
||||
padding: 12px 20px;
|
||||
width: 360px !important;
|
||||
max-width: 360px !important;
|
||||
min-width: 360px !important;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
|
||||
transition: background 0.3s ease;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
background: #1b1b1b;
|
||||
border: 1px solid #2f2f2f;
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
width: 100% !important;
|
||||
max-width: 720px;
|
||||
min-width: 0 !important;
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35);
|
||||
transition:
|
||||
background 0.25s ease,
|
||||
border-color 0.25s ease;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.settings-list {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
padding: 10px 6px 16px;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
max-width: 100%;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.setting-item.column {
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -121,6 +162,7 @@
|
||||
|
||||
.setting-item:hover {
|
||||
background: #2a2a2a;
|
||||
border-color: #3a3a3a;
|
||||
}
|
||||
|
||||
.setting-item.easter-egg:hover {
|
||||
@@ -141,8 +183,9 @@
|
||||
|
||||
.setting-label {
|
||||
color: #f0f0f0;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.setting-input {
|
||||
@@ -175,13 +218,25 @@
|
||||
}
|
||||
|
||||
.setting-input.slider {
|
||||
-webkit-appearance: none;
|
||||
width: 180px;
|
||||
height: 10px;
|
||||
background: linear-gradient(to right, #2196f3 50%, #444 50%);
|
||||
border-radius: 5px;
|
||||
width: 100%;
|
||||
height: 12px;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
appearance: none;
|
||||
outline: none;
|
||||
transition: background 0.3s;
|
||||
--fill: 0%;
|
||||
}
|
||||
|
||||
.setting-input.slider::-webkit-slider-runnable-track {
|
||||
height: 12px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
#2196f3 0%,
|
||||
#2196f3 var(--fill),
|
||||
#444 var(--fill),
|
||||
#444 100%
|
||||
);
|
||||
}
|
||||
|
||||
.setting-input.slider::-webkit-slider-thumb {
|
||||
@@ -292,18 +347,19 @@
|
||||
|
||||
.setting-keybind-box {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 12px 16px;
|
||||
}
|
||||
|
||||
.setting-keybind-description {
|
||||
flex: 1;
|
||||
font-size: 0.75rem;
|
||||
color: #e5e5e5;
|
||||
flex: 1 1 clamp(320px, 45vw, 620px);
|
||||
font-size: 0.95rem;
|
||||
color: #e5e7eb;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.setting-key {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// renderUnitTypeOptions.ts
|
||||
import { html, TemplateResult } from "lit";
|
||||
import { UnitType } from "../../core/game/Game";
|
||||
import { translateText } from "../Utils";
|
||||
@@ -25,26 +24,24 @@ export function renderUnitTypeOptions({
|
||||
disabledUnits,
|
||||
toggleUnit,
|
||||
}: UnitTypeRenderContext): TemplateResult[] {
|
||||
return unitOptions.map(
|
||||
({ type, translationKey }) => html`
|
||||
<label
|
||||
class="option-card ${disabledUnits.includes(type)
|
||||
? ""
|
||||
: "selected"} w-35"
|
||||
return unitOptions.map(({ type, translationKey }) => {
|
||||
const isEnabled = !disabledUnits.includes(type);
|
||||
return html`
|
||||
<button
|
||||
class="relative p-4 rounded-xl border transition-all duration-200 flex flex-col items-center justify-center gap-2 min-h-[100px] w-full cursor-pointer ${isEnabled
|
||||
? "bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
|
||||
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20 opacity-80"}"
|
||||
aria-pressed="${isEnabled}"
|
||||
@click=${() => toggleUnit(type, isEnabled)}
|
||||
>
|
||||
<div class="checkbox-icon"></div>
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${disabledUnits.includes(type)}
|
||||
@change=${(e: Event) => {
|
||||
const checked = (e.target as HTMLInputElement).checked;
|
||||
toggleUnit(type, checked);
|
||||
}}
|
||||
/>
|
||||
<div class="option-card-title text-center">
|
||||
<div
|
||||
class="text-xs uppercase font-bold tracking-wider text-center w-full leading-tight break-words hyphens-auto ${isEnabled
|
||||
? "text-white"
|
||||
: "text-white/60"}"
|
||||
>
|
||||
${translateText(translationKey)}
|
||||
</div>
|
||||
</label>
|
||||
`,
|
||||
);
|
||||
</button>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
Vendored
+20
@@ -10,6 +10,11 @@ declare module "*.md" {
|
||||
export default mdContent;
|
||||
}
|
||||
|
||||
declare module "*.md?url" {
|
||||
const mdUrl: string;
|
||||
export default mdUrl;
|
||||
}
|
||||
|
||||
declare module "*.html" {
|
||||
const htmlContent: string;
|
||||
export default htmlContent;
|
||||
@@ -20,7 +25,22 @@ declare module "*.xml" {
|
||||
export default xmlContent;
|
||||
}
|
||||
|
||||
declare module "*.txt" {
|
||||
const txtContent: string;
|
||||
export default txtContent;
|
||||
}
|
||||
|
||||
declare module "*.txt?raw" {
|
||||
const txtRawContent: string;
|
||||
export default txtRawContent;
|
||||
}
|
||||
|
||||
declare module "*.webp" {
|
||||
const webpContent: string;
|
||||
export default webpContent;
|
||||
}
|
||||
|
||||
declare module "*.svg?url" {
|
||||
const svgUrl: string;
|
||||
export default svgUrl;
|
||||
}
|
||||
|
||||
+10
-1
@@ -349,9 +349,18 @@ export function getClanTagOriginalCase(name: string): string | null {
|
||||
return clanTag ? clanTag[1] : null;
|
||||
}
|
||||
|
||||
const CLAN_TAG_CHARS = "a-zA-Z0-9";
|
||||
|
||||
const CLAN_TAG_INVALID_CHARS = new RegExp(`[^${CLAN_TAG_CHARS}]`, "g");
|
||||
const CLAN_TAG_REGEX = new RegExp(`\\[([${CLAN_TAG_CHARS}]{2,5})\\]`);
|
||||
|
||||
export function sanitizeClanTag(tag: string): string {
|
||||
return tag.replace(CLAN_TAG_INVALID_CHARS, "").substring(0, 5).toUpperCase();
|
||||
}
|
||||
|
||||
function clanMatch(name: string): RegExpMatchArray | null {
|
||||
if (!name.includes("[") || !name.includes("]")) {
|
||||
return null;
|
||||
}
|
||||
return name.match(/\[([a-zA-Z0-9]{2,5})\]/);
|
||||
return name.match(CLAN_TAG_REGEX);
|
||||
}
|
||||
|
||||
@@ -80,10 +80,7 @@ export function censorNameWithClanTag(username: string): string {
|
||||
|
||||
// Restore clan tag if it existed and is not profane
|
||||
if (clanTag && !clanTagIsProfane) {
|
||||
if (usernameIsProfane) {
|
||||
return `[${clanTag}] ${censoredNameWithoutClan}`;
|
||||
}
|
||||
return username;
|
||||
return `[${clanTag.toUpperCase()}] ${censoredNameWithoutClan}`;
|
||||
}
|
||||
|
||||
// Don't restore profane or nonexistent clan tag
|
||||
|
||||
@@ -53,7 +53,7 @@ describe("FluentSlider", () => {
|
||||
|
||||
describe("Value Updates from Range Slider", () => {
|
||||
it("should update value when slider input changes", async () => {
|
||||
const rangeInput = slider.shadowRoot?.querySelector(
|
||||
const rangeInput = slider.querySelector(
|
||||
'input[type="range"]',
|
||||
) as HTMLInputElement;
|
||||
expect(rangeInput).toBeTruthy();
|
||||
@@ -67,7 +67,7 @@ describe("FluentSlider", () => {
|
||||
});
|
||||
|
||||
it("should update value when slider change event fires", async () => {
|
||||
const rangeInput = slider.shadowRoot?.querySelector(
|
||||
const rangeInput = slider.querySelector(
|
||||
'input[type="range"]',
|
||||
) as HTMLInputElement;
|
||||
|
||||
@@ -84,7 +84,7 @@ describe("FluentSlider", () => {
|
||||
const eventSpy = vi.fn();
|
||||
slider.addEventListener("value-changed", eventSpy);
|
||||
|
||||
const rangeInput = slider.shadowRoot?.querySelector(
|
||||
const rangeInput = slider.querySelector(
|
||||
'input[type="range"]',
|
||||
) as HTMLInputElement;
|
||||
rangeInput.valueAsNumber = 200;
|
||||
@@ -107,7 +107,7 @@ describe("FluentSlider", () => {
|
||||
const eventSpy = vi.fn();
|
||||
slider.addEventListener("value-changed", eventSpy);
|
||||
|
||||
const rangeInput = slider.shadowRoot?.querySelector(
|
||||
const rangeInput = slider.querySelector(
|
||||
'input[type="range"]',
|
||||
) as HTMLInputElement;
|
||||
|
||||
@@ -138,7 +138,7 @@ describe("FluentSlider", () => {
|
||||
|
||||
slider.addEventListener("value-changed", mockHandler);
|
||||
|
||||
const rangeInput = slider.shadowRoot?.querySelector(
|
||||
const rangeInput = slider.querySelector(
|
||||
'input[type="range"]',
|
||||
) as HTMLInputElement;
|
||||
rangeInput.valueAsNumber = 250;
|
||||
@@ -163,7 +163,7 @@ describe("FluentSlider", () => {
|
||||
|
||||
slider.addEventListener("value-changed", mockHandler);
|
||||
|
||||
const rangeInput = slider.shadowRoot?.querySelector(
|
||||
const rangeInput = slider.querySelector(
|
||||
'input[type="range"]',
|
||||
) as HTMLInputElement;
|
||||
rangeInput.valueAsNumber = 350;
|
||||
@@ -221,9 +221,7 @@ describe("FluentSlider", () => {
|
||||
|
||||
describe("Component Structure", () => {
|
||||
it("should render a range input", () => {
|
||||
const rangeInput = slider.shadowRoot?.querySelector(
|
||||
'input[type="range"]',
|
||||
);
|
||||
const rangeInput = slider.querySelector('input[type="range"]');
|
||||
expect(rangeInput).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -233,7 +231,7 @@ describe("FluentSlider", () => {
|
||||
slider.max = 400;
|
||||
slider.step = 1;
|
||||
|
||||
const rangeInput = slider.shadowRoot?.querySelector(
|
||||
const rangeInput = slider.querySelector(
|
||||
'input[type="range"]',
|
||||
) as HTMLInputElement;
|
||||
|
||||
@@ -242,11 +240,11 @@ describe("FluentSlider", () => {
|
||||
expect(rangeInput.step).toBe("1");
|
||||
});
|
||||
|
||||
it("should render an editable span for the value display", () => {
|
||||
const editableSpan = slider.shadowRoot?.querySelector("span.editable");
|
||||
expect(editableSpan).toBeTruthy();
|
||||
expect(editableSpan?.getAttribute("role")).toBe("button");
|
||||
expect(editableSpan?.getAttribute("tabindex")).toBe("0");
|
||||
it("should render a span for the value display with role button", () => {
|
||||
const valueSpan = slider.querySelector('span[role="button"]');
|
||||
expect(valueSpan).toBeTruthy();
|
||||
expect(valueSpan?.getAttribute("role")).toBe("button");
|
||||
expect(valueSpan?.getAttribute("tabindex")).toBe("0");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -262,7 +260,7 @@ describe("FluentSlider", () => {
|
||||
slider.value = 0;
|
||||
await slider.updateComplete;
|
||||
|
||||
const rangeInput = slider.shadowRoot?.querySelector(
|
||||
const rangeInput = slider.querySelector(
|
||||
'input[type="range"]',
|
||||
) as HTMLInputElement;
|
||||
|
||||
@@ -279,4 +277,19 @@ describe("FluentSlider", () => {
|
||||
expect(slider.value).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("should handle min equal to max without NaN in style", async () => {
|
||||
slider.min = 100;
|
||||
slider.max = 100;
|
||||
slider.value = 100;
|
||||
await slider.updateComplete;
|
||||
|
||||
const rangeInput = slider.querySelector('input[type="range"]');
|
||||
const style = rangeInput?.getAttribute("style");
|
||||
|
||||
expect(style).not.toContain("NaN");
|
||||
expect(style).toContain("0%");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user