diff --git a/index.html b/index.html index 36e62b80a..a6e541a60 100644 --- a/index.html +++ b/index.html @@ -127,552 +127,102 @@ - + >
- +
+ -
-
-
- + + - -
-
- -
- - + + + + + + + + + + + + + - - -
+ + - -
- -
- - - -
- - - -
- - -
- -
- - - - - - - - - -
- - -
-
-
-
-
- - - - - -
-
-
-
-
-
- - - - - - - - - - - - - - - - -
-
-
-
${this.renderLinkAccountSection()}
-
- `; + return this.renderLoginOptions(); } return html` @@ -235,60 +231,6 @@ export class AccountModal extends BaseModal { `; } - private renderLinkAccountSection(): TemplateResult { - return html` -
-
-
-

- ${translateText("account_modal.save_progress_title")} -

-

- ${translateText("account_modal.save_progress_desc")} -

-
-
- -
- - -
-
- - -
-
-
-
- `; - } - private renderLoggedInAs(): TemplateResult { const me = this.userMeResponse?.user; if (me?.discord) { @@ -309,46 +251,7 @@ export class AccountModal extends BaseModal {
`; } - - // "Mini" Login Options for linking account - return html` -
- - -
-
- - -
-
-
- `; + return html``; } private viewGame(gameId: string): void { @@ -398,11 +301,6 @@ export class AccountModal extends BaseModal {
-

- ${translateText("account_modal.welcome_back")} -

${translateText("account_modal.sign_in_desc")}

@@ -414,9 +312,6 @@ export class AccountModal extends BaseModal { @click="${this.handleDiscordLogin}" 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" > -
Discord -
- - - - -
`; } @@ -81,6 +110,11 @@ export class FlagInput extends LitElement { ) as HTMLElement; if (!preview) return; + if (this.showSelectLabel && this.isDefaultFlagValue(this.flag)) { + preview.innerHTML = ""; + return; + } + preview.innerHTML = ""; if (this.flag?.startsWith("!")) { diff --git a/src/client/FlagInputModal.ts b/src/client/FlagInputModal.ts index 0fba25ccf..a4c6ad10f 100644 --- a/src/client/FlagInputModal.ts +++ b/src/client/FlagInputModal.ts @@ -18,7 +18,7 @@ export class FlagInputModal extends BaseModal { render() { const content = html`
diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 8d87feddf..c995d9fec 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -102,7 +102,7 @@ export class HostLobbyModal extends BaseModal { render() { const content = html`
+
@@ -614,7 +633,7 @@ export class HostLobbyModal extends BaseModal { min="0" max="120" .value=${String(this.maxTimerValue ?? 0)} - class="w-full text-center rounded bg-black/40 text-white text-sm font-bold border border-white/20 focus:outline-none focus:border-blue-500 p-1 my-1" + class="w-full text-center rounded bg-black/60 text-white text-sm font-bold border border-white/20 focus:outline-none focus:border-blue-500 p-1 my-1" @click=${(e: Event) => e.stopPropagation()} @input=${this.handleMaxTimerValueChanges} @keydown=${this.handleMaxTimerValueKeyDown} @@ -694,7 +713,7 @@ export class HostLobbyModal extends BaseModal { .value=${String( this.spawnImmunityDurationMinutes ?? 0, )} - class="w-full text-center rounded bg-black/40 text-white text-sm font-bold border border-white/20 focus:outline-none focus:border-blue-500 p-1 my-1" + class="w-full text-center rounded bg-black/60 text-white text-sm font-bold border border-white/20 focus:outline-none focus:border-blue-500 p-1 my-1" @click=${(e: Event) => e.stopPropagation()} @input=${this.handleSpawnImmunityDurationInput} @keydown=${this.handleSpawnImmunityDurationKeyDown} diff --git a/src/client/JoinPrivateLobbyModal.ts b/src/client/JoinPrivateLobbyModal.ts index f6531472f..30c5875e1 100644 --- a/src/client/JoinPrivateLobbyModal.ts +++ b/src/client/JoinPrivateLobbyModal.ts @@ -38,7 +38,7 @@ export class JoinPrivateLobbyModal extends BaseModal { render() { const content = html`
+
` : ""}
diff --git a/src/client/KeybindsModal.ts b/src/client/KeybindsModal.ts deleted file mode 100644 index 2eadfd4c4..000000000 --- a/src/client/KeybindsModal.ts +++ /dev/null @@ -1,522 +0,0 @@ -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 = { - 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 = { ...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` - - - - - ${(() => { - const message = translateText( - "user_setting.keybind_conflict_error", - { key: displayKey }, - ); - const parts = message.split(displayKey); - return html`${parts[0]}${displayKey}${parts[1] || ""}`; - })()} - - `, - 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` -
-
-
- - - ${translateText("user_setting.tab_keybinds")} - -
-
- -
-
${this.renderKeybindSettings()}
-
-
- `; - - if (this.inline) { - return content; - } - - return html` - - ${content} - - `; - } - - private renderKeybindSettings() { - return html` -

- ${translateText("user_setting.view_options")} -

- - - -

- ${translateText("user_setting.build_controls")} -

- - - - - - - - - - - - - - - - - - - - - -

- ${translateText("user_setting.attack_ratio_controls")} -

- - - - - -

- ${translateText("user_setting.attack_keybinds")} -

- - - - - -

- ${translateText("user_setting.zoom_controls")} -

- - - - - -

- ${translateText("user_setting.camera_movement")} -

- - - - - - - - - - - `; - } - - protected onOpen(): void { - this.requestUpdate(); - } -} diff --git a/src/client/LangSelector.ts b/src/client/LangSelector.ts index 3d446f348..1ee3329c7 100644 --- a/src/client/LangSelector.ts +++ b/src/client/LangSelector.ts @@ -220,11 +220,11 @@ export class LangSelector extends LitElement { "o-modal", "o-button", "territory-patterns-modal", + "pattern-input", "fluent-slider", "news-modal", "news-button", "account-modal", - "keybinds-modal", "stats-modal", "flag-input-modal", "flag-input", @@ -243,6 +243,27 @@ export class LangSelector extends LitElement { element.textContent = text; }); + const applyAttributeTranslation = ( + dataAttr: string, + targetAttr: string, + ): void => { + document.querySelectorAll(`[${dataAttr}]`).forEach((element) => { + const key = element.getAttribute(dataAttr); + if (key === null) return; + const text = this.translateText(key); + if (text === null) { + console.warn(`Translation key not found: ${key}`); + return; + } + element.setAttribute(targetAttr, text); + }); + }; + + applyAttributeTranslation("data-i18n-title", "title"); + applyAttributeTranslation("data-i18n-alt", "alt"); + applyAttributeTranslation("data-i18n-aria-label", "aria-label"); + applyAttributeTranslation("data-i18n-placeholder", "placeholder"); + components.forEach((tag) => { document.querySelectorAll(tag).forEach((el) => { if (typeof (el as any).requestUpdate === "function") { diff --git a/src/client/LanguageModal.ts b/src/client/LanguageModal.ts index 3f1f8d894..1a29cc4d3 100644 --- a/src/client/LanguageModal.ts +++ b/src/client/LanguageModal.ts @@ -32,7 +32,7 @@ export class LanguageModal extends BaseModal {
diff --git a/src/client/Layout.ts b/src/client/Layout.ts index 4e33ab2bb..e138f9706 100644 --- a/src/client/Layout.ts +++ b/src/client/Layout.ts @@ -1,81 +1,84 @@ export function initLayout() { - const hb = document.getElementById("hamburger-btn"); - const sidebar = document.getElementById("sidebar-menu"); - const backdrop = document.getElementById("mobile-menu-backdrop"); + // Wait for play-page component to render before setting up hamburger menu + customElements.whenDefined("play-page").then(() => { + 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(); + // Force sidebar visibility style to ensure it's not hidden by other CSS + if (sidebar && window.innerWidth < 768) { + sidebar.style.display = "flex"; } - const opening = !sidebar.classList.contains("open"); - if (opening) { - openMenu(); - } else { - closeMenu(); + if (!hb) { + console.error("Hamburger button not found"); + return; } - }; - hb.addEventListener("click", toggle); + // Disable fallback inline handler now that JS is loaded + hb.onclick = null; - 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(); + if (!sidebar) { + console.error("Sidebar menu not found"); + return; } - }); - - // Close on Escape (Mobile only) - document.addEventListener("keydown", (e) => { - if (window.innerWidth >= 768) return; - if (e.key === "Escape" && sidebar.classList.contains("open")) { - closeMenu(); + 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(); + } + }); }); } diff --git a/src/client/Main.ts b/src/client/Main.ts index 4d91c0880..1b4a86cb7 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -23,7 +23,6 @@ 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 { initLayout } from "./Layout"; @@ -31,6 +30,7 @@ import "./Matchmaking"; import { MatchmakingModal } from "./Matchmaking"; import { initNavigation } from "./Navigation"; import "./NewsModal"; +import "./PatternInput"; import "./PublicLobby"; import { PublicLobby } from "./PublicLobby"; import { SinglePlayerModal } from "./SinglePlayerModal"; @@ -44,7 +44,17 @@ import { import { UserSettingModal } from "./UserSettingModal"; import "./UsernameInput"; import { UsernameInput } from "./UsernameInput"; -import { incrementGamesPlayed, isInIframe } from "./Utils"; +import { + getDiscordAvatarUrl, + incrementGamesPlayed, + isInIframe, + translateText, +} from "./Utils"; +import "./components/DesktopNavBar"; +import "./components/Footer"; +import "./components/MainLayout"; +import "./components/MobileNavBar"; +import "./components/PlayPage"; import "./components/baseComponents/Button"; import "./components/baseComponents/Modal"; import "./styles.css"; @@ -54,6 +64,100 @@ import "./styles/layout/container.css"; import "./styles/layout/header.css"; import "./styles/modal/chat.css"; +function updateAccountNavButton(userMeResponse: UserMeResponse | false) { + const button = document.getElementById("nav-account-button"); + if (!button) return; + + const avatarEl = document.getElementById("nav-account-avatar") as + | (HTMLImageElement & { _navToken?: symbol }) + | null; + const personIconEl = document.getElementById( + "nav-account-person-icon", + ) as SVGElement | null; + const emailBadgeEl = document.getElementById( + "nav-account-email-badge", + ) as HTMLElement | null; + const signInTextEl = document.getElementById( + "nav-account-signin-text", + ) as HTMLSpanElement | null; + + // Unique token for this update call + const navToken = Symbol(); + if (avatarEl) avatarEl._navToken = navToken; + + const showAvatar = (src: string, alt?: string) => { + if (avatarEl) { + avatarEl.alt = alt ?? translateText("main.discord_avatar_alt"); + // If the avatar fails to load (bad URL / CDN issue / offline), fall back + // to the default sign-in UI instead of leaving a broken image. + avatarEl.onerror = () => { + // Only handle if this is the latest update + if (avatarEl._navToken !== navToken) return; + avatarEl.src = ""; + // If the user is still logged in via email, show the email badge state. + const email = + userMeResponse !== false ? userMeResponse.user.email : undefined; + if (email) { + showEmailLoggedIn(); + } else { + showSignIn(); + } + }; + avatarEl.onload = () => { + // Only handle if this is the latest update + if (avatarEl._navToken !== navToken) return; + // Clear error handler after a successful load. + avatarEl.onerror = null; + }; + avatarEl.src = src; + avatarEl.classList.remove("hidden"); + } + personIconEl?.classList.add("hidden"); + emailBadgeEl?.classList.add("hidden"); + signInTextEl?.classList.add("hidden"); + button?.classList.remove("border", "border-white/20"); + }; + + const showSignIn = () => { + avatarEl?.classList.add("hidden"); + personIconEl?.classList.remove("hidden"); + emailBadgeEl?.classList.add("hidden"); + signInTextEl?.classList.remove("hidden"); + // Restore border when showing signin state + button?.classList.add("border", "border-white/20"); + }; + + const showEmailLoggedIn = () => { + avatarEl?.classList.add("hidden"); + personIconEl?.classList.remove("hidden"); + emailBadgeEl?.classList.remove("hidden"); + signInTextEl?.classList.add("hidden"); + button?.classList.add("border", "border-white/20"); + }; + + const discord = + userMeResponse !== false ? userMeResponse.user.discord : undefined; + if (discord && avatarEl) { + const avatarAlt = translateText("main.user_avatar_alt", { + username: discord.username, + }); + const url = getDiscordAvatarUrl(discord); + if (url) { + showAvatar(url, avatarAlt); + return; + } + } + + const email = + userMeResponse !== false ? userMeResponse.user.email : undefined; + if (email) { + showEmailLoggedIn(); + return; + } + + showSignIn(); +} + declare global { interface Window { turnstile: any; @@ -129,6 +233,10 @@ class Client { // the user joins a lobby. this.turnstileTokenPromise = getTurnstileToken(); + // Wait for components to render before setting version + await customElements.whenDefined("mobile-nav-bar"); + await customElements.whenDefined("desktop-nav-bar"); + const versionElements = document.querySelectorAll( "#game-version, .game-version-display", ); @@ -233,25 +341,13 @@ class Client { console.warn("Flag input modal element not found"); } - // 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; + // Attach listener to any flag-input component (desktop or potentially others) + document.querySelectorAll("flag-input").forEach((flagInput) => { + flagInput.addEventListener("flag-input-click", () => { + if (flagInputModal && flagInputModal instanceof FlagInputModal) { + flagInputModal.open(); } - flagButton.addEventListener("click", (e) => { - e.preventDefault(); - e.stopPropagation(); - if (flagInputModal && flagInputModal instanceof FlagInputModal) { - flagInputModal.open(); - } - }); - }, 100); + }); }); this.patternsModal = document.getElementById( @@ -263,49 +359,27 @@ class Client { ) { console.warn("Territory patterns modal element not found"); } - const patternButton = document.getElementById( - "territory-patterns-input-preview-button", - ); - if (isInIframe() && patternButton) { - 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); + // Attach listener to any pattern-input component + document.querySelectorAll("pattern-input").forEach((patternInput) => { + patternInput.addEventListener("pattern-input-click", () => { + // Open the Store page which contains the patterns UI + 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 }); } } - }; - moveButtonBasedOnScreenSize(); - window.addEventListener("resize", moveButtonBasedOnScreenSize); + }); + }); + + if (isInIframe()) { + const mobilePat = document.getElementById("pattern-input-mobile"); + if (mobilePat) mobilePat.style.display = "none"; } if ( @@ -314,13 +388,17 @@ class Client { ) { console.warn("Territory patterns modal element not found"); } - if (patternButton === null) - throw new Error("territory-patterns-input-preview-button"); - this.patternsModal.previewButton = patternButton; + + // We no longer need to manually manage the preview button as PatternInput handles it component-side. + // However, we still want to ensure the modal can be opened. + // The setupPatternInput above handles the click event for the new buttons. + this.patternsModal.refresh(); - // Listen for pattern selection to update preview button + + // Listen for pattern selection to update any other listeners if needed, + // though PatternInput handles its own updates via window event. this.patternsModal.addEventListener("pattern-selected", () => { - this.patternsModal.refresh(); + // PatternInput components will update themselves. }); window.addEventListener("showPage", (e: any) => { @@ -331,19 +409,6 @@ class Client { } }); - patternButton.addEventListener("click", () => { - 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( "token-login", ) as TokenLoginModal; @@ -397,6 +462,12 @@ class Client { }); } + if (matchmakingButtonLoggedOut) { + matchmakingButtonLoggedOut.addEventListener("click", () => { + window.showPage?.("page-account"); + }); + } + const onUserMe = async (userMeResponse: UserMeResponse | false) => { // Check if user has actual authentication (discord or email), not just a publicId const loggedIn = @@ -407,6 +478,7 @@ class Client { (userMeResponse.user.discord !== undefined || userMeResponse.user.email !== undefined); updateMatchmakingButton(loggedIn); + updateAccountNavButton(userMeResponse); document.dispatchEvent( new CustomEvent("userMeResponse", { detail: userMeResponse, diff --git a/src/client/Matchmaking.ts b/src/client/Matchmaking.ts index 59d1fbd0d..a0fff3cdd 100644 --- a/src/client/Matchmaking.ts +++ b/src/client/Matchmaking.ts @@ -48,7 +48,7 @@ export class MatchmakingModal extends BaseModal { const content = html`
{ - // Hide all pages - document.querySelectorAll(".page-content").forEach((el) => { - el.classList.add("hidden"); - el.classList.remove("block"); - }); - document.getElementById("page-play")?.classList.add("hidden"); + (window as any).currentPageId = pageId; - 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"); - } + // Hide only the currently visible modal + const visibleModal = document.querySelector(".page-content:not(.hidden)"); + if (visibleModal) { + visibleModal.classList.add("hidden"); + visibleModal.classList.remove("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(); + // Handle page-play separately (it's not a page-content element) + const pagePlayEl = document.getElementById("page-play"); + if (pageId === "page-play") { + pagePlayEl?.classList.remove("hidden"); + } else { + pagePlayEl?.classList.add("hidden"); + } + + // Show the target page if it's a modal + if (pageId !== "page-play") { + 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(); + } } } @@ -39,39 +52,63 @@ export function initNavigation() { window.showPage = showPage; - document.querySelectorAll(".nav-menu-item[data-page]").forEach((el) => { - el.addEventListener("click", () => { - const pageId = (el as HTMLElement).dataset.page; + // Use event delegation for navigation items (they may be inside Lit components) + document.addEventListener("click", (e) => { + const target = (e.target as HTMLElement).closest( + ".nav-menu-item[data-page]", + ); + if (target) { + const pageId = (target 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"); + // Wait for main-layout component to render before setting up click handler + customElements.whenDefined("main-layout").then(() => { + // Handle clicks on main container to close open modals (navigate back) + const mainEl = document.querySelector("main"); - // 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"); + 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) { + // Close modal if clicking on main element itself, or directly on a page-content element + const isOnMain = target === mainEl; + const isOnPageContent = target.classList.contains("page-content"); + + if (isOnMain || isOnPageContent) { + // Find the open modal and call its close() method instead of showPage directly + // This ensures proper cleanup (like websocket disconnection) + const openModal = document.querySelector( + ".page-content:not(.hidden)", + ) as any; + + if (openModal && typeof openModal.close === "function") { + // Call leaveLobby or closeAndLeave first if it exists (for lobby modals) + if (typeof openModal.leaveLobby === "function") { + openModal.leaveLobby(); + } else if (typeof openModal.closeAndLeave === "function") { + openModal.closeAndLeave(); + return; // closeAndLeave already calls close() + } + openModal.close(); + } else { + 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")) { + // Set default page to play if no menu item is active + const anyActive = document.querySelector(".nav-menu-item.active"); + if (!anyActive) { showPage("page-play"); } } diff --git a/src/client/NewsModal.ts b/src/client/NewsModal.ts index 2cca111c7..66693e614 100644 --- a/src/client/NewsModal.ts +++ b/src/client/NewsModal.ts @@ -18,7 +18,7 @@ export class NewsModal extends BaseModal { const content = html`
| null = null; + +function getCachedCosmetics(): Promise { + if (!cosmeticsCache) { + const fetchPromise = fetchCosmetics(); + cosmeticsCache = fetchPromise.catch((err) => { + cosmeticsCache = null; + throw err; + }); + } + return cosmeticsCache; +} + +@customElement("pattern-input") +export class PatternInput extends LitElement { + @state() public pattern: PlayerPattern | null = null; + @state() public selectedColor: string | null = null; + @state() private isLoading: boolean = true; + + @property({ type: Boolean, attribute: "show-select-label" }) + public showSelectLabel: boolean = false; + + private userSettings = new UserSettings(); + private cosmetics: Cosmetics | null = null; + private _abortController: AbortController | null = null; + + private _onPatternSelected = () => { + this.updateFromSettings(); + }; + + private updateFromSettings() { + this.selectedColor = this.userSettings.getSelectedColor() ?? null; + + if (this.cosmetics) { + this.pattern = this.userSettings.getSelectedPatternName(this.cosmetics); + } else { + this.pattern = null; + } + } + + private onInputClick(e: Event) { + e.preventDefault(); + e.stopPropagation(); + this.dispatchEvent( + new CustomEvent("pattern-input-click", { + bubbles: true, + composed: true, + }), + ); + } + + async connectedCallback() { + super.connectedCallback(); + this._abortController = new AbortController(); + this.isLoading = true; + const cosmetics = await getCachedCosmetics(); + if (!this.isConnected) return; + this.cosmetics = cosmetics; + this.updateFromSettings(); + if (!this.isConnected) return; + this.isLoading = false; + window.addEventListener("pattern-selected", this._onPatternSelected, { + signal: this._abortController.signal, + }); + } + + disconnectedCallback() { + super.disconnectedCallback(); + if (this._abortController) { + this._abortController.abort(); + this._abortController = null; + } + } + + createRenderRoot() { + return this; + } + + render() { + const isDefault = this.pattern === null && this.selectedColor === null; + const showSelect = this.showSelectLabel && isDefault; + const buttonTitle = translateText("territory_patterns.title"); + + // Show loading state + if (this.isLoading) { + return html` + + `; + } + + let previewContent; + if (this.pattern) { + previewContent = renderPatternPreview(this.pattern, 128, 128); + } else { + previewContent = renderPatternPreview(null, 128, 128); + } + + return html` + + `; + } +} diff --git a/src/client/PublicLobby.ts b/src/client/PublicLobby.ts index c5ac27ffb..c7516804d 100644 --- a/src/client/PublicLobby.ts +++ b/src/client/PublicLobby.ts @@ -26,7 +26,7 @@ export class PublicLobby extends LitElement { private joiningInterval: number | null = null; private currLobby: GameInfo | null = null; - private debounceDelay: number = 750; + private debounceDelay: number = 150; private lobbyIDToStart = new Map(); private lobbySocket = new PublicLobbySocket((lobbies) => this.handleLobbiesUpdate(lobbies), @@ -124,111 +124,124 @@ export class PublicLobby extends LitElement {
+ +
-
${this.renderBasicSettings()}
+
${activeContent}
`; @@ -260,6 +473,266 @@ export class UserSettingModal extends BaseModal { window.removeEventListener("keydown", this.handleEasterEggKey); } + private renderKeybindSettings() { + return html` +

+ ${translateText("user_setting.view_options")} +

+ + + +

+ ${translateText("user_setting.build_controls")} +

+ + + + + + + + + + + + + + + + + + + + + +

+ ${translateText("user_setting.attack_ratio_controls")} +

+ + + + + +

+ ${translateText("user_setting.attack_keybinds")} +

+ + + + + +

+ ${translateText("user_setting.zoom_controls")} +

+ + + + + +

+ ${translateText("user_setting.camera_movement")} +

+ + + + + + + + + + + `; + } + private renderBasicSettings() { return html` @@ -450,6 +923,7 @@ export class UserSettingModal extends BaseModal { protected onOpen(): void { window.addEventListener("keydown", this.handleEasterEggKey); + this.loadKeybindsFromStorage(); } public open() { diff --git a/src/client/UsernameInput.ts b/src/client/UsernameInput.ts index 84e8e8e8e..20b2bb372 100644 --- a/src/client/UsernameInput.ts +++ b/src/client/UsernameInput.ts @@ -63,7 +63,7 @@ export class UsernameInput extends LitElement { @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" + class="w-[6rem] 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" />
${this.validationError diff --git a/src/client/Utils.ts b/src/client/Utils.ts index 93f51026b..2f4e0dafd 100644 --- a/src/client/Utils.ts +++ b/src/client/Utils.ts @@ -393,3 +393,29 @@ export async function getSvgAspectRatio(src: string): Promise { return null; } + +export function getDiscordAvatarUrl(user: { + id: string; + avatar: string | null; + discriminator?: string; +}): string | null { + if (user.avatar) { + // - id is a Discord numeric string + // - avatar is a hash, optionally prefixed with "a_" for animated avatars + const validId = /^\d+$/.test(user.id); + const validAvatar = + /^[a-f0-9]+$/.test(user.avatar) || /^a_[a-f0-9]+$/.test(user.avatar); + + if (validId && validAvatar) { + const extension = user.avatar.startsWith("a_") ? "gif" : "png"; + return `https://cdn.discordapp.com/avatars/${encodeURIComponent(user.id)}/${encodeURIComponent(user.avatar)}.${extension}?size=64`; + } + } + + if (user.discriminator !== undefined) { + const idx = Number(user.discriminator) % 5; + return `https://cdn.discordapp.com/embed/avatars/${idx}.png`; + } + + return null; +} diff --git a/src/client/components/DesktopNavBar.ts b/src/client/components/DesktopNavBar.ts new file mode 100644 index 000000000..74ea1b498 --- /dev/null +++ b/src/client/components/DesktopNavBar.ts @@ -0,0 +1,187 @@ +import { LitElement, html } from "lit"; +import { customElement } from "lit/decorators.js"; + +@customElement("desktop-nav-bar") +export class DesktopNavBar extends LitElement { + createRenderRoot() { + return this; + } + + connectedCallback() { + super.connectedCallback(); + window.addEventListener("showPage", this._onShowPage); + + const current = (window as any).currentPageId; + if (current) { + // Wait for render + this.updateComplete.then(() => { + this._updateActiveState(current); + }); + } + } + + disconnectedCallback() { + super.disconnectedCallback(); + window.removeEventListener("showPage", this._onShowPage); + } + + private _onShowPage = (e: Event) => { + const pageId = (e as CustomEvent).detail; + this._updateActiveState(pageId); + }; + + private _updateActiveState(pageId: string) { + this.querySelectorAll(".nav-menu-item").forEach((el) => { + if ((el as HTMLElement).dataset.page === pageId) { + el.classList.add("active"); + } else { + el.classList.remove("active"); + } + }); + } + + render() { + return html` + + `; + } +} diff --git a/src/client/components/FluentSlider.ts b/src/client/components/FluentSlider.ts index f70de7c3e..ce2f1e5af 100644 --- a/src/client/components/FluentSlider.ts +++ b/src/client/components/FluentSlider.ts @@ -106,7 +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" + class="w-[60px] bg-black/60 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; diff --git a/src/client/components/Footer.ts b/src/client/components/Footer.ts new file mode 100644 index 000000000..8858796d1 --- /dev/null +++ b/src/client/components/Footer.ts @@ -0,0 +1,92 @@ +import { LitElement, html } from "lit"; +import { customElement } from "lit/decorators.js"; + +@customElement("page-footer") +export class Footer extends LitElement { + createRenderRoot() { + return this; + } + + render() { + return html` + + `; + } +} diff --git a/src/client/components/MainLayout.ts b/src/client/components/MainLayout.ts new file mode 100644 index 000000000..f155deed9 --- /dev/null +++ b/src/client/components/MainLayout.ts @@ -0,0 +1,32 @@ +import { LitElement, html } from "lit"; +import { customElement } from "lit/decorators.js"; + +@customElement("main-layout") +export class MainLayout extends LitElement { + private _initialChildren: Node[] = []; + + createRenderRoot() { + return this; + } + + connectedCallback() { + if (this._initialChildren.length === 0 && this.childNodes.length > 0) { + this._initialChildren = Array.from(this.childNodes); + } + super.connectedCallback(); + } + + render() { + return html` +
+
+ ${this._initialChildren} +
+
+ `; + } +} diff --git a/src/client/components/MobileNavBar.ts b/src/client/components/MobileNavBar.ts new file mode 100644 index 000000000..09cce67d4 --- /dev/null +++ b/src/client/components/MobileNavBar.ts @@ -0,0 +1,155 @@ +import { LitElement, html } from "lit"; +import { customElement } from "lit/decorators.js"; + +@customElement("mobile-nav-bar") +export class MobileNavBar extends LitElement { + createRenderRoot() { + return this; + } + + connectedCallback() { + super.connectedCallback(); + window.addEventListener("showPage", this._onShowPage); + + const current = (window as any).currentPageId; + if (current) { + this.updateComplete.then(() => { + this._updateActiveState(current); + }); + } + } + + disconnectedCallback() { + super.disconnectedCallback(); + window.removeEventListener("showPage", this._onShowPage); + } + + private _onShowPage = (e: Event) => { + const pageId = (e as CustomEvent).detail; + this._updateActiveState(pageId); + }; + + private _updateActiveState(pageId: string) { + this.querySelectorAll(".nav-menu-item").forEach((el) => { + if ((el as HTMLElement).dataset.page === pageId) { + el.classList.add("active"); + } else { + el.classList.remove("active"); + } + }); + } + + render() { + return html` + +
+
+ +
+ +
+
+ + + + + + + + + + + + + + +
+
+
+ + + + + + + + +
+ +
+
+ `; + } +} diff --git a/src/client/components/PatternButton.ts b/src/client/components/PatternButton.ts index 9319957a7..58ba1a863 100644 --- a/src/client/components/PatternButton.ts +++ b/src/client/components/PatternButton.ts @@ -188,7 +188,7 @@ function renderBlankPreview(width: number, height: number): TemplateResult {
+ `; + } +} diff --git a/src/client/components/baseComponents/Button.ts b/src/client/components/baseComponents/Button.ts index 7adf97eb7..dd0818c5a 100644 --- a/src/client/components/baseComponents/Button.ts +++ b/src/client/components/baseComponents/Button.ts @@ -12,33 +12,36 @@ export class OButton extends LitElement { @property({ type: Boolean }) blockDesktop = false; @property({ type: Boolean }) disable = false; @property({ type: Boolean }) fill = false; + private static readonly BASE_CLASS = + "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"; createRenderRoot() { return this; } + private getButtonClasses(): Record { + return { + [OButton.BASE_CLASS]: 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-gray-700 text-gray-100 hover:bg-gray-600": this.secondary, + "disabled:opacity-70 disabled:cursor-not-allowed disabled:transform-none disabled:bg-gray-600": + this.disable, + }; + } + render() { return html` `; } diff --git a/src/client/components/baseComponents/setting/SettingKeybind.ts b/src/client/components/baseComponents/setting/SettingKeybind.ts index ed3582ad0..e72b52867 100644 --- a/src/client/components/baseComponents/setting/SettingKeybind.ts +++ b/src/client/components/baseComponents/setting/SettingKeybind.ts @@ -41,7 +41,7 @@ export class SettingKeybind extends LitElement {
e.preventDefault()} > ${this.getMessage()} diff --git a/src/client/graphics/layers/SettingsModal.ts b/src/client/graphics/layers/SettingsModal.ts index 4c1c2c461..92d8f1fe4 100644 --- a/src/client/graphics/layers/SettingsModal.ts +++ b/src/client/graphics/layers/SettingsModal.ts @@ -187,7 +187,7 @@ export class SettingsModal extends LitElement implements Layer { return html`