UI refinements (#2859)

## Description:

UI Refinements requested by @evanpelle  check https://ui.openfront.dev

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

w.o.n
This commit is contained in:
Ryan
2026-01-11 22:52:03 +00:00
committed by GitHub
parent 14512e4f87
commit 3e661752af
39 changed files with 1928 additions and 1573 deletions
+71 -521
View File
@@ -127,552 +127,102 @@
<div
id="mobile-menu-backdrop"
class="lg:!hidden [.in-game_&]:hidden hidden pointer-events-none [&.open]:block [&.open]:pointer-events-auto [&.open]:fixed [&.open]:inset-0 [&.open]:bg-black/50 [&.open]:z-[40000] transition-opacity"
class="lg:!hidden [.in-game_&]:hidden hidden pointer-events-none [&.open]:block [&.open]:pointer-events-auto [&.open]:fixed [&.open]:inset-0 [&.open]:bg-black/60 [&.open]:z-[40000] transition-opacity"
role="presentation"
aria-hidden="true"
></div>
<div
<mobile-nav-bar
id="sidebar-menu"
class="peer [.in-game_&]:hidden z-[40001] fixed left-0 top-0 h-full flex flex-col justify-start overflow-visible bg-black/60 backdrop-blur-md transition-transform duration-500 ease-out transform -translate-x-full w-[80%] [&.open]:translate-x-0 lg:hidden"
role="dialog"
data-i18n-aria-label="main.menu"
aria-hidden="true"
>
<!-- Border Segments (Custom right border with gap for button) -->
<div
class="absolute right-0 top-0 w-px bg-transparent"
style="height: calc(50% - 64px)"
></div>
<div
class="absolute right-0 bottom-0 w-px bg-transparent"
style="height: calc(50% - 64px)"
></div>
<div
class="flex-1 w-full flex flex-col justify-start overflow-y-auto md:pt-[clamp(1rem,3vh,4rem)] md:pb-[clamp(0.5rem,2vh,2rem)] md:px-[clamp(1rem,1.5vw,2rem)] p-5 gap-[clamp(1rem,3vh,3rem)]"
>
<!-- Logo + Menu -->
<div
class="flex flex-col text-[#2563eb] mb-[clamp(1rem,2vh,2rem)] ml-[clamp(0.2rem,0.4vw,0.4vh)]"
>
<div class="flex flex-col items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1364 259"
width="100%"
height="100%"
fill="currentColor"
class="w-[clamp(120px,15vw,192px)] h-[clamp(40px,6vh,64px)] drop-shadow-[0_0_10px_rgba(37,99,235,0.3)]"
>
<!-- (Logo paths preserved) -->
<path
d="M0,174V51h15.24v-17.14h16.81v-16.98h16.96V0h1266v17.23h17.13v16.81h16.98v16.96h14.88v123h-15.13v17.08h-17.08v17.08h-16.9v17.04H324.9v16.86h-16.9v16.95h-102v-17.12h-17.07v-17.05H48.73v-17.05h-16.89v-16.89H14.94v-16.89H0ZM1297.95,17.35H65.9v16.7h-17.08v17.08h-14.5v123.08h14.85v16.9h17.08v17.08h139.9v17.08h17.08v16.36h67.9v-16.72h17.08v-17.07h989.88v-17.07h17.08v-16.9h14.44V50.8h-14.75v-17.08h-16.9v-16.37Z"
/>
<path
d="M189.1,154.78v17.07h-16.9v16.75h-51.07v-16.42h-16.9v-17.07h-16.97v-84.88h16.63v-17.07h16.9v-16.84h51.07v16.5h17.07v17.07h16.7v84.89h-16.54ZM137.87,53.1v17.15h-16.6v84.86h16.97v16.61h16.89v-16.97h16.6v-84.86h-16.97v-16.79h-16.89Z"
/>
<path
d="M273.91,104.06v-16.73h50.92v16.45h16.85v68.05h-16.44v17.06h-50.96v16.88h16.4v16.96h-67.28v-16.61h16.33v-101.86h-16.38v-16.98h33.4v16.63c6.12,0,11.72,0,17.31,0,0,22.56,0,45.13,0,67.75h33.59v-67.61h-33.73Z"
/>
<path
d="M631.12,188.64v-16.36h16.53V53.2h-16.25v-16.86h118.33v33.29h-16.65v-16.36h-50.72v50.44h33.36v-16.35h16.99v50.25h-16.6v-16.33h-33.73v50.65h16.37v16.72h-67.63Z"
/>
<path
d="M596.78,103.8v84.94h-33.54v-84.39h-34.03v84.25h-33.85v-101.29h84.5v16.49h16.93Z"
/>
<path
d="M1107.12,188.71v-84.34h-34.03v84.37h-33.7v-101.41h84.42v16.41h16.86v84.96h-33.54Z"
/>
<path
d="M988.1,171.78v16.87h-67.88v-16.38h-16.87v-68.06h16.38v-16.87h68.06v16.38h16.87v68.06h-16.55ZM970.78,104.35h-33.39v67.38h33.39v-67.38Z"
/>
<path
d="M460.77,155.38v16.49h-16.58v16.83h-68.05v-16.5h-16.83v-68.05h16.49v-16.83h68.05v16.49h16.83v34.06h-67.31v33.82h33.47v-16.31h33.92ZM393.39,104.18v16.56h33.3v-16.56h-33.3Z"
/>
<path
d="M1209.13,172h-16.9v-67.9h-16.96v-16.9h16.68v-17.08h16.9v-16.82h16.9v33.58h50.98v16.91h-50.4v67.96h16.48v-16.43h50.95v16.54h-16.55v16.76h-68.08v-16.6Z"
/>
<path
d="M834.91,120.94v16.96h-16.65v33.88h16.41v16.96h-67.29v-16.63h16.34v-67.87h-16.4v-16.97h50.42v33.81h17.3l-.14-.14Z"
/>
<path
d="M835.05,121.08v-33.75h33.76v16.43h16.85v33.96h-33.43v-16.79c-6.13,0-11.73,0-17.32,0,0,0,.14.14.14.14Z"
/>
</svg>
<div
id="game-version"
class="l-header__highlightText text-center"
></div>
</div>
</div>
<div class="flex flex-col">
<button
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
data-page="page-play"
data-i18n="main.play"
></button>
<button
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
data-page="page-news"
data-i18n="main.news"
></button>
<button
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
data-page="page-item-store"
data-i18n="main.store"
></button>
<button
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
data-page="page-options"
data-i18n="main.options"
></button>
<!-- Keybinds menu item removed for mobile users -->
<button
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
data-page="page-stats"
data-i18n="main.stats"
></button>
<button
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
data-page="page-account"
data-i18n="main.account"
></button>
<button
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
data-page="page-help"
data-i18n="main.help"
></button>
</div>
<div class="flex flex-col gap-4 w-full mt-auto">
<div
class="[.in-game_&]:hidden flex w-full items-center justify-end gap-4 pt-4 border-t border-white/10"
>
<div class="flex-shrink-0">
<lang-selector></lang-selector>
</div>
</div>
</div>
</div>
</div>
></mobile-nav-bar>
<!-- MAIN CONTENT AREA -->
<div
class="[.in-game_&]:hidden flex-1 relative overflow-hidden h-full transition-[margin] duration-500 ease-out will-change-[margin-left] flex flex-col"
>
<!-- Desktop Top Bar -->
<nav
class="hidden lg:flex w-full bg-slate-950/70 backdrop-blur-md border-b border-white/10 items-center justify-center gap-8 py-4 shrink-0 transition-opacity z-50 relative"
>
<div class="flex flex-col items-center justify-center">
<div class="h-8 text-[#2563eb]">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1364 259"
fill="currentColor"
class="h-full w-auto drop-shadow-[0_0_10px_rgba(37,99,235,0.4)]"
>
<path
d="M0,174V51h15.24v-17.14h16.81v-16.98h16.96V0h1266v17.23h17.13v16.81h16.98v16.96h14.88v123h-15.13v17.08h-17.08v17.08h-16.9v17.04H324.9v16.86h-16.9v16.95h-102v-17.12h-17.07v-17.05H48.73v-17.05h-16.89v-16.89H14.94v-16.89H0ZM1297.95,17.35H65.9v16.7h-17.08v17.08h-14.5v123.08h14.85v16.9h17.08v17.08h139.9v17.08h17.08v16.36h67.9v-16.72h17.08v-17.07h989.88v-17.07h17.08v-16.9h14.44V50.8h-14.75v-17.08h-16.9v-16.37Z"
/>
<path
d="M189.1,154.78v17.07h-16.9v16.75h-51.07v-16.42h-16.9v-17.07h-16.97v-84.88h16.63v-17.07h16.9v-16.84h51.07v16.5h17.07v17.07h16.7v84.89h-16.54ZM137.87,53.1v17.15h-16.6v84.86h16.97v16.61h16.89v-16.97h16.6v-84.86h-16.97v-16.79h-16.89Z"
/>
<path
d="M273.91,104.06v-16.73h50.92v16.45h16.85v68.05h-16.44v17.06h-50.96v16.88h16.4v16.96h-67.28v-16.61h16.33v-101.86h-16.38v-16.98h33.4v16.63c6.12,0,11.72,0,17.31,0,0,22.56,0,45.13,0,67.75h33.59v-67.61h-33.73Z"
/>
<path
d="M631.12,188.64v-16.36h16.53V53.2h-16.25v-16.86h118.33v33.29h-16.65v-16.36h-50.72v50.44h33.36v-16.35h16.99v50.25h-16.6v-16.33h-33.73v50.65h16.37v16.72h-67.63Z"
/>
<path
d="M596.78,103.8v84.94h-33.54v-84.39h-34.03v84.25h-33.85v-101.29h84.5v16.49h16.93Z"
/>
<path
d="M1107.12,188.71v-84.34h-34.03v84.37h-33.7v-101.41h84.42v16.41h16.86v84.96h-33.54Z"
/>
<path
d="M988.1,171.78v16.87h-67.88v-16.38h-16.87v-68.06h16.38v-16.87h68.06v16.38h16.87v68.06h-16.55ZM970.78,104.35h-33.39v67.38h33.39v-67.38Z"
/>
<path
d="M460.77,155.38v16.49h-16.58v16.83h-68.05v-16.5h-16.83v-68.05h16.49v-16.83h68.05v16.49h16.83v34.06h-67.31v33.82h33.47v-16.31h33.92ZM393.39,104.18v16.56h33.3v-16.56h-33.3Z"
/>
<path
d="M1209.13,172h-16.9v-67.9h-16.96v-16.9h16.68v-17.08h16.9v-16.82h16.9v33.58h50.98v16.91h-50.4v67.96h16.48v-16.43h50.95v16.54h-16.55v16.76h-68.08v-16.6Z"
/>
<path
d="M834.91,120.94v16.96h-16.65v33.88h16.41v16.96h-67.29v-16.63h16.34v-67.87h-16.4v-16.97h50.42v33.81h17.3l-.14-.14Z"
/>
<path
d="M835.05,121.08v-33.75h33.76v16.43h16.85v33.96h-33.43v-16.79c-6.13,0-11.73,0-17.32,0,0,0,.14.14.14.14Z"
/>
</svg>
</div>
<div
class="game-version-display text-[#2563eb] text-xs font-bold tracking-widest uppercase cursor-default select-text leading-none mt-1"
></div>
</div>
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
data-page="page-play"
data-i18n="main.play"
></button>
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
data-page="page-news"
data-i18n="main.news"
></button>
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500 relative"
data-page="page-item-store"
data-i18n="main.store"
></button>
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
data-page="page-options"
data-i18n="main.options"
></button>
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
data-page="page-keybinds"
data-i18n="main.keys"
></button>
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
data-page="page-stats"
data-i18n="main.stats"
></button>
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
data-page="page-account"
data-i18n="main.account"
></button>
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
data-page="page-help"
data-i18n="main.help"
></button>
<lang-selector></lang-selector>
</nav>
<desktop-nav-bar></desktop-nav-bar>
<div
id="turnstile-container"
class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[99999]"
></div>
<gutter-ads></gutter-ads>
<!-- Main container with responsive padding -->
<main
class="relative [.in-game_&]:hidden flex flex-col flex-1 overflow-hidden w-full px-[clamp(1.5rem,3vw,3rem)] pt-[clamp(0.75rem,1.5vw,1.5rem)] pb-[clamp(0.75rem,1.5vw,1.5rem)]"
>
<div
class="w-full max-w-[20cm] mx-auto flex flex-col flex-1 gap-[clamp(1.5rem,3vw,3rem)] overflow-y-auto overflow-x-hidden [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden lg:[scrollbar-width:auto] lg:[-ms-overflow-style:auto] lg:[&::-webkit-scrollbar]:block"
>
<div
id="page-play"
class="flex flex-col gap-2 w-full max-w-6xl mx-auto px-0 sm:px-4 transition-all duration-300 my-auto min-h-0"
>
<token-login class="w-full hidden"></token-login>
<main-layout class="contents">
<play-page class="contents"></play-page>
<!-- Header / Identity Section -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-2 lg:gap-6 w-full">
<div
class="lg:col-span-9 flex flex-row flex-nowrap gap-x-2 h-[60px] items-center bg-slate-900/80 backdrop-blur-md p-3 rounded-xl border border-blue-500/20 relative z-20 text-sm sm:text-base shrink-0"
>
<!-- Flag -->
<div
class="h-[40px] sm:h-[50px] shrink-0 aspect-[4/3] flex items-center justify-center"
>
<!-- Hamburger (Mobile) -->
<button
id="hamburger-btn"
class="lg:hidden flex w-full h-full bg-slate-800/40 text-white/90 border border-blue-400/20 hover:bg-slate-700/40 p-0 rounded-md items-center justify-center cursor-pointer transition-all duration-200"
data-i18n-aria-label="main.menu"
aria-expanded="false"
aria-controls="sidebar-menu"
aria-haspopup="dialog"
data-i18n-title="main.menu"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-8 h-8"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg>
</button>
<news-modal
id="page-news"
inline
class="hidden w-full h-full page-content"
></news-modal>
<single-player-modal
id="page-single-player"
inline
class="hidden w-full h-full page-content"
></single-player-modal>
<host-lobby-modal
id="page-host-lobby"
inline
class="hidden w-full h-full page-content"
></host-lobby-modal>
<join-private-lobby-modal
id="page-join-private-lobby"
inline
class="hidden w-full h-full page-content"
></join-private-lobby-modal>
<territory-patterns-modal
id="page-item-store"
inline
class="hidden w-full h-full page-content"
></territory-patterns-modal>
<matchmaking-modal
id="page-matchmaking"
inline
class="hidden w-full h-full page-content"
></matchmaking-modal>
<user-setting
id="page-settings"
inline
class="hidden w-full h-full page-content"
></user-setting>
<stats-modal
id="page-stats"
inline
class="hidden w-full h-full page-content"
></stats-modal>
<account-modal
id="page-account"
inline
class="hidden w-full h-full page-content"
></account-modal>
<help-modal
id="page-help"
inline
class="hidden w-full h-full page-content"
></help-modal>
<language-modal
id="page-language"
inline
class="hidden w-full h-full page-content"
></language-modal>
<flag-input-modal
id="flag-input-modal"
inline
class="hidden w-full h-full page-content"
></flag-input-modal>
</main-layout>
<!-- Flag (Desktop) -->
<flag-input
id="flag-input-component"
class="hidden lg:flex w-full h-full items-center justify-center rounded-md overflow-hidden"
></flag-input>
</div>
<!-- Desktop Footer -->
<page-footer></page-footer>
<!-- Username -->
<div
class="flex-1 min-w-0 h-[40px] sm:h-[50px] flex items-center"
>
<username-input
class="relative w-full h-full block text-ellipsis overflow-hidden whitespace-nowrap"
></username-input>
</div>
<!-- Pattern button (Mobile - inside bar, Desktop - hidden here) -->
<button
id="territory-patterns-input-preview-button"
class="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"
style="padding: 4px !important"
data-i18n-title="main.pick_pattern"
></button>
</div>
<!-- Pattern button (Desktop only - separate column) -->
<div class="hidden lg:flex lg:col-span-3">
<div
id="territory-patterns-preview-desktop-wrapper"
class="w-full h-[60px]"
></div>
</div>
</div>
<!-- Primary Game Actions Area -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6 w-full">
<!-- Left Column: Featured Lobbies / Quick Play -->
<div class="lg:col-span-9 flex flex-col gap-6 min-w-0">
<!-- Public Lobby Card -->
<public-lobby
class="block w-full transition-all duration-300"
></public-lobby>
<!-- Matchmaking Buttons (Full Width within column) -->
<!-- Not Logged In Button -->
<button
id="matchmaking-button-logged-out"
class="w-full h-20 bg-purple-600/50 text-white font-black uppercase tracking-widest rounded-xl transition-all duration-200 flex flex-col items-center justify-center border border-purple-500/30 overflow-hidden relative opacity-60 cursor-not-allowed"
disabled
aria-disabled="true"
>
<span
class="relative z-10 text-2xl"
data-i18n="matchmaking_button.login_required"
></span>
</button>
<!-- Logged In Button -->
<button
id="matchmaking-button"
class="hidden w-full h-20 bg-purple-600 hover:bg-purple-500 text-white font-black uppercase tracking-widest rounded-xl transition-all duration-200 flex flex-col items-center justify-center border border-purple-500/30 group overflow-hidden relative"
data-i18n-title="matchmaking_modal.title"
>
<span
class="relative z-10 text-2xl"
data-i18n="matchmaking_button.play_ranked"
></span>
<span
class="relative z-10 text-xs font-medium text-purple-100 opacity-90 group-hover:opacity-100 transition-opacity"
data-i18n="matchmaking_button.description"
></span>
</button>
</div>
<!-- Right Column: Custom Games & Modes -->
<div class="lg:col-span-3">
<div class="h-40 lg:h-[30.5rem] relative z-10">
<div
class="h-full flex flex-col bg-slate-900/40 backdrop-blur-sm rounded-2xl border border-blue-400/10 overflow-hidden"
>
<div
class="py-2 bg-blue-900/20 border-b border-blue-400/10 text-center text-sm font-bold text-gray-300 uppercase tracking-widest"
data-i18n="host_modal.label"
></div>
<div class="flex-1 p-2 flex flex-row lg:flex-col gap-2">
<o-button
id="single-player"
data-i18n-title="main.solo"
translationKey="main.solo"
fill
class="flex-1 transition-transform hover:-translate-y-0.5"
></o-button>
<o-button
id="host-lobby-button"
data-i18n-title="main.create"
translationKey="main.create"
fill
secondary
class="flex-1 opacity-90 hover:opacity-100"
></o-button>
<o-button
id="join-private-lobby-button"
data-i18n-title="main.join"
translationKey="main.join"
fill
secondary
class="flex-1 opacity-90 hover:opacity-100"
></o-button>
</div>
</div>
</div>
</div>
</div>
</div>
<news-modal
id="page-news"
inline
class="hidden w-full h-full page-content"
></news-modal>
<single-player-modal
id="page-single-player"
inline
class="hidden w-full h-full page-content"
></single-player-modal>
<host-lobby-modal
id="page-host-lobby"
inline
class="hidden w-full h-full page-content"
></host-lobby-modal>
<join-private-lobby-modal
id="page-join-private-lobby"
inline
class="hidden w-full h-full page-content"
></join-private-lobby-modal>
<territory-patterns-modal
id="page-item-store"
inline
class="hidden w-full h-full page-content"
></territory-patterns-modal>
<matchmaking-modal
id="page-matchmaking"
inline
class="hidden w-full h-full page-content"
></matchmaking-modal>
<user-setting
id="page-options"
inline
class="hidden w-full h-full page-content"
></user-setting>
<keybinds-modal
id="page-keybinds"
inline
class="hidden w-full h-full page-content"
></keybinds-modal>
<stats-modal
id="page-stats"
inline
class="hidden w-full h-full page-content"
></stats-modal>
<account-modal
id="page-account"
inline
class="hidden w-full h-full page-content"
></account-modal>
<help-modal
id="page-help"
inline
class="hidden w-full h-full page-content"
></help-modal>
<language-modal
id="page-language"
inline
class="hidden w-full h-full page-content"
></language-modal>
<flag-input-modal
id="flag-input-modal"
inline
class="hidden w-full h-full page-content"
></flag-input-modal>
<!-- Desktop Footer -->
</div>
</main>
<footer
class="[.in-game_&]:hidden bg-black/60 backdrop-blur-md flex flex-col items-center justify-center gap-2 pt-[2px] pb-2 text-white/50 w-full border-t border-white/10 shrink-0 mt-auto"
>
<div class="flex items-center justify-center gap-6 pt-2">
<a
href="https://github.com/openfrontio/OpenFrontIO"
target="_blank"
rel="noopener noreferrer"
class="opacity-60 hover:opacity-100 hover:scale-110 transition-all"
>
<img
src="/icons/github-mark-white.svg"
data-i18n-alt="news.github_link"
class="h-7 w-7 object-contain"
/>
</a>
<a
href="https://www.reddit.com/r/OpenFrontIO/"
target="_blank"
rel="noopener noreferrer"
class="opacity-60 hover:opacity-100 hover:scale-110 transition-all"
>
<svg
class="h-7 w-7 object-contain"
viewBox="0 0 24 24"
fill="white"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.249-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z"
/>
</svg>
</a>
<a
href="https://discord.gg/jRpxXvG42t"
target="_blank"
rel="noopener noreferrer"
class="opacity-60 hover:opacity-100 hover:scale-110 transition-all"
>
<svg
class="h-7 w-7 object-contain"
viewBox="0 0 24 24"
fill="white"
>
<path
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-14.36a.074.074 0 0 0-.032-.027zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.418 2.157-2.418 1.21 0 2.176 1.085 2.157 2.418 0 1.334-.956 2.419-2.157 2.419zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.418 2.157-2.418 1.21 0 2.176 1.085 2.157 2.418 0 1.334-.946 2.419-2.157 2.419z"
/>
</svg>
</a>
<a
href="https://openfront.wiki/Main_Page"
target="_blank"
rel="noopener noreferrer"
class="opacity-60 hover:opacity-100 hover:scale-110 transition-all"
>
<img
src="/icons/wiki-logo.svg"
data-i18n-alt="main.wiki"
class="h-7 w-7 object-contain"
/>
</a>
</div>
<div class="text-xs mt-2 flex items-center justify-center gap-4">
<a
href="/terms-of-service.html"
data-i18n="main.terms_of_service"
target="_blank"
class="hover:text-white transition-colors"
></a>
<span data-i18n="main.copyright"></span>
<a
href="/privacy-policy.html"
data-i18n="main.privacy_policy"
target="_blank"
class="hover:text-white transition-colors"
></a>
</div>
</footer>
<!-- Global Modals -->
<territory-patterns-modal
id="territory-patterns-modal"
+6 -8
View File
@@ -26,12 +26,15 @@
"title": "OpenFront (ALPHA)",
"join_discord": "Discord",
"login_discord": "Login with Discord",
"sign_in": "Sign in",
"discord_avatar_alt": "Discord profile avatar",
"user_avatar_alt": "{username}'s avatar",
"checking_login": "Checking login...",
"logged_in": "Logged in!",
"log_out": "Log out",
"create": "Create Lobby",
"join": "Join Lobby",
"solo": "Solo Lobby",
"solo": "Solo",
"instructions": "Instructions",
"game_info": "Game info",
"wiki": "Wiki",
@@ -42,7 +45,7 @@
"play": "Play",
"news": "News",
"store": "Store",
"options": "Options",
"settings": "Settings",
"keys": "Keys",
"stats": "Stats",
"account": "Account",
@@ -179,13 +182,8 @@
"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",
@@ -429,7 +427,7 @@
"factory": "Factory"
},
"user_setting": {
"title": "User Settings",
"title": "Settings",
"tab_basic": "Basic Settings",
"tab_keybinds": "Keybinds",
"dark_mode_label": "Dark Mode",
+4 -128
View File
@@ -72,7 +72,7 @@ export class AccountModal extends BaseModal {
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]"
class="flex flex-col items-center justify-center p-12 text-white bg-black/60 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"
@@ -107,7 +107,7 @@ export class AccountModal extends BaseModal {
return html`
<div
class="h-full flex flex-col bg-black/40 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden"
class="h-full flex flex-col bg-black/60 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"
@@ -173,11 +173,7 @@ export class AccountModal extends BaseModal {
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 this.renderLoginOptions();
}
return html`
@@ -235,60 +231,6 @@ export class AccountModal extends BaseModal {
`;
}
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 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>
</div>
`;
}
private renderLoggedInAs(): TemplateResult {
const me = this.userMeResponse?.user;
if (me?.discord) {
@@ -309,46 +251,7 @@ export class AccountModal extends BaseModal {
</div>
`;
}
// "Mini" Login Options for linking account
return html`
<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>
`;
return html``;
}
private viewGame(gameId: string): void {
@@ -398,11 +301,6 @@ export class AccountModal extends BaseModal {
<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>
@@ -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"
>
<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"
@@ -454,25 +349,6 @@ export class AccountModal extends BaseModal {
)}"
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}"
+42 -8
View File
@@ -1,5 +1,5 @@
import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { renderPlayerFlag } from "../core/CustomFlag";
import { FlagSchema } from "../core/Schemas";
import { translateText } from "./Utils";
@@ -10,7 +10,12 @@ const flagKey: string = "flag";
export class FlagInput extends LitElement {
@state() public flag: string = "";
static styles = css``;
@property({ type: Boolean, attribute: "show-select-label" })
public showSelectLabel: boolean = false;
private isDefaultFlagValue(flag: string): boolean {
return !flag || flag === "xx";
}
public getCurrentFlag(): string {
return this.flag;
@@ -42,6 +47,17 @@ export class FlagInput extends LitElement {
}
};
private onInputClick(e: Event) {
e.preventDefault();
e.stopPropagation();
this.dispatchEvent(
new CustomEvent("flag-input-click", {
bubbles: true,
composed: true,
}),
);
}
connectedCallback() {
super.connectedCallback();
this.flag = this.getStoredFlag();
@@ -59,18 +75,31 @@ export class FlagInput extends LitElement {
}
render() {
const isDefaultFlag = this.isDefaultFlagValue(this.flag);
const showSelect = this.showSelectLabel && isDefaultFlag;
const buttonTitle = showSelect
? translateText("flag_input.title")
: translateText("flag_input.button_title");
return html`
<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"
id="flag-input"
class="flag-btn m-0 border-0 w-full h-full flex cursor-pointer justify-center items-center focus:outline-none focus:ring-0 transition-all duration-200 hover:scale-105 bg-slate-900/80 hover:bg-slate-800/80 active:bg-slate-800/90 rounded-lg overflow-hidden"
style="padding: 0 !important;"
title=${translateText("flag_input.button_title")}
title=${buttonTitle}
@click=${this.onInputClick}
>
<span
id="flag-preview"
class="w-full h-full overflow-hidden"
style="display:block;"
class=${showSelect ? "hidden" : "w-full h-full overflow-hidden"}
></span>
${showSelect
? html`<span
class="text-[10px] font-black text-white/40 uppercase leading-none break-words w-full text-center px-1"
>
${translateText("flag_input.title")}
</span>`
: null}
</button>
`;
}
@@ -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("!")) {
+2 -2
View File
@@ -18,7 +18,7 @@ export class FlagInputModal extends BaseModal {
render() {
const content = html`
<div
class="h-full flex flex-col bg-black/40 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden"
class="h-full flex flex-col bg-black/60 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"
@@ -54,7 +54,7 @@ export class FlagInputModal extends BaseModal {
<div class="flex justify-center w-full px-6 pb-4 shrink-0">
<input
class="h-12 w-full max-w-md border border-white/10 bg-black/40
class="h-12 w-full max-w-md border border-white/10 bg-black/60
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"
+1 -1
View File
@@ -98,7 +98,7 @@ export class HelpModal extends BaseModal {
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-black/60 backdrop-blur-md rounded-2xl border border-white/10 p-6"
: ""}"
>
<div class="flex items-center mb-6 pb-2 border-b border-white/10 gap-2">
+22 -3
View File
@@ -102,7 +102,7 @@ export class HostLobbyModal extends BaseModal {
render() {
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"
class="h-full flex flex-col bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden select-none"
>
<!-- Header -->
<div
@@ -202,6 +202,25 @@ export class HostLobbyModal extends BaseModal {
? this.lobbyId
: "••••••••"}
</button>
<button
@click=${this.copyToClipboard}
class="p-1.5 rounded-md hover:bg-white/10 text-white/60 hover:text-white transition-colors"
title="${translateText("common.click_to_copy")}"
aria-label="${translateText("common.click_to_copy")}"
type="button"
>
<svg
viewBox="0 0 24 24"
height="16px"
width="16px"
fill="currentColor"
aria-hidden="true"
>
<path
d="M16 1H4c-1.1 0-2 .9-2 2v12h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"
/>
</svg>
</button>
</div>
</div>
@@ -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}
+20 -1
View File
@@ -38,7 +38,7 @@ export class JoinPrivateLobbyModal extends BaseModal {
render() {
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"
class="h-full flex flex-col bg-black/60 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"
@@ -137,6 +137,25 @@ export class JoinPrivateLobbyModal extends BaseModal {
? this.currentLobbyId
: "••••••••"}
</div>
<button
@click=${this.copyToClipboard}
class="p-1.5 rounded-md hover:bg-white/10 text-white/60 hover:text-white transition-colors"
title="${translateText("common.click_to_copy")}"
aria-label="${translateText("common.click_to_copy")}"
type="button"
>
<svg
viewBox="0 0 24 24"
height="16px"
width="16px"
fill="currentColor"
aria-hidden="true"
>
<path
d="M16 1H4c-1.1 0-2 .9-2 2v12h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"
/>
</svg>
</button>
</div>`
: ""}
</div>
-522
View File
@@ -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<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();
}
}
+22 -1
View File
@@ -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") {
+1 -1
View File
@@ -32,7 +32,7 @@ export class LanguageModal extends BaseModal {
<div
class="h-full flex flex-col ${
this.inline
? "bg-black/40 backdrop-blur-md rounded-2xl border border-white/10 p-6"
? "bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 p-6"
: "bg-[#232323] text-white"
}"
>
+73 -70
View File
@@ -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();
}
});
});
}
+150 -78
View File
@@ -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,
+1 -1
View File
@@ -48,7 +48,7 @@ export class MatchmakingModal extends BaseModal {
const content = html`
<div
class="h-full flex flex-col ${this.inline
? "bg-black/40 backdrop-blur-md rounded-2xl border border-white/10"
? "bg-black/60 backdrop-blur-md rounded-2xl border border-white/10"
: ""}"
>
<div
+83 -46
View File
@@ -1,26 +1,39 @@
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");
(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");
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ export class NewsModal extends BaseModal {
const content = html`
<div
class="h-full flex flex-col ${this.inline
? "bg-black/40 backdrop-blur-md rounded-2xl border border-white/10"
? "bg-black/60 backdrop-blur-md rounded-2xl border border-white/10"
: ""}"
>
<div
+140
View File
@@ -0,0 +1,140 @@
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { Cosmetics } from "../core/CosmeticSchemas";
import { UserSettings } from "../core/game/UserSettings";
import { PlayerPattern } from "../core/Schemas";
import { renderPatternPreview } from "./components/PatternButton";
import { fetchCosmetics } from "./Cosmetics";
import { translateText } from "./Utils";
// Module-level cosmetics cache to avoid refetching on every component mount
let cosmeticsCache: Promise<Cosmetics | null> | null = null;
function getCachedCosmetics(): Promise<Cosmetics | null> {
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`
<button
id="pattern-input"
class="pattern-btn m-0 border-0 !p-0 w-full h-full flex cursor-pointer justify-center items-center focus:outline-none focus:ring-0 bg-slate-900/80 rounded-lg overflow-hidden"
disabled
>
<span
class="w-6 h-6 border-4 border-blue-500/30 border-t-blue-500 rounded-full animate-spin"
></span>
</button>
`;
}
let previewContent;
if (this.pattern) {
previewContent = renderPatternPreview(this.pattern, 128, 128);
} else {
previewContent = renderPatternPreview(null, 128, 128);
}
return html`
<button
id="pattern-input"
class="pattern-btn m-0 border-0 !p-0 w-full h-full flex cursor-pointer justify-center items-center focus:outline-none focus:ring-0 transition-all duration-200 hover:scale-105 bg-slate-900/80 hover:bg-slate-800/80 active:bg-slate-800/90 rounded-lg overflow-hidden"
title=${buttonTitle}
@click=${this.onInputClick}
>
<span
class=${showSelect
? "hidden"
: "w-full h-full overflow-hidden flex items-center justify-center [&>img]:object-cover [&>img]:w-full [&>img]:h-full"}
>
${!showSelect ? previewContent : null}
</span>
${showSelect
? html`<span
class="text-[10px] font-black text-white/40 uppercase leading-none break-words w-full text-center px-1"
>
${translateText("territory_patterns.select_skin")}
</span>`
: null}
</button>
`;
}
}
+104 -91
View File
@@ -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<GameID, number>();
private lobbySocket = new PublicLobbySocket((lobbies) =>
this.handleLobbiesUpdate(lobbies),
@@ -124,111 +124,124 @@ export class PublicLobby extends LitElement {
<button
@click=${() => this.lobbyClicked(lobby)}
?disabled=${this.isButtonDebounced}
class="group relative isolate flex flex-col w-full h-80 lg:h-96 overflow-hidden rounded-2xl transition-all duration-300 ${this
class="group relative isolate flex flex-col w-full h-80 lg:h-96 overflow-hidden rounded-2xl transition-all duration-200 bg-[#3d7bab] ${this
.isLobbyHighlighted
? "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"
: "hover:scale-[1.01]"} active:scale-[0.98] ${this.isButtonDebounced
? "cursor-not-allowed"
: ""}"
>
<!-- 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 class="font-sans w-full h-full flex flex-col">
<!-- Main card gradient - stops before text -->
<div class="absolute inset-0 pointer-events-none z-10"></div>
<!-- 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"
<!-- Map Image Area with gradient overlay -->
<div class="flex-1 w-full relative overflow-hidden">
${mapImageSrc
? html`<img
src="${mapImageSrc}"
alt="${lobby.gameConfig.gameMap}"
class="absolute inset-0 w-full h-full object-cover object-center z-10"
/>`
: ""}
<!-- Vignette overlay for dark edges -->
<div class="pointer-events-none absolute inset-0 z-20"></div>
</div>
<!-- Mode Badge in top left -->
${fullModeLabel
? html`<span
class="absolute top-4 left-4 px-4 py-1 rounded font-bold text-sm lg:text-base uppercase tracking-widest z-30 bg-slate-800 text-white ring-1 ring-white/10 shadow-sm"
>
${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
>`}
${fullModeLabel}
</span>`
: ""}
<!-- Timer in top right -->
${timeRemaining > 0
? html`
<span
class="absolute top-4 right-4 px-4 py-1 rounded font-bold text-sm lg:text-base tracking-widest z-30 bg-blue-600 text-white"
>
${timeDisplay}
</span>
`
: html`<span
class="absolute top-4 right-4 px-4 py-1 rounded font-bold text-sm lg:text-base uppercase tracking-widest z-30 bg-green-600 text-white"
>
${translateText("public_lobby.started")}
</span>`}
<!-- Content Banner -->
<div class="absolute bottom-0 left-0 right-0 z-20">
<!-- Modifier badges placed just above the gradient overlay -->
${modifierLabel.length > 0
? html`<div
class="absolute -top-8 left-4 z-30 flex gap-2 flex-wrap"
>
${modifierLabel.map(
(label) => html`
<span
class="px-2 py-0.5 rounded text-xs font-medium uppercase tracking-wide bg-purple-600 text-white"
>
${label}
</span>
`,
)}
</div>`
: html``}
<!-- Gradient overlay for text area - adds extra darkening -->
<div
class="absolute inset-0 bg-gradient-to-b from-black/60 to-black/90 pointer-events-none"
></div>
<div class="relative p-6 flex flex-col gap-2 text-left">
<!-- Header row: Status/Join on left, Player Count on right -->
<div class="flex items-center justify-between w-full">
<div class="text-base uppercase tracking-widest text-white">
${this.currLobby
? isStarting
? html`<span class="text-green-400 animate-pulse"
>${translateText("public_lobby.starting_game")}</span
>`
: html`<span class="text-orange-400"
>${translateText("public_lobby.waiting_for_players")}
${[0, 1, 2]
.map((i) =>
i === this.joiningDotIndex ? "•" : "·",
)
.join("")}</span
>`
: html`${translateText("public_lobby.join")}`}
</div>
<div class="flex items-center gap-2 text-white z-30">
<span class="text-base font-bold uppercase tracking-widest"
>${lobby.numClients}/${lobby.gameConfig.maxPlayers}</span
>
<svg
class="w-5 h-5 text-white"
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>
</div>
<!-- Map Name & Mode -->
<!-- Map Name - Full Width -->
<div
class="text-3xl font-black text-white leading-none tracking-tight"
class="text-2xl lg:text-3xl font-bold text-white leading-none uppercase tracking-widest w-full"
>
${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>`}
<!-- modifiers moved above gradient overlay -->
</div>
</div>
</div>
+2 -2
View File
@@ -129,7 +129,7 @@ export class SinglePlayerModal extends BaseModal {
render() {
const content = html`
<div
class="h-full flex flex-col bg-black/40 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden"
class="h-full flex flex-col bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden"
>
<!-- Header -->
<div
@@ -599,7 +599,7 @@ export class SinglePlayerModal extends BaseModal {
min="1"
max="120"
.value=${String(this.maxTimerValue ?? "")}
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"
aria-label=${translateText("single_modal.max_timer")}
@input=${this.handleMaxTimerValueChanges}
@keydown=${this.handleMaxTimerValueKeyDown}
+1 -1
View File
@@ -372,7 +372,7 @@ export class StatsModal extends BaseModal {
const content = html`
<div
class="h-full flex flex-col ${this.inline
? "bg-black/40 backdrop-blur-md rounded-2xl border border-white/10"
? "bg-black/60 backdrop-blur-md rounded-2xl border border-white/10"
: ""}"
>
<div
+3 -31
View File
@@ -1,5 +1,5 @@
import type { TemplateResult } from "lit";
import { html, render } from "lit";
import { html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { UserMeResponse } from "../core/ApiSchemas";
import { ColorPalette, Cosmetics, Pattern } from "../core/CosmeticSchemas";
@@ -9,7 +9,6 @@ import { hasLinkedAccount } from "./Api";
import { BaseModal } from "./components/BaseModal";
import "./components/Difficulties";
import "./components/PatternButton";
import { renderPatternPreview } from "./components/PatternButton";
import {
fetchCosmetics,
handlePurchase,
@@ -279,7 +278,7 @@ export class TerritoryPatternsModal extends BaseModal {
const content = html`
<div
class="h-full flex flex-col bg-black/40 backdrop-blur-md rounded-2xl border border-white/10 p-6"
class="h-full flex flex-col bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 p-6"
>
${this.renderTabNavigation()}
<div class="overflow-y-auto pr-2 custom-scrollbar mr-1">
@@ -391,6 +390,7 @@ export class TerritoryPatternsModal extends BaseModal {
this.selectedColor = hexCode;
this.userSettings.setSelectedColor(hexCode);
this.refresh();
this.dispatchEvent(new CustomEvent("pattern-selected", { bubbles: true }));
this.close();
}
@@ -409,33 +409,5 @@ export class TerritoryPatternsModal extends BaseModal {
public async refresh() {
this.requestUpdate();
const preview = this.selectedColor
? this.renderColorPreview(this.selectedColor, 48, 48)
: renderPatternPreview(this.selectedPattern ?? null, 48, 48);
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();
}
}
+479 -5
View File
@@ -1,7 +1,9 @@
import { html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { translateText } from "../client/Utils";
import { formatKeyForDisplay, 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";
@@ -13,18 +15,203 @@ interface FlagInputModalElement extends HTMLElement {
returnTo?: string;
}
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("user-setting")
export class UserSettingModal extends BaseModal {
private userSettings: UserSettings = new UserSettings();
@state() private activeTab: "basic" | "keybinds" = "basic";
@state() private keySequence: string[] = [];
@state() private showEasterEggSettings = false;
@state() private keybinds: Record<
string,
{ value: string | string[]; key: string }
> = {};
connectedCallback() {
super.connectedCallback();
this.loadKeybindsFromStorage();
}
disconnectedCallback() {
window.removeEventListener("keydown", this.handleEasterEggKey);
super.disconnectedCallback();
}
private loadKeybindsFromStorage() {
const savedKeybinds = localStorage.getItem("settings.keybinds");
if (!savedKeybinds) return;
try {
const parsed = JSON.parse(savedKeybinds);
if (
typeof parsed === "object" &&
parsed !== null &&
!Array.isArray(parsed)
) {
const isValid = Object.values(parsed).every((entry) => {
if (
typeof entry !== "object" ||
entry === null ||
Array.isArray(entry)
) {
return false;
}
if (!("key" in entry) || typeof (entry as any).key !== "string") {
return false;
}
if (!("value" in entry)) {
return false;
}
const value = (entry as any).value;
if (typeof value === "string") {
return true;
}
if (Array.isArray(value)) {
return 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)) {
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") {
const displayKey = formatKeyForDisplay(key || value);
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) {
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;
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 || "";
}
private handleEasterEggKey = (e: KeyboardEvent) => {
if (!this.isModalOpen || this.showEasterEggSettings) return;
@@ -188,20 +375,25 @@ export class UserSettingModal extends BaseModal {
document.querySelector<FlagInputModalElement>("#flag-input-modal");
if (flagInputModal?.open) {
this.close();
flagInputModal.returnTo = "#" + (this.id || "page-options");
flagInputModal.returnTo = "#" + (this.id || "page-settings");
flagInputModal.open();
}
};
render() {
const activeContent =
this.activeTab === "basic"
? this.renderBasicSettings()
: this.renderKeybindSettings();
const content = html`
<div
class="h-full flex flex-col ${this.inline
? "bg-black/40 backdrop-blur-md rounded-2xl border border-white/10"
? "bg-black/60 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"
class="relative flex flex-col mb-6 border-b border-white/10 pb-4 shrink-0 p-6"
>
<div class="flex items-center gap-4 flex-1 flex-wrap">
<button
@@ -230,12 +422,33 @@ export class UserSettingModal extends BaseModal {
${translateText("user_setting.title")}
</span>
</div>
<div class="hidden md:flex items-center gap-2 justify-center mt-4">
<button
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
.activeTab === "basic"
? "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 = "basic")}
>
${translateText("user_setting.tab_basic")}
</button>
<button
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
.activeTab === "keybinds"
? "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 = "keybinds")}
>
${translateText("user_setting.tab_keybinds")}
</button>
</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 class="flex flex-col gap-2">${activeContent}</div>
</div>
</div>
`;
@@ -260,6 +473,266 @@ export class UserSettingModal extends BaseModal {
window.removeEventListener("keydown", this.handleEasterEggKey);
}
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>
`;
}
private renderBasicSettings() {
return html`
<!-- 🚩 Flag Selector -->
@@ -450,6 +923,7 @@ export class UserSettingModal extends BaseModal {
protected onOpen(): void {
window.addEventListener("keydown", this.handleEasterEggKey);
this.loadKeybindsFromStorage();
}
public open() {
+2 -2
View File
@@ -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"
/>
<input
type="text"
@@ -71,7 +71,7 @@ export class UsernameInput extends LitElement {
@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"
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 transition-colors overflow-x-auto whitespace-nowrap text-ellipsis pr-2"
/>
</div>
${this.validationError
+26
View File
@@ -393,3 +393,29 @@ export async function getSvgAspectRatio(src: string): Promise<number | null> {
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;
}
+187
View File
@@ -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`
<nav
class="hidden lg:flex w-full bg-slate-950/70 backdrop-blur-md border-b border-white/10 items-center justify-center gap-8 py-4 shrink-0 transition-opacity z-50 relative"
>
<div class="flex flex-col items-center justify-center">
<div class="h-8 text-[#2563eb]">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1364 259"
fill="currentColor"
class="h-full w-auto drop-shadow-[0_0_10px_rgba(37,99,235,0.4)]"
>
<path
d="M0,174V51h15.24v-17.14h16.81v-16.98h16.96V0h1266v17.23h17.13v16.81h16.98v16.96h14.88v123h-15.13v17.08h-17.08v17.08h-16.9v17.04H324.9v16.86h-16.9v16.95h-102v-17.12h-17.07v-17.05H48.73v-17.05h-16.89v-16.89H14.94v-16.89H0ZM1297.95,17.35H65.9v16.7h-17.08v17.08h-14.5v123.08h14.85v16.9h17.08v17.08h139.9v17.08h17.08v16.36h67.9v-16.72h17.08v-17.07h989.88v-17.07h17.08v-16.9h14.44V50.8h-14.75v-17.08h-16.9v-16.37Z"
/>
<path
d="M189.1,154.78v17.07h-16.9v16.75h-51.07v-16.42h-16.9v-17.07h-16.97v-84.88h16.63v-17.07h16.9v-16.84h51.07v16.5h17.07v17.07h16.7v84.89h-16.54ZM137.87,53.1v17.15h-16.6v84.86h16.97v16.61h16.89v-16.97h16.6v-84.86h-16.97v-16.79h-16.89Z"
/>
<path
d="M273.91,104.06v-16.73h50.92v16.45h16.85v68.05h-16.44v17.06h-50.96v16.88h16.4v16.96h-67.28v-16.61h16.33v-101.86h-16.38v-16.98h33.4v16.63c6.12,0,11.72,0,17.31,0,0,22.56,0,45.13,0,67.75h33.59v-67.61h-33.73Z"
/>
<path
d="M631.12,188.64v-16.36h16.53V53.2h-16.25v-16.86h118.33v33.29h-16.65v-16.36h-50.72v50.44h33.36v-16.35h16.99v50.25h-16.6v-16.33h-33.73v50.65h16.37v16.72h-67.63Z"
/>
<path
d="M596.78,103.8v84.94h-33.54v-84.39h-34.03v84.25h-33.85v-101.29h84.5v16.49h16.93Z"
/>
<path
d="M1107.12,188.71v-84.34h-34.03v84.37h-33.7v-101.41h84.42v16.41h16.86v84.96h-33.54Z"
/>
<path
d="M988.1,171.78v16.87h-67.88v-16.38h-16.87v-68.06h16.38v-16.87h68.06v16.38h16.87v68.06h-16.55ZM970.78,104.35h-33.39v67.38h33.39v-67.38Z"
/>
<path
d="M460.77,155.38v16.49h-16.58v16.83h-68.05v-16.5h-16.83v-68.05h16.49v-16.83h68.05v16.49h16.83v34.06h-67.31v33.82h33.47v-16.31h33.92ZM393.39,104.18v16.56h33.3v-16.56h-33.3Z"
/>
<path
d="M1209.13,172h-16.9v-67.9h-16.96v-16.9h16.68v-17.08h16.9v-16.82h16.9v33.58h50.98v16.91h-50.4v67.96h16.48v-16.43h50.95v16.54h-16.55v16.76h-68.08v-16.6Z"
/>
<path
d="M834.91,120.94v16.96h-16.65v33.88h16.41v16.96h-67.29v-16.63h16.34v-67.87h-16.4v-16.97h50.42v33.81h17.3l-.14-.14Z"
/>
<path
d="M835.05,121.08v-33.75h33.76v16.43h16.85v33.96h-33.43v-16.79c-6.13,0-11.73,0-17.32,0,0,0,.14.14.14.14Z"
/>
</svg>
</div>
<div
id="game-version"
class="l-header__highlightText text-center"
></div>
</div>
<!-- Desktop Navigation Menu Items -->
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
data-page="page-play"
data-i18n="main.play"
></button>
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
data-page="page-news"
data-i18n="main.news"
></button>
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500 relative"
data-page="page-item-store"
data-i18n="main.store"
></button>
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
data-page="page-settings"
data-i18n="main.settings"
></button>
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
data-page="page-stats"
data-i18n="main.stats"
></button>
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
data-page="page-help"
data-i18n="main.help"
></button>
<lang-selector></lang-selector>
<button
id="nav-account-button"
class="nav-menu-item relative h-10 rounded-full overflow-hidden flex items-center justify-center gap-2 px-3 bg-transparent border border-white/20 text-white/80 hover:text-white cursor-pointer transition-colors [&.active]:text-white"
data-page="page-account"
data-i18n-aria-label="main.account"
data-i18n-title="main.account"
>
<img
id="nav-account-avatar"
class="hidden w-8 h-8 rounded-full object-cover"
alt=""
data-i18n-alt="main.discord_avatar_alt"
referrerpolicy="no-referrer"
/>
<svg
id="nav-account-person-icon"
class="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M20 21a8 8 0 0 0-16 0" />
<path d="M12 13a4 4 0 1 0-4-4 4 4 0 0 0 4 4Z" />
</svg>
<span
id="nav-account-email-badge"
class="hidden absolute bottom-1 right-1 w-4 h-4 rounded-full bg-slate-900/80 border border-white/20 flex items-center justify-center"
aria-hidden="true"
>
<svg
class="w-2.5 h-2.5 text-white/80"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 4h16v16H4z" opacity="0" />
<path d="M4 6h16v12H4z" />
<path d="m4 7 8 6 8-6" />
</svg>
</span>
<span
id="nav-account-signin-text"
class="text-xs font-bold tracking-widest"
data-i18n="main.sign_in"
>
</span>
</button>
</nav>
`;
}
}
+1 -1
View File
@@ -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;
+92
View File
@@ -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`
<footer
class="[.in-game_&]:hidden bg-black/60 backdrop-blur-md flex flex-col items-center justify-center gap-2 pt-[2px] pb-2 text-white/50 w-full border-t border-white/10 shrink-0 mt-auto"
>
<div class="flex items-center justify-center gap-6 pt-2">
<a
href="https://github.com/openfrontio/OpenFrontIO"
target="_blank"
rel="noopener noreferrer"
class="opacity-60 hover:opacity-100 hover:scale-110 transition-all"
>
<img
src="/icons/github-mark-white.svg"
data-i18n-alt="news.github_link"
class="h-7 w-7 object-contain"
/>
</a>
<a
href="https://www.reddit.com/r/OpenFrontIO/"
target="_blank"
rel="noopener noreferrer"
class="opacity-60 hover:opacity-100 hover:scale-110 transition-all"
>
<svg
class="h-7 w-7 object-contain"
viewBox="0 0 24 24"
fill="white"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.249-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z"
/>
</svg>
</a>
<a
href="https://discord.gg/jRpxXvG42t"
target="_blank"
rel="noopener noreferrer"
class="opacity-60 hover:opacity-100 hover:scale-110 transition-all"
>
<svg
class="h-7 w-7 object-contain"
viewBox="0 0 24 24"
fill="white"
>
<path
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-14.36a.074.074 0 0 0-.032-.027zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.418 2.157-2.418 1.21 0 2.176 1.085 2.157 2.418 0 1.334-.956 2.419-2.157 2.419zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.418 2.157-2.418 1.21 0 2.176 1.085 2.157 2.418 0 1.334-.946 2.419-2.157 2.419z"
/>
</svg>
</a>
<a
href="https://openfront.wiki/Main_Page"
target="_blank"
rel="noopener noreferrer"
class="opacity-60 hover:opacity-100 hover:scale-110 transition-all"
>
<img
src="/icons/wiki-logo.svg"
data-i18n-alt="main.wiki"
class="h-7 w-7 object-contain"
/>
</a>
</div>
<div class="text-xs mt-2 flex items-center justify-center gap-4">
<a
href="/terms-of-service.html"
data-i18n="main.terms_of_service"
target="_blank"
class="hover:text-white transition-colors"
></a>
<span data-i18n="main.copyright"></span>
<a
href="/privacy-policy.html"
data-i18n="main.privacy_policy"
target="_blank"
class="hover:text-white transition-colors"
></a>
</div>
</footer>
`;
}
}
+32
View File
@@ -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`
<main
class="relative [.in-game_&]:hidden flex flex-col flex-1 overflow-hidden w-full px-[clamp(1.5rem,3vw,3rem)] pt-[clamp(0.75rem,1.5vw,1.5rem)] pb-[clamp(0.75rem,1.5vw,1.5rem)]"
>
<div
class="w-full max-w-[20cm] mx-auto flex flex-col flex-1 gap-[clamp(1.5rem,3vw,3rem)] overflow-y-auto overflow-x-hidden [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden lg:[scrollbar-width:auto] lg:[-ms-overflow-style:auto] lg:[&::-webkit-scrollbar]:block"
>
${this._initialChildren}
</div>
</main>
`;
}
}
+155
View File
@@ -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`
<!-- Border Segments (Custom right border with gap for button) -->
<div
class="absolute right-0 top-0 w-px bg-transparent"
style="height: calc(50% - 64px)"
></div>
<div
class="absolute right-0 bottom-0 w-px bg-transparent"
style="height: calc(50% - 64px)"
></div>
<div
class="flex-1 w-full flex flex-col justify-start overflow-y-auto md:pt-[clamp(1rem,3vh,4rem)] md:pb-[clamp(0.5rem,2vh,2rem)] md:px-[clamp(1rem,1.5vw,2rem)] p-5 gap-[clamp(1rem,3vh,3rem)]"
>
<!-- Logo + Menu -->
<div
class="flex flex-col text-[#2563eb] mb-[clamp(1rem,2vh,2rem)] ml-[clamp(0.2rem,0.4vw,0.4vh)]"
>
<div class="flex flex-col items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1364 259"
width="100%"
height="100%"
fill="currentColor"
class="w-[clamp(120px,15vw,192px)] h-[clamp(40px,6vh,64px)] drop-shadow-[0_0_10px_rgba(37,99,235,0.3)]"
>
<!-- (Logo paths preserved) -->
<path
d="M0,174V51h15.24v-17.14h16.81v-16.98h16.96V0h1266v17.23h17.13v16.81h16.98v16.96h14.88v123h-15.13v17.08h-17.08v17.08h-16.9v17.04H324.9v16.86h-16.9v16.95h-102v-17.12h-17.07v-17.05H48.73v-17.05h-16.89v-16.89H14.94v-16.89H0ZM1297.95,17.35H65.9v16.7h-17.08v17.08h-14.5v123.08h14.85v16.9h17.08v17.08h139.9v17.08h17.08v16.36h67.9v-16.72h17.08v-17.07h989.88v-17.07h17.08v-16.9h14.44V50.8h-14.75v-17.08h-16.9v-16.37Z"
/>
<path
d="M189.1,154.78v17.07h-16.9v16.75h-51.07v-16.42h-16.9v-17.07h-16.97v-84.88h16.63v-17.07h16.9v-16.84h51.07v16.5h17.07v17.07h16.7v84.89h-16.54ZM137.87,53.1v17.15h-16.6v84.86h16.97v16.61h16.89v-16.97h16.6v-84.86h-16.97v-16.79h-16.89Z"
/>
<path
d="M273.91,104.06v-16.73h50.92v16.45h16.85v68.05h-16.44v17.06h-50.96v16.88h16.4v16.96h-67.28v-16.61h16.33v-101.86h-16.38v-16.98h33.4v16.63c6.12,0,11.72,0,17.31,0,0,22.56,0,45.13,0,67.75h33.59v-67.61h-33.73Z"
/>
<path
d="M631.12,188.64v-16.36h16.53V53.2h-16.25v-16.86h118.33v33.29h-16.65v-16.36h-50.72v50.44h33.36v-16.35h16.99v50.25h-16.6v-16.33h-33.73v50.65h16.37v16.72h-67.63Z"
/>
<path
d="M596.78,103.8v84.94h-33.54v-84.39h-34.03v84.25h-33.85v-101.29h84.5v16.49h16.93Z"
/>
<path
d="M1107.12,188.71v-84.34h-34.03v84.37h-33.7v-101.41h84.42v16.41h16.86v84.96h-33.54Z"
/>
<path
d="M988.1,171.78v16.87h-67.88v-16.38h-16.87v-68.06h16.38v-16.87h68.06v16.38h16.87v68.06h-16.55ZM970.78,104.35h-33.39v67.38h33.39v-67.38Z"
/>
<path
d="M460.77,155.38v16.49h-16.58v16.83h-68.05v-16.5h-16.83v-68.05h16.49v-16.83h68.05v16.49h16.83v34.06h-67.31v33.82h33.47v-16.31h33.92ZM393.39,104.18v16.56h33.3v-16.56h-33.3Z"
/>
<path
d="M1209.13,172h-16.9v-67.9h-16.96v-16.9h16.68v-17.08h16.9v-16.82h16.9v33.58h50.98v16.91h-50.4v67.96h16.48v-16.43h50.95v16.54h-16.55v16.76h-68.08v-16.6Z"
/>
<path
d="M834.91,120.94v16.96h-16.65v33.88h16.41v16.96h-67.29v-16.63h16.34v-67.87h-16.4v-16.97h50.42v33.81h17.3l-.14-.14Z"
/>
<path
d="M835.05,121.08v-33.75h33.76v16.43h16.85v33.96h-33.43v-16.79c-6.13,0-11.73,0-17.32,0,0,0,.14.14.14.14Z"
/>
</svg>
<div
id="game-version"
class="l-header__highlightText text-center"
></div>
</div>
</div>
<!-- Mobile Navigation Menu Items -->
<button
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
data-page="page-play"
data-i18n="main.play"
></button>
<button
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
data-page="page-news"
data-i18n="main.news"
></button>
<button
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
data-page="page-stats"
data-i18n="main.stats"
></button>
<button
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
data-page="page-item-store"
data-i18n="main.store"
></button>
<button
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
data-page="page-settings"
data-i18n="main.settings"
></button>
<button
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
data-page="page-account"
data-i18n="main.account"
></button>
<button
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
data-page="page-help"
data-i18n="main.help"
></button>
<div
class="flex flex-col w-full mt-auto [.in-game_&]:hidden items-end justify-end pt-4 border-t border-white/10"
>
<lang-selector></lang-selector>
</div>
</div>
`;
}
}
+1 -1
View File
@@ -188,7 +188,7 @@ function renderBlankPreview(width: number, height: number): TemplateResult {
</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"
class="hidden md:flex items-center justify-center h-full w-full rounded overflow-hidden relative text-center p-1"
>
<span
class="text-[10px] font-black text-white/40 uppercase leading-none break-words w-full"
+172
View File
@@ -0,0 +1,172 @@
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("play-page")
export class PlayPage extends LitElement {
createRenderRoot() {
return this;
}
render() {
return html`
<div
id="page-play"
class="flex flex-col gap-2 w-full max-w-6xl mx-auto px-0 sm:px-4 transition-all duration-300 my-auto min-h-0"
>
<token-login class="w-full hidden"></token-login>
<!-- Header / Identity Section -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-2 lg:gap-6 w-full">
<div
class="lg:col-span-9 flex flex-row flex-nowrap gap-x-2 h-[60px] items-center bg-slate-900/80 backdrop-blur-md p-3 rounded-xl border border-blue-500/20 relative z-20 text-sm sm:text-base shrink-0"
>
<!-- Flag -->
<div
class="h-[40px] sm:h-[50px] shrink-0 aspect-[4/3] flex items-center justify-center lg:hidden"
>
<!-- Hamburger (Mobile) -->
<button
id="hamburger-btn"
class="lg:hidden flex w-full h-full bg-slate-800/40 text-white/90 border border-blue-400/20 hover:bg-slate-700/40 p-0 rounded-md items-center justify-center cursor-pointer transition-all duration-200"
data-i18n-aria-label="main.menu"
aria-expanded="false"
aria-controls="sidebar-menu"
aria-haspopup="dialog"
data-i18n-title="main.menu"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-8 h-8"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg>
</button>
</div>
<!-- Username -->
<div class="flex-1 min-w-0 h-[40px] sm:h-[50px] flex items-center">
<username-input
class="relative w-full h-full block text-ellipsis overflow-hidden whitespace-nowrap"
></username-input>
</div>
<!-- Pattern button (Mobile - inside bar, Desktop - hidden here) -->
<pattern-input
id="pattern-input-mobile"
show-select-label
class="aspect-square h-[50px] sm:h-[50px] lg:hidden shrink-0"
></pattern-input>
</div>
<!-- Pattern & Flag buttons (Desktop only - separate column) -->
<div class="hidden lg:flex lg:col-span-3">
<div class="w-full h-[60px] flex gap-2">
<pattern-input
id="pattern-input-desktop"
show-select-label
class="flex-1 h-full"
></pattern-input>
<flag-input
id="flag-input-desktop"
show-select-label
class="flex-1 h-full"
></flag-input>
</div>
</div>
</div>
<!-- Primary Game Actions Area -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6 w-full">
<!-- Left Column: Featured Lobbies / Quick Play -->
<div class="lg:col-span-9 flex flex-col gap-6 min-w-0">
<!-- Public Lobby Card -->
<public-lobby
class="block w-full transition-all duration-[50ms]"
></public-lobby>
</div>
<!-- Right Column: Custom Games & Modes -->
<div class="lg:col-span-3">
<div
class="group relative isolate flex flex-col w-full h-40 lg:h-96 overflow-hidden rounded-2xl transition-all duration-300"
>
<div
class="h-full flex flex-col bg-slate-900/40 backdrop-blur-sm rounded-2xl border border-blue-400/10 overflow-hidden"
>
<div
class="py-2 bg-blue-900/20 border-b border-blue-400/10 text-center text-sm font-bold text-gray-300 uppercase tracking-widest"
data-i18n="host_modal.label"
></div>
<div class="flex-1 p-2 flex flex-row lg:flex-col gap-2">
<o-button
id="single-player"
data-i18n-title="main.solo"
translationKey="main.solo"
fill
class="flex-1 transition-transform"
></o-button>
<o-button
id="host-lobby-button"
data-i18n-title="main.create"
translationKey="main.create"
fill
secondary
class="flex-1 opacity-90 hover:opacity-100"
></o-button>
<o-button
id="join-private-lobby-button"
data-i18n-title="main.join"
translationKey="main.join"
fill
secondary
class="flex-1 opacity-90 hover:opacity-100"
></o-button>
</div>
</div>
</div>
</div>
<!-- Matchmaking Buttons (Full Width across entire grid) -->
<div class="lg:col-span-12 flex flex-col gap-6">
<!-- Not Logged In Button -->
<button
id="matchmaking-button-logged-out"
class="w-full h-20 bg-purple-600 hover:bg-purple-500 text-white font-black uppercase tracking-widest rounded-xl transition-all duration-200 flex flex-col items-center justify-center border border-purple-500/30 overflow-hidden relative cursor-pointer"
>
<span
class="relative z-10 text-2xl"
data-i18n="matchmaking_button.login_required"
></span>
</button>
<!-- Logged In Button -->
<button
id="matchmaking-button"
class="hidden w-full h-20 bg-purple-600 hover:bg-purple-500 text-white font-black uppercase tracking-widest rounded-xl transition-all duration-200 flex flex-col items-center justify-center border border-purple-500/30 group overflow-hidden relative"
data-i18n-title="matchmaking_modal.title"
>
<span
class="relative z-10 text-2xl"
data-i18n="matchmaking_button.play_ranked"
></span>
<span
class="relative z-10 text-xs font-medium text-purple-100 opacity-90 group-hover:opacity-100 transition-opacity"
data-i18n="matchmaking_button.description"
></span>
</button>
</div>
</div>
</div>
`;
}
}
+20 -17
View File
@@ -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<string, boolean> {
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`
<button
class=${classMap({
"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,
})}
class=${classMap(this.getButtonClasses())}
?disabled=${this.disable}
>
${`${this.translationKey}` === ""
? `${this.title}`
: `${translateText(this.translationKey)}`}
${this.translationKey === ""
? this.title
: translateText(this.translationKey)}
</button>
`;
}
@@ -41,7 +41,7 @@ export class SettingKeybind extends LitElement {
<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
class="relative h-12 min-w-[80px] px-4 flex items-center justify-center bg-black/60 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"
: ""}"
@@ -50,7 +50,7 @@ export class SettingNumber extends LitElement {
<input
type="number"
id="setting-number-input"
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"
class="shrink-0 w-[100px] py-2 px-3 border border-white/20 rounded-lg bg-black/60 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}
@@ -52,7 +52,7 @@ export class SettingToggle extends LitElement {
@change=${this.handleChange}
/>
<span
class="absolute inset-0 bg-black/40 border border-white/10 transition-all duration-300 rounded-full
class="absolute inset-0 bg-black/60 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"
@@ -1,7 +1,7 @@
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import type { DiscordUser } from "../../../../core/ApiSchemas";
import { translateText } from "../../../Utils";
import { getDiscordAvatarUrl, translateText } from "../../../Utils";
@customElement("discord-user-header")
export class DiscordUserHeader extends LitElement {
@@ -23,15 +23,7 @@ export class DiscordUserHeader extends LitElement {
private get avatarUrl(): string | null {
const u = this._data;
if (!u) return null;
if (u.avatar) {
const ext = u.avatar.startsWith("a_") ? "gif" : "png";
return `https://cdn.discordapp.com/avatars/${u.id}/${u.avatar}.${ext}`;
}
if (u.discriminator !== undefined) {
const idx = Number(u.discriminator) % 5;
return `https://cdn.discordapp.com/embed/avatars/${idx}.png`;
}
return null;
return getDiscordAvatarUrl(u);
}
private get discordDisplayName(): string {
+6 -3
View File
@@ -126,10 +126,13 @@ export class HeadsUpMessage extends LitElement implements Layer {
${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
class="fixed top-[10%] left-1/2 -translate-x-1/2 z-[11000]
inline-flex items-center justify-center h-8 lg:h-10
w-fit max-w-[90vw]
bg-gray-900/60 rounded-md lg:rounded-lg
backdrop-blur-md text-white text-md lg:text-xl p-1 lg:p-2"
backdrop-blur-md text-white text-md lg:text-xl px-3 lg:px-4
text-center break-words"
style="word-wrap: break-word; hyphens: auto;"
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
>
${this.getMessage()}
+1 -1
View File
@@ -187,7 +187,7 @@ export class SettingsModal extends LitElement implements Layer {
return html`
<div
class="modal-overlay fixed inset-0 bg-black/50 backdrop-blur-xs z-2000 flex items-center justify-center p-4"
class="modal-overlay fixed inset-0 bg-black/60 backdrop-blur-xs z-2000 flex items-center justify-center p-4"
@contextmenu=${(e: Event) => e.preventDefault()}
>
<div
-13
View File
@@ -1,13 +0,0 @@
/* 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. */
/* Keep small helper rule for legacy button layout, remove global .c-modal rules */
o-modal o-button {
@media (min-width: 1024px) {
margin: 0 auto;
display: block;
text-align: center;
}
}