Refactor modal system: BaseModal renders shell, unified open(args) API (#3923)

## Description

Refactors the modal system so that `BaseModal` owns the `<o-modal>`
shell rendering, tab state, and lifecycle. Modal subclasses now provide
content via small hook methods (`renderHeaderSlot()`, `renderBody(tab)`,
`modalConfig()`) instead of each rebuilding the `<o-modal>` template and
inline-mode branching.

This sets up the foundation for a future modal URL router (e.g.
`#modal=store&tab=flags`), which will be a follow-up PR.

### What changed

**`BaseModal`** — `src/client/components/BaseModal.ts`
- Now renders the `<o-modal>` shell itself; subclasses no longer
duplicate it
- Owns `activeTab` state and dispatches per-tab rendering via
`renderBody(tab)`
- Single `modalConfig()` method returns `{ title?, tabs?, hideHeader?,
hideCloseButton?, alwaysMaximized?, maxWidth? }`
- Uniform `open(args?)` / `close(args?)` interface; subclasses interpret
args in `onOpen(args)` / `onClose(args)`
- Tabbed modals can lazy-load via `onTabEnter(tab)` lifecycle hook
- Re-entrancy guard on `open()` so `showPage()` re-invocations don't
clobber state set by the outer call
- Initial tab defaults to first entry in `modalConfig().tabs` so the
active tab is highlighted on first open

**17 modals migrated** to the new shape:
- Tabbed: Store, UserSetting, Leaderboard, Clan
- Non-tabbed: FlagInput, Account, TokenLogin, News, TerritoryPatterns,
Troubleshooting, SinglePlayer, Matchmaking, RankedModal, Help, Language
- Lobby: JoinLobbyModal, HostLobbyModal (kept their `confirmBeforeClose`
/ `closeAndLeave` / `closeWithoutLeaving` methods)

Per-modal diffs are mostly mechanical:
- Drop the `<o-modal>` wrapper template and the `if (this.inline) return
content` branch
- Drop the inner `<div class="${this.modalContainerClass}">` wrapper
(shell styling now lives on `<o-modal>`)
- Move header content into `renderHeaderSlot()` so it lives in the
sticky header area
- Convert `super.open()`/`super.close()` overrides into
`onOpen(args)`/`onClose(args)` hooks
- For tabbed modals: drop subclass `@state activeTab`, manual
`handleTabChange`, and the `render()` switch — all owned by BaseModal
now

**Other changes:**
- `Store`: in affiliate mode (`#affiliate=X`), tabs are hidden and a
single combined grid of purchasable affiliate items is shown
- `Main.ts`: `joinModal.open(lobbyId, lobbyInfo)` callsites converted to
the new `open({ lobbyId, lobbyInfo })` shape

### Follow-up

Modal URL router (`#modal=X&tab=Y&...`) is a separate PR on top of this
foundation.

## Please complete the following:

- [x] I have added screenshots for all UI updates _(no visual changes;
smoke-tested in dev)_
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file _(no new user-visible strings)_
- [ ] I have added relevant tests to the test directory _(no test
coverage; tested in browser)_
- [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:

evan
This commit is contained in:
Evan
2026-05-14 15:33:41 -07:00
committed by GitHub
parent e0f73598d6
commit bbe727cc84
19 changed files with 850 additions and 1001 deletions
+32 -60
View File
@@ -64,71 +64,43 @@ export class AccountModal extends BaseModal {
);
}
render() {
const content = this.isLoadingUser
? this.renderLoadingSpinner(
translateText("account_modal.fetching_account"),
)
: this.renderInner();
if (this.inline) {
return this.isLoadingUser
? html`<div class="${this.modalContainerClass}">
${modalHeader({
title: translateText("account_modal.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
})}
${content}
</div>`
: content;
}
return html`
<o-modal
id="account-modal"
title=""
?hideCloseButton=${true}
?inline=${this.inline}
hideHeader
>
${content}
</o-modal>
`;
}
private renderInner() {
protected renderHeaderSlot() {
const isLoggedIn = !!this.userMeResponse?.user;
const title = translateText("account_modal.title");
const publicId = this.userMeResponse?.player?.publicId ?? "";
const displayId = publicId || translateText("account_modal.not_found");
return modalHeader({
title: translateText("account_modal.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
rightContent:
isLoggedIn && !this.isLoadingUser
? html`
<div class="flex items-center gap-2">
<span
class="text-xs text-blue-400 font-bold uppercase tracking-wider"
>${translateText("account_modal.public_player_id")}</span
>
<copy-button
.lobbyId=${publicId}
.copyText=${publicId}
.displayText=${displayId}
></copy-button>
</div>
`
: undefined,
});
}
protected renderBody() {
if (this.isLoadingUser) {
return this.renderLoadingSpinner(
translateText("account_modal.fetching_account"),
);
}
const isLoggedIn = !!this.userMeResponse?.user;
return html`
<div class="${this.modalContainerClass}">
${modalHeader({
title,
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
rightContent: isLoggedIn
? html`
<div class="flex items-center gap-2">
<span
class="text-xs text-blue-400 font-bold uppercase tracking-wider"
>${translateText("account_modal.public_player_id")}</span
>
<copy-button
.lobbyId=${publicId}
.copyText=${publicId}
.displayText=${displayId}
></copy-button>
</div>
`
: undefined,
})}
<div class="flex-1 overflow-y-auto custom-scrollbar mr-1">
${isLoggedIn ? this.renderAccountInfo() : this.renderLoginOptions()}
</div>
<div class="custom-scrollbar mr-1">
${isLoggedIn ? this.renderAccountInfo() : this.renderLoginOptions()}
</div>
`;
}
+30 -36
View File
@@ -18,7 +18,6 @@ import "./components/CopyButton";
import { modalHeader } from "./components/ui/ModalHeader";
import { translateText } from "./Utils";
type Tab = "my-clans" | "browse";
type View =
| "list"
| "detail"
@@ -30,7 +29,6 @@ type View =
@customElement("clan-modal")
export class ClanModal extends BaseModal {
@state() private activeTab: Tab = "my-clans";
@state() private view: View = "list";
@state() private loading = false;
@@ -59,36 +57,42 @@ export class ClanModal extends BaseModal {
stats: ClanStats | null;
} | null = null;
render() {
const onListView = this.view === "list" && !this.selectedClanTag;
const tabs = onListView
? [
{ key: "my-clans", label: translateText("clan_modal.my_clans") },
{ key: "browse", label: translateText("clan_modal.browse") },
]
: [];
const header = onListView
private get onListView(): boolean {
return this.view === "list" && !this.selectedClanTag;
}
protected modalConfig() {
return {
tabs: this.onListView
? [
{ key: "my-clans", label: translateText("clan_modal.my_clans") },
{ key: "browse", label: translateText("clan_modal.browse") },
]
: [],
};
}
protected renderHeaderSlot() {
return this.onListView
? modalHeader({
title: translateText("clan_modal.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
})
: this.renderSubViewHeader();
return html`
<o-modal
id="clan-modal"
title=""
?hideCloseButton=${true}
?inline=${this.inline}
hideHeader
.tabs=${tabs}
.activeTab=${this.activeTab}
.onTabChange=${(key: string) => this.handleTabChange(key as Tab)}
>
${header ? html`<div slot="header">${header}</div>` : ""}
<div class="p-4 lg:p-[1.4rem]">${this.renderInner()}</div>
</o-modal>
`;
}
protected renderBody() {
return html`<div class="p-4 lg:p-[1.4rem]">${this.renderInner()}</div>`;
}
protected onTabEnter(tab: string): void {
this.view = "list";
this.selectedClan = null;
this.selectedClanTag = "";
if (tab === "my-clans") {
this.loadMyClans();
}
}
private tagPill(tag: string) {
@@ -152,16 +156,6 @@ export class ClanModal extends BaseModal {
});
}
private handleTabChange(tab: Tab) {
this.activeTab = tab;
this.view = "list";
this.selectedClan = null;
this.selectedClanTag = "";
if (tab === "my-clans") {
this.loadMyClans();
}
}
protected onOpen(): void {
this.loadMyClans();
}
+35 -53
View File
@@ -122,67 +122,49 @@ export class FlagInputModal extends BaseModal {
`;
}
render() {
const content = html`
<div class="${this.modalContainerClass}">
<div
class="relative flex flex-col border-b border-white/10 pb-4 shrink-0"
>
${modalHeader({
title: translateText("flag_input.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
rightContent: html`<not-logged-in-warning></not-logged-in-warning>`,
})}
protected renderHeaderSlot() {
return html`
<div
class="relative flex flex-col border-b border-white/10 pb-4 shrink-0"
>
${modalHeader({
title: translateText("flag_input.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
rightContent: html`<not-logged-in-warning></not-logged-in-warning>`,
})}
<div class="md:flex items-center gap-2 justify-center mt-4">
<input
class="h-12 w-full max-w-md border border-white/10 bg-black/60
<div class="md:flex items-center gap-2 justify-center mt-4">
<input
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"
placeholder=${translateText("flag_input.search_flag")}
.value=${this.search}
@change=${this.handleSearch}
@keyup=${this.handleSearch}
/>
</div>
</div>
<div class="flex justify-center py-3 shrink-0">
<o-button
class="no-crazygames"
variant="primary"
size="sm"
translationKey="main.store"
@click=${() => {
this.close();
window.showPage?.("page-item-store");
}}
></o-button>
</div>
<div
class="flex-1 overflow-y-auto px-3 pb-3 scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent mr-1"
>
${this.renderFlags()}
type="text"
placeholder=${translateText("flag_input.search_flag")}
.value=${this.search}
@change=${this.handleSearch}
@keyup=${this.handleSearch}
/>
</div>
</div>
`;
}
if (this.inline) {
return content;
}
protected renderBody() {
return html`
<o-modal
id="flag-input-modal"
title=${translateText("flag_input.title")}
?inline=${this.inline}
hideHeader
hideCloseButton
>
${content}
</o-modal>
<div class="flex justify-center py-3 shrink-0">
<o-button
class="no-crazygames"
variant="primary"
size="sm"
translationKey="main.store"
@click=${() => {
this.close();
window.showPage?.("page-item-store");
}}
></o-button>
</div>
<div class="px-3 pb-3">${this.renderFlags()}</div>
`;
}
+20 -36
View File
@@ -57,28 +57,28 @@ export class HelpModal extends BaseModal {
>`;
}
render() {
protected renderHeaderSlot() {
return modalHeader({
title: translateText("main.help"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
});
}
protected renderBody() {
const keybinds = this.keybinds;
const content = html`
<div class="${this.modalContainerClass}">
${modalHeader({
title: translateText("main.help"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
})}
<div
class="prose prose-invert prose-sm max-w-none overflow-y-auto px-6 py-3 mr-1
[&_a]:text-blue-400 [&_a:hover]:text-blue-300 transition-colors
[&_h1]:text-2xl [&_h1]:font-bold [&_h1]:mb-4 [&_h1]:text-white [&_h1]:border-b [&_h1]:border-white/10 [&_h1]:pb-2
[&_h2]:text-xl [&_h2]:font-bold [&_h2]:mt-6 [&_h2]:mb-3 [&_h2]:text-blue-200
[&_h3]:text-lg [&_h3]:font-semibold [&_h3]:mt-4 [&_h3]:mb-2 [&_h3]:text-blue-100
[&_ul]:pl-5 [&_ul]:list-disc [&_ul]:space-y-1
[&_li]:text-gray-300 [&_li]:leading-relaxed
[&_p]:text-gray-300 [&_p]:mb-3 [&_strong]:text-white [&_strong]:font-bold
scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent"
>
return html`
<div
class="prose prose-invert prose-sm max-w-none px-6 py-3
[&_a]:text-blue-400 [&_a:hover]:text-blue-300 transition-colors
[&_h1]:text-2xl [&_h1]:font-bold [&_h1]:mb-4 [&_h1]:text-white [&_h1]:border-b [&_h1]:border-white/10 [&_h1]:pb-2
[&_h2]:text-xl [&_h2]:font-bold [&_h2]:mt-6 [&_h2]:mb-3 [&_h2]:text-blue-200
[&_h3]:text-lg [&_h3]:font-semibold [&_h3]:mt-4 [&_h3]:mb-2 [&_h3]:text-blue-100
[&_ul]:pl-5 [&_ul]:list-disc [&_ul]:space-y-1
[&_li]:text-gray-300 [&_li]:leading-relaxed
[&_p]:text-gray-300 [&_p]:mb-3 [&_strong]:text-white [&_strong]:font-bold"
>
<!-- Video Tutorial Section -->
<div class="flex items-center gap-3 mb-3">
<div class="text-blue-400">
@@ -1228,22 +1228,6 @@ export class HelpModal extends BaseModal {
</div>
</div>
`;
if (this.inline) {
return content;
}
return html`
<o-modal
id="helpModal"
title="Instructions"
?inline=${this.inline}
?hideHeader=${true}
?hideCloseButton=${true}
>
${content}
</o-modal>
`;
}
openTroubleshooting() {
+22 -38
View File
@@ -150,7 +150,25 @@ export class HostLobbyModal extends BaseModal {
this.eventBus?.off(LobbyInfoEvent, this.handleLobbyInfo);
}
render() {
protected renderHeaderSlot() {
return modalHeader({
title: translateText("host_modal.title"),
onBack: () => {
this.leaveLobbyOnClose = true;
this.close();
},
ariaLabel: translateText("common.back"),
rightContent: html`
<copy-button
.lobbyId=${this.lobbyId}
.lobbySuffix=${this.lobbyUrlSuffix}
include-lobby-query
></copy-button>
`,
});
}
protected renderBody() {
const inputCards = [
html`<toggle-input-card
.labelKey=${"host_modal.max_timer"}
@@ -258,28 +276,9 @@ export class HostLobbyModal extends BaseModal {
></toggle-input-card>`,
];
const content = html`
<div class="${this.modalContainerClass}">
<!-- Header -->
${modalHeader({
title: translateText("host_modal.title"),
onBack: () => {
this.leaveLobbyOnClose = true;
this.close();
},
ariaLabel: translateText("common.back"),
rightContent: html`
<copy-button
.lobbyId=${this.lobbyId}
.lobbySuffix=${this.lobbyUrlSuffix}
include-lobby-query
></copy-button>
`,
})}
<div
class="flex-1 overflow-y-auto custom-scrollbar p-6 mr-1 mx-auto w-full max-w-5xl"
>
return html`
<div class="flex flex-col h-full">
<div class="flex-1 p-6 mx-auto w-full max-w-5xl">
<game-config-settings
class="block"
.sectionGapClass=${"space-y-10"}
@@ -416,21 +415,6 @@ export class HostLobbyModal extends BaseModal {
</div>
</div>
`;
if (this.inline) {
return content;
}
return html`
<o-modal
title=""
?hideCloseButton=${true}
?inline=${this.inline}
hideHeader
>
${content}
</o-modal>
`;
}
protected onOpen(): void {
+29 -54
View File
@@ -77,7 +77,26 @@ export class JoinLobbyModal extends BaseModal {
});
};
render() {
protected renderHeaderSlot() {
if (!this.currentLobbyId) {
return modalHeader({
title: translateText("private_lobby.title"),
onBack: () => this.closeAndLeave(),
ariaLabel: translateText("common.close"),
});
}
return modalHeader({
title: translateText("public_lobby.title"),
onBack: () => this.closeAndLeave(),
ariaLabel: translateText("common.close"),
rightContent:
this.currentLobbyId && this.isPrivateLobby()
? html`<copy-button .lobbyId=${this.currentLobbyId}></copy-button>`
: undefined,
});
}
protected renderBody() {
// Pre-join state: show lobby ID input form
if (!this.currentLobbyId) {
return this.renderJoinForm();
@@ -104,20 +123,9 @@ export class JoinLobbyModal extends BaseModal {
const hostClientID = this.isPrivateLobby()
? (this.lobbyCreatorClientID ?? "")
: "";
const content = html`
<div class="${this.modalContainerClass}">
${modalHeader({
title: translateText("public_lobby.title"),
onBack: () => this.closeAndLeave(),
ariaLabel: translateText("common.close"),
rightContent:
this.currentLobbyId && this.isPrivateLobby()
? html`
<copy-button .lobbyId=${this.currentLobbyId}></copy-button>
`
: undefined,
})}
<div class="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-4 mr-1">
return html`
<div class="flex flex-col h-full">
<div class="flex-1 custom-scrollbar p-6 space-y-4 mr-1">
${this.isConnecting
? html`
<div
@@ -206,31 +214,11 @@ export class JoinLobbyModal extends BaseModal {
`}
</div>
`;
if (this.inline) {
return content;
}
return html`
<o-modal
?hideHeader=${true}
?hideCloseButton=${true}
?inline=${this.inline}
>
${content}
</o-modal>
`;
}
private renderJoinForm() {
const content = html`
<div class="${this.modalContainerClass}">
${modalHeader({
title: translateText("private_lobby.title"),
onBack: () => this.closeAndLeave(),
ariaLabel: translateText("common.close"),
})}
<form @submit=${this.joinLobbyFromInput} class="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-4 mr-1">
return html`
<form @submit=${this.joinLobbyFromInput} class="custom-scrollbar p-6 space-y-4 mr-1">
<div class="flex flex-col gap-3">
<div class="flex gap-2">
<input
@@ -268,26 +256,13 @@ export class JoinLobbyModal extends BaseModal {
></o-button>
</div>
</div>
</div>
`;
if (this.inline) {
return content;
}
return html`
<o-modal
?hideHeader=${true}
?hideCloseButton=${true}
?inline=${this.inline}
>
${content}
</o-modal>
</form>
`;
}
public open(lobbyId: string = "", lobbyInfo?: GameInfo | PublicGameInfo) {
super.open();
protected onOpen(args?: Record<string, unknown>): void {
const lobbyId = typeof args?.lobbyId === "string" ? args.lobbyId : "";
const lobbyInfo = args?.lobbyInfo as GameInfo | PublicGameInfo | undefined;
if (lobbyId) {
this.startTrackingLobby(lobbyId, lobbyInfo);
// If opened with lobbyId but no lobbyInfo (URL join case), auto-join the lobby
+69 -92
View File
@@ -1,8 +1,7 @@
import { html } from "lit";
import { html, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { translateText } from "../client/Utils";
import { assetUrl } from "../core/AssetUrls";
import "./components/baseComponents/Modal";
import { BaseModal } from "./components/BaseModal";
import { modalHeader } from "./components/ui/ModalHeader";
@@ -29,105 +28,83 @@ export class LanguageModal extends BaseModal {
this.close();
};
render() {
const content = html`
<div
class="${this.modalContainerClass}"
>
<!-- Header -->
${modalHeader({
title: translateText("select_lang.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
})}
protected renderHeaderSlot() {
return modalHeader({
title: translateText("select_lang.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
});
}
protected renderBody(): TemplateResult {
return html`
<div class="custom-scrollbar p-2">
<div
class="flex-1 overflow-y-auto custom-scrollbar p-2"
class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3"
>
<div
class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3"
>
${this.languageList.map((lang) => {
const isActive = this.currentLang === lang.code;
const isDebug = lang.code === "debug";
${this.languageList.map((lang) => {
const isActive = this.currentLang === lang.code;
const isDebug = lang.code === "debug";
let buttonClasses =
"relative group rounded-xl border transition-all duration-200 flex items-center p-3 gap-3 w-full cursor-pointer";
let buttonClasses =
"relative group rounded-xl border transition-all duration-200 flex items-center p-3 gap-3 w-full cursor-pointer";
if (isDebug) {
buttonClasses +=
" animate-pulse font-bold text-white border-2 border-dashed border-cyan-400 shadow-[0_0_15px_rgba(34,211,238,0.2)] bg-gradient-to-r from-red-600 via-yellow-600 via-green-600 via-blue-600 to-purple-600";
} else if (isActive) {
buttonClasses += " bg-malibu-blue/20 border-malibu-blue/50";
} else {
buttonClasses +=
" bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20";
}
if (isDebug) {
buttonClasses +=
" animate-pulse font-bold text-white border-2 border-dashed border-cyan-400 shadow-[0_0_15px_rgba(34,211,238,0.2)] bg-gradient-to-r from-red-600 via-yellow-600 via-green-600 via-blue-600 to-purple-600";
} else if (isActive) {
buttonClasses += " bg-malibu-blue/20 border-malibu-blue/50";
} else {
buttonClasses +=
" bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20";
}
return html`
<button
class="${buttonClasses}"
@click=${() => this.selectLanguage(lang.code)}
>
<img
src=${assetUrl(`flags/${lang.svg}.svg`)}
class="w-8 h-6 object-contain rounded-sm shrink-0"
alt="${lang.code}"
/>
<div class="flex flex-col items-start min-w-0">
<span
class="text-sm font-bold uppercase tracking-wider whitespace-normal break-words w-full text-left ${isActive
? "text-white"
: "text-gray-200 group-hover:text-white"}"
>${lang.native}</span
>
<span
class="text-xs text-white/40 uppercase tracking-widest group-hover:text-white/60 transition-colors whitespace-normal break-words w-full text-left"
>${lang.en}</span
>
</div>
return html`
<button
class="${buttonClasses}"
@click=${() => this.selectLanguage(lang.code)}
>
<img
src=${assetUrl(`flags/${lang.svg}.svg`)}
class="w-8 h-6 object-contain rounded-sm shrink-0"
alt="${lang.code}"
/>
<div class="flex flex-col items-start min-w-0">
<span
class="text-sm font-bold uppercase tracking-wider whitespace-normal break-words w-full text-left ${isActive
? "text-white"
: "text-gray-200 group-hover:text-white"}"
>${lang.native}</span
>
<span
class="text-xs text-white/40 uppercase tracking-widest group-hover:text-white/60 transition-colors whitespace-normal break-words w-full text-left"
>${lang.en}</span
>
</div>
${isActive
? html`
<div class="ml-auto text-blue-400 shrink-0">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-5 h-5"
>
<path
fill-rule="evenodd"
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm13.36-1.814a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z"
clip-rule="evenodd"
/>
</svg>
</div>
`
: ""}
</button>
`;
})}
</div>
</div>
${isActive
? html`
<div class="ml-auto text-blue-400 shrink-0">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-5 h-5"
>
<path
fill-rule="evenodd"
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm13.36-1.814a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z"
clip-rule="evenodd"
/>
</svg>
</div>
`
: ""}
</button>
`;
})}
</div>
</div>
`;
if (this.inline) {
return content;
}
return html`
<o-modal
title=${translateText("select_lang.title")}
?inline=${this.inline}
.onClose=${this.close.bind(this)}
hideHeader
hideCloseButton
>
${content}
</o-modal>
`;
}
}
+45 -53
View File
@@ -10,7 +10,6 @@ import { translateText } from "./Utils";
@customElement("leaderboard-modal")
export class LeaderboardModal extends BaseModal {
@state() private activeTab: "players" | "clans" = "players";
@state()
private clanDateRange: { start: string; end: string } | null = null;
@@ -21,10 +20,26 @@ export class LeaderboardModal extends BaseModal {
private loadToken = 0;
protected modalConfig() {
return {
tabs: [
{
key: "players",
label: translateText("leaderboard_modal.ranked_tab"),
},
{ key: "clans", label: translateText("leaderboard_modal.clans_tab") },
],
};
}
protected onOpen(): void {
this.loadActiveTabData();
}
protected onTabEnter(): void {
this.loadActiveTabData();
}
private loadActiveTabData() {
const token = ++this.loadToken;
@@ -54,18 +69,13 @@ export class LeaderboardModal extends BaseModal {
})();
}
private handleTabChange(tab: "clans" | "players") {
this.activeTab = tab;
this.loadActiveTabData();
}
private handleClanDateRangeChange(
event: CustomEvent<{ start: string; end: string }>,
) {
this.clanDateRange = event.detail;
}
render() {
protected renderHeaderSlot() {
let dateRange = html``;
if (this.clanDateRange) {
const start = new Date(this.clanDateRange.start).toLocaleDateString();
@@ -80,54 +90,36 @@ export class LeaderboardModal extends BaseModal {
>(${translateText("leaderboard_modal.refresh_time")})</span
>`;
const tabs = [
{
key: "players",
label: translateText("leaderboard_modal.ranked_tab"),
},
{ key: "clans", label: translateText("leaderboard_modal.clans_tab") },
];
return modalHeader({
titleContent: html`
<div class="flex flex-wrap items-center gap-2">
<span
class="text-white text-xl sm:text-2xl font-bold uppercase tracking-widest"
>
${translateText("leaderboard_modal.title")}
</span>
${this.activeTab === "clans" ? dateRange : ""}
${this.activeTab === "players" ? refreshTime : ""}
</div>
`,
onBack: () => this.close(),
ariaLabel: translateText("common.close"),
});
}
protected renderBody() {
return html`
<o-modal
id="leaderboard-modal"
?inline=${this.inline}
hideCloseButton
hideHeader
.tabs=${tabs}
.activeTab=${this.activeTab}
.onTabChange=${(key: string) =>
this.handleTabChange(key as "players" | "clans")}
>
<div slot="header">
${modalHeader({
titleContent: html`
<div class="flex flex-wrap items-center gap-2">
<span
class="text-white text-xl sm:text-2xl font-bold uppercase tracking-widest"
>
${translateText("leaderboard_modal.title")}
</span>
${this.activeTab === "clans" ? dateRange : ""}
${this.activeTab === "players" ? refreshTime : ""}
</div>
`,
onBack: () => this.close(),
ariaLabel: translateText("common.close"),
})}
</div>
<div class="flex-1 min-h-0 h-full">
<leaderboard-player-list
class=${this.activeTab === "players" ? "h-full" : "hidden"}
></leaderboard-player-list>
<leaderboard-clan-table
class=${this.activeTab === "clans" ? "h-full" : "hidden"}
@date-range-change=${(
event: CustomEvent<{ start: string; end: string }>,
) => this.handleClanDateRangeChange(event)}
></leaderboard-clan-table>
</div>
</o-modal>
<div class="flex-1 min-h-0 h-full">
<leaderboard-player-list
class=${this.activeTab === "players" ? "h-full" : "hidden"}
></leaderboard-player-list>
<leaderboard-clan-table
class=${this.activeTab === "clans" ? "h-full" : "hidden"}
@date-range-change=${(
event: CustomEvent<{ start: string; end: string }>,
) => this.handleClanDateRangeChange(event)}
></leaderboard-clan-table>
</div>
`;
}
}
+7 -4
View File
@@ -611,7 +611,7 @@ class Client {
// On low end-chromebooks the join modal was not registered in time.
await new Promise((resolve) => setTimeout(resolve, 2000));
window.showPage?.("page-join-lobby");
this.joinModal?.open(lobbyId);
this.joinModal?.open({ lobbyId });
console.log(`CrazyGames: joining lobby ${lobbyId} from invite param`);
return;
}
@@ -721,7 +721,7 @@ class Client {
pathMatch && GAME_ID_REGEX.test(pathMatch[1]) ? pathMatch[1] : null;
if (lobbyId) {
window.showPage?.("page-join-lobby");
this.joinModal.open(lobbyId);
this.joinModal.open({ lobbyId });
console.log(`joining lobby ${lobbyId}`);
return;
}
@@ -729,7 +729,7 @@ class Client {
const affiliateCode = decodedHash.replace("#affiliate=", "");
strip();
if (affiliateCode) {
this.storeModal?.open(affiliateCode);
this.storeModal?.open({ affiliateCode });
}
}
if (decodedHash.startsWith("#refresh")) {
@@ -776,7 +776,10 @@ class Client {
document.body.classList.remove("in-game");
}
if (lobby.source === "public") {
this.joinModal?.open(lobby.gameID, lobby.publicLobbyInfo);
this.joinModal?.open({
lobbyId: lobby.gameID,
lobbyInfo: lobby.publicLobbyInfo,
});
}
// Only update URL immediately for private lobbies, not public ones
if (lobby.source !== "public") {
+12 -27
View File
@@ -28,39 +28,24 @@ export class MatchmakingModal extends BaseModal {
return this;
}
render() {
protected renderHeaderSlot() {
return modalHeader({
title: translateText("matchmaking_modal.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
});
}
protected renderBody() {
const eloDisplay = html`
<p class="text-center mt-2 mb-4 text-white/60">
${translateText("matchmaking_modal.elo", { elo: this.elo })}
</p>
`;
const content = html`
<div class="${this.modalContainerClass}">
${modalHeader({
title: translateText("matchmaking_modal.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
})}
<div class="flex-1 flex flex-col items-center justify-center gap-6 p-6">
${eloDisplay} ${this.renderInner()}
</div>
</div>
`;
if (this.inline) {
return content;
}
return html`
<o-modal
id="matchmaking-modal"
title="${translateText("matchmaking_modal.title")}"
hideCloseButton
hideHeader
>
${content}
</o-modal>
<div class="flex flex-col items-center justify-center gap-6 p-6">
${eloDisplay} ${this.renderInner()}
</div>
`;
}
+22 -38
View File
@@ -4,7 +4,6 @@ import { customElement, property, query } from "lit/decorators.js";
import version from "resources/version.txt?raw";
import { translateText } from "../client/Utils";
import { assetUrl } from "../core/AssetUrls";
import "./components/baseComponents/Modal";
import { BaseModal } from "./components/BaseModal";
import { modalHeader } from "./components/ui/ModalHeader";
import { normalizeNewsMarkdown } from "./NewsMarkdown";
@@ -15,46 +14,31 @@ export class NewsModal extends BaseModal {
private initialized: boolean = false;
render() {
const content = html`
<div class="${this.modalContainerClass}">
${modalHeader({
title: translateText("news.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
})}
<div
class="prose prose-invert prose-sm max-w-none overflow-y-auto px-6 py-3 mr-1
[&_a]:text-blue-400 [&_a:hover]:text-blue-300 transition-colors
[&_h1]:text-2xl [&_h1]:font-bold [&_h1]:mb-4 [&_h1]:text-white [&_h1]:border-b [&_h1]:border-white/10 [&_h1]:pb-2
[&_h2]:text-xl [&_h2]:font-bold [&_h2]:mt-6 [&_h2]:mb-3 [&_h2]:text-blue-200
[&_h3]:text-lg [&_h3]:font-semibold [&_h3]:mt-4 [&_h3]:mb-2 [&_h3]:text-blue-100
[&_ul]:pl-5 [&_ul]:my-3 [&_ul]:list-disc [&_ul]:space-y-1
[&_li]:text-gray-300 [&_li]:leading-relaxed
[&_p]:text-gray-300 [&_p]:mb-3 [&_strong]:text-white [&_strong]:font-bold
scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent"
>
${resolveMarkdown(this.markdown, {
includeImages: true,
includeCodeBlockClassNames: true,
})}
</div>
</div>
`;
if (this.inline) {
return content;
}
protected renderHeaderSlot() {
return modalHeader({
title: translateText("news.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
});
}
protected renderBody() {
return html`
<o-modal
title=${translateText("news.title")}
?inline=${this.inline}
hideCloseButton
hideHeader
<div
class="prose prose-invert prose-sm max-w-none px-6 py-3
[&_a]:text-blue-400 [&_a:hover]:text-blue-300 transition-colors
[&_h1]:text-2xl [&_h1]:font-bold [&_h1]:mb-4 [&_h1]:text-white [&_h1]:border-b [&_h1]:border-white/10 [&_h1]:pb-2
[&_h2]:text-xl [&_h2]:font-bold [&_h2]:mt-6 [&_h2]:mb-3 [&_h2]:text-blue-200
[&_h3]:text-lg [&_h3]:font-semibold [&_h3]:mt-4 [&_h3]:mb-2 [&_h3]:text-blue-100
[&_ul]:pl-5 [&_ul]:my-3 [&_ul]:list-disc [&_ul]:space-y-1
[&_li]:text-gray-300 [&_li]:leading-relaxed
[&_p]:text-gray-300 [&_p]:mb-3 [&_strong]:text-white [&_strong]:font-bold"
>
${content}
</o-modal>
${resolveMarkdown(this.markdown, {
includeImages: true,
includeCodeBlockClassNames: true,
})}
</div>
`;
}
+41 -57
View File
@@ -169,7 +169,34 @@ export class SinglePlayerModal extends BaseModal {
this.mapWins = winsMap;
}
render() {
protected renderHeaderSlot() {
return modalHeader({
title: translateText("main.solo") || "Solo",
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
rightContent: hasLinkedAccount(this.userMeResponse)
? html`<button
@click=${this.toggleAchievements}
class="flex items-center gap-2 px-3 py-2 rounded-xl border border-white/10 bg-white/5 hover:bg-white/10 transition-all shrink-0 ${this
.showAchievements
? "bg-yellow-500/10 border-yellow-500/30 text-yellow-400"
: "text-white/60"}"
>
<img
src=${assetUrl("images/MedalIconWhite.svg")}
class="w-4 h-4 opacity-80 shrink-0"
style="${this.showAchievements ? "" : "filter: grayscale(1);"}"
/>
<span
class="text-xs font-bold uppercase tracking-wider whitespace-nowrap"
>${translateText("single_modal.toggle_achievements")}</span
>
</button>`
: this.renderNotLoggedInBanner(),
});
}
protected renderBody() {
const inputCards = [
html`<toggle-input-card
.labelKey=${"single_modal.max_timer"}
@@ -224,39 +251,8 @@ export class SinglePlayerModal extends BaseModal {
></toggle-input-card>`,
];
const content = html`
<div class="${this.modalContainerClass}">
<!-- Header -->
${modalHeader({
title: translateText("main.solo") || "Solo",
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
rightContent: hasLinkedAccount(this.userMeResponse)
? html`<button
@click=${this.toggleAchievements}
class="flex items-center gap-2 px-3 py-2 rounded-xl border border-white/10 bg-white/5 hover:bg-white/10 transition-all shrink-0 ${this
.showAchievements
? "bg-yellow-500/10 border-yellow-500/30 text-yellow-400"
: "text-white/60"}"
>
<img
src=${assetUrl("images/MedalIconWhite.svg")}
class="w-4 h-4 opacity-80 shrink-0"
style="${this.showAchievements
? ""
: "filter: grayscale(1);"}"
/>
<span
class="text-xs font-bold uppercase tracking-wider whitespace-nowrap"
>${translateText("single_modal.toggle_achievements")}</span
>
</button>`
: this.renderNotLoggedInBanner(),
})}
<div
class="flex-1 overflow-y-auto custom-scrollbar px-6 pt-4 pb-6 mr-1 mx-auto w-full max-w-5xl"
>
return html`
<div class="px-6 pt-4 pb-6 mx-auto w-full max-w-5xl">
<game-config-settings
class="block"
.sectionGapClass=${"space-y-6"}
@@ -341,13 +337,17 @@ export class SinglePlayerModal extends BaseModal {
<!-- Footer Action -->
<div class="p-6 border-t border-white/10 bg-black/20">
${hasLinkedAccount(this.userMeResponse) && this.hasOptionsChanged()
? html`<div
class="mb-4 px-4 py-3 rounded-xl bg-yellow-500/20 border border-yellow-500/30 text-yellow-400 text-xs font-bold uppercase tracking-wider text-center"
>
${translateText("single_modal.options_changed_no_achievements")}
</div>`
: null}
${
hasLinkedAccount(this.userMeResponse) && this.hasOptionsChanged()
? html`<div
class="mb-4 px-4 py-3 rounded-xl bg-yellow-500/20 border border-yellow-500/30 text-yellow-400 text-xs font-bold uppercase tracking-wider text-center"
>
${translateText(
"single_modal.options_changed_no_achievements",
)}
</div>`
: null
}
<o-button
variant="primary"
width="block"
@@ -358,22 +358,6 @@ export class SinglePlayerModal extends BaseModal {
</div>
</div>
`;
if (this.inline) {
return content;
}
return html`
<o-modal
id="singlePlayerModal"
title="${translateText("main.solo") || "Solo"}"
?inline=${this.inline}
hideHeader
hideCloseButton
>
${content}
</o-modal>
`;
}
// Check if any options other than map and difficulty have been changed from defaults
+71 -57
View File
@@ -1,6 +1,6 @@
import type { TemplateResult } from "lit";
import { html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { customElement } from "lit/decorators.js";
import { UserMeResponse } from "../core/ApiSchemas";
import { Cosmetics } from "../core/CosmeticSchemas";
import { UserSettings } from "../core/game/UserSettings";
@@ -15,16 +15,29 @@ import {
} from "./Cosmetics";
import { translateText } from "./Utils";
type StoreTab = "patterns" | "flags" | "packs" | "subscriptions";
@customElement("store-modal")
export class StoreModal extends BaseModal {
@state() private activeTab: "patterns" | "flags" | "packs" | "subscriptions" =
"patterns";
private cosmetics: Cosmetics | null = null;
private isActive = false;
private affiliateCode: string | null = null;
private userMeResponse: UserMeResponse | false = false;
protected modalConfig() {
if (this.affiliateCode) {
// Affiliate mode: hide tabs, show only items associated with the code.
return {};
}
return {
tabs: [
{ key: "packs", label: translateText("store.packs") },
{ key: "subscriptions", label: translateText("store.subscriptions") },
{ key: "patterns", label: translateText("store.patterns") },
{ key: "flags", label: translateText("store.flags") },
],
};
}
connectedCallback() {
super.connectedCallback();
document.addEventListener(
@@ -188,71 +201,72 @@ export class StoreModal extends BaseModal {
`;
}
render() {
if (!this.isActive && !this.inline) return html``;
protected renderHeaderSlot() {
return this.renderHeader();
}
const tabs = [
{ key: "packs", label: translateText("store.packs") },
{ key: "subscriptions", label: translateText("store.subscriptions") },
{ key: "patterns", label: translateText("store.patterns") },
{ key: "flags", label: translateText("store.flags") },
];
protected renderBody(key: string): TemplateResult {
if (this.affiliateCode) {
return this.renderAffiliateGrid();
}
switch (key as StoreTab) {
case "patterns":
return this.renderPatternGrid();
case "flags":
return this.renderFlagGrid();
case "subscriptions":
return this.renderSubscriptionGrid();
case "packs":
default:
return this.renderPackGrid();
}
}
const grid =
this.activeTab === "patterns"
? this.renderPatternGrid()
: this.activeTab === "flags"
? this.renderFlagGrid()
: this.activeTab === "subscriptions"
? this.renderSubscriptionGrid()
: this.renderPackGrid();
private renderAffiliateGrid(): TemplateResult {
const items = resolveCosmetics(
this.cosmetics,
this.userMeResponse,
this.affiliateCode,
).filter(
(r) =>
(r.type === "pattern" || r.type === "flag" || r.type === "pack") &&
r.relationship === "purchasable",
);
if (items.length === 0) {
return html`<div
class="text-white/40 text-sm font-bold uppercase tracking-wider text-center py-8"
>
${translateText("store.no_skins")}
</div>`;
}
return html`
<o-modal
id="storeModal"
title="${translateText("store.title")}"
?inline=${this.inline}
?hideHeader=${true}
?hideCloseButton=${true}
.tabs=${tabs}
.activeTab=${this.activeTab}
.onTabChange=${(key: string) =>
(this.activeTab = key as
| "patterns"
| "flags"
| "packs"
| "subscriptions")}
<div
class="flex flex-wrap gap-4 p-8 justify-center items-stretch content-start"
>
<div slot="header">${this.renderHeader()}</div>
${grid}
</o-modal>
${items.map(
(r) => html`
<cosmetic-button
.resolved=${r}
.onPurchase=${purchaseCosmetic}
></cosmetic-button>
`,
)}
</div>
`;
}
public async open(options?: string | { affiliateCode?: string }) {
if (this.isModalOpen) return;
this.isActive = true;
if (typeof options === "string") {
this.affiliateCode = options;
} else if (
options !== null &&
typeof options === "object" &&
!Array.isArray(options)
) {
this.affiliateCode = options.affiliateCode ?? null;
} else {
this.affiliateCode = null;
}
protected async onOpen(args?: Record<string, unknown>) {
const affiliate =
typeof args?.affiliateCode === "string" ? args.affiliateCode : null;
this.affiliateCode = affiliate;
this.cosmetics ??= await fetchCosmetics();
await this.refresh();
super.open();
}
public close() {
this.isActive = false;
protected onClose(): void {
this.affiliateCode = null;
super.close();
}
public async refresh() {
+35 -52
View File
@@ -122,66 +122,49 @@ export class TerritoryPatternsModal extends BaseModal {
`;
}
render() {
const content = html`
<div class="${this.modalContainerClass}">
<div
class="relative flex flex-col border-b border-white/10 pb-4 shrink-0"
>
${modalHeader({
title: translateText("territory_patterns.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
rightContent: html`<not-logged-in-warning></not-logged-in-warning>`,
})}
protected renderHeaderSlot() {
return html`
<div
class="relative flex flex-col border-b border-white/10 pb-4 shrink-0"
>
${modalHeader({
title: translateText("territory_patterns.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
rightContent: html`<not-logged-in-warning></not-logged-in-warning>`,
})}
<div class="md:flex items-center gap-2 justify-center mt-4">
<input
class="h-12 w-full max-w-md border border-white/10 bg-black/60
<div class="md:flex items-center gap-2 justify-center mt-4">
<input
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"
placeholder=${translateText("territory_patterns.search")}
.value=${this.search}
@change=${this.handleSearch}
@keyup=${this.handleSearch}
/>
</div>
</div>
<div class="flex justify-center py-3 shrink-0">
<o-button
class="no-crazygames"
variant="primary"
size="sm"
translationKey="main.store"
@click=${() => {
this.close();
window.showPage?.("page-item-store");
}}
></o-button>
</div>
<div
class="flex-1 overflow-y-auto px-3 pb-3 scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent mr-1"
>
${this.renderPatternGrid()}
type="text"
placeholder=${translateText("territory_patterns.search")}
.value=${this.search}
@change=${this.handleSearch}
@keyup=${this.handleSearch}
/>
</div>
</div>
`;
}
if (this.inline) {
return content;
}
protected renderBody() {
return html`
<o-modal
id="territoryPatternsModal"
title="${translateText("territory_patterns.title")}"
?inline=${this.inline}
?hideHeader=${true}
?hideCloseButton=${true}
>
${content}
</o-modal>
<div class="flex justify-center py-3 shrink-0">
<o-button
class="no-crazygames"
variant="primary"
size="sm"
translationKey="main.store"
@click=${() => {
this.close();
window.showPage?.("page-item-store");
}}
></o-button>
</div>
<div class="px-3 pb-3">${this.renderPatternGrid()}</div>
`;
}
+27 -37
View File
@@ -22,35 +22,23 @@ export class TokenLoginModal extends BaseModal {
super();
}
render() {
const title = translateText("token_login_modal.title");
const content = html`
<div class="${this.modalContainerClass}">
${modalHeader({
title,
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
})}
<div class="flex-1 flex flex-col gap-4 p-6">
${this.email ? this.loginSuccess(this.email) : this.loggingIn()}
</div>
</div>
`;
protected modalConfig() {
return { maxWidth: "620px" };
}
if (this.inline) {
return content;
}
protected renderHeaderSlot() {
return modalHeader({
title: translateText("token_login_modal.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
});
}
protected renderBody() {
return html`
<o-modal
id="token-login-modal"
title="${title}"
hideHeader
hideCloseButton
maxWidth="620px"
>
${content}
</o-modal>
<div class="flex-1 flex flex-col gap-4 p-6">
${this.email ? this.loginSuccess(this.email) : this.loggingIn()}
</div>
`;
}
@@ -89,15 +77,6 @@ export class TokenLoginModal extends BaseModal {
`;
}
public open(): void {
if (!this.token) {
return;
}
super.open();
clearInterval(this.retryInterval);
this.retryInterval = setInterval(() => this.tryLogin(), 3000);
}
public openWithToken(token: string): void {
this.token = token;
this.email = null;
@@ -106,11 +85,22 @@ export class TokenLoginModal extends BaseModal {
this.open();
}
public close() {
public open(args?: Record<string, unknown>): void {
if (!this.token) {
return;
}
super.open(args);
}
protected onOpen(): void {
clearInterval(this.retryInterval);
this.retryInterval = setInterval(() => this.tryLogin(), 3000);
}
protected onClose(): void {
this.token = null;
clearInterval(this.retryInterval);
this.attemptCount = 0;
super.close();
this.isAttemptingLogin = false;
}
+114 -138
View File
@@ -3,7 +3,6 @@ import { customElement, property } from "lit/decorators.js";
import { assetUrl } from "../core/AssetUrls";
import { translateText } from "./Utils";
import { BaseModal } from "./components/BaseModal";
import "./components/baseComponents/Modal";
import { modalHeader } from "./components/ui/ModalHeader";
import {
collectGraphicsDiagnostics,
@@ -29,140 +28,117 @@ export class TroubleshootingModal extends BaseModal {
this.initialized = true;
}
render() {
const content = html`
<div class="${this.modalContainerClass}">
${modalHeader({
titleContent: html` <div
class="w-full flex flex-col sm:flex-row justify-between gap-2"
>
<span
class="text-white text-xl sm:text-2xl md:text-3xl font-bold uppercase tracking-widest break-words hyphens-auto"
>
<a
class="hover:text-blue-200 text-blue-400 cursor-pointer"
@click=${this.close}
>${translateText("main.help")}</a
>
/ ${translateText("troubleshooting.title")}
</span>
<o-button
variant="primary"
size="sm"
translationKey="common.copy"
@click=${this.copyDiagnostics}
></o-button>
</div>`,
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
})}
${this.loading
? ""
: html`
<div
class="flex-1 overflow-y-auto px-1 scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent mr-1"
>
${this.section(
"",
html`${this.infoTip(
translateText("troubleshooting.hardware_acceleration_tip"),
true,
)}`,
)}
${this.section(
translateText("troubleshooting.environment"),
html`
${this.row(
translateText("troubleshooting.browser"),
this.diagnostics!.browser.engine,
)}
${this.row(
translateText("troubleshooting.platform"),
this.diagnostics!.browser.platform,
)}
${this.row(
translateText("troubleshooting.os"),
this.diagnostics!.browser.os,
)}
${this.row(
translateText("troubleshooting.device_pixel_ratio"),
this.diagnostics!.browser.dpr,
)}
${this.infoTip(
translateText("troubleshooting.chromium_tip"),
)}
`,
)}
${this.section(
translateText("troubleshooting.rendering"),
html`
${this.row(
translateText("troubleshooting.renderer"),
this.describeRenderer(this.diagnostics!.rendering),
)}
${this.row(
translateText("troubleshooting.max_texture_size"),
this.diagnostics!.rendering.maxTextureSize ??
translateText("troubleshooting.unknown"),
)}
${this.row(
translateText("troubleshooting.high_precision_shaders"),
this.diagnostics!.rendering.shaderHighp === true
? translateText("troubleshooting.yes")
: translateText("troubleshooting.no"),
)}${this.row(
translateText("troubleshooting.gpu"),
!this.diagnostics!.rendering.gpu ||
this.diagnostics!.rendering.gpu.unavailable
? translateText("troubleshooting.unavailable")
: `${this.diagnostics!.rendering.gpu.vendor}${this.diagnostics!.rendering.gpu.renderer}`,
)}
${this.infoTip(translateText("troubleshooting.gpu_tip"))}
`,
)}
${this.section(
translateText("troubleshooting.power"),
html`
${this.diagnostics!.power.unavailable
? this.row(
translateText("troubleshooting.battery"),
translateText("troubleshooting.unavailable"),
)
: html`
${this.row(
translateText("troubleshooting.charging"),
this.diagnostics!.power.charging
? translateText("troubleshooting.yes")
: translateText("troubleshooting.no"),
)}
${this.row(
translateText("troubleshooting.battery_level"),
this.diagnostics!.power.level,
)}
`}
${this.infoTip(
translateText("troubleshooting.power_saving_tip"),
)}
`,
)}
</div>
`}
</div>
`;
if (this.inline) {
return content;
}
return html`
<o-modal
title=${translateText("troubleshooting.title")}
?inline=${this.inline}
hideCloseButton
hideHeader
protected renderHeaderSlot() {
return modalHeader({
titleContent: html` <div
class="w-full flex flex-col sm:flex-row justify-between gap-2"
>
${content}
</o-modal>
<span
class="text-white text-xl sm:text-2xl md:text-3xl font-bold uppercase tracking-widest break-words hyphens-auto"
>
<a
class="hover:text-blue-200 text-blue-400 cursor-pointer"
@click=${this.close}
>${translateText("main.help")}</a
>
/ ${translateText("troubleshooting.title")}
</span>
<o-button
variant="primary"
size="sm"
translationKey="common.copy"
@click=${this.copyDiagnostics}
></o-button>
</div>`,
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
});
}
protected renderBody() {
if (this.loading) return html``;
return html`
<div class="px-1">
${this.section(
"",
html`${this.infoTip(
translateText("troubleshooting.hardware_acceleration_tip"),
true,
)}`,
)}
${this.section(
translateText("troubleshooting.environment"),
html`
${this.row(
translateText("troubleshooting.browser"),
this.diagnostics!.browser.engine,
)}
${this.row(
translateText("troubleshooting.platform"),
this.diagnostics!.browser.platform,
)}
${this.row(
translateText("troubleshooting.os"),
this.diagnostics!.browser.os,
)}
${this.row(
translateText("troubleshooting.device_pixel_ratio"),
this.diagnostics!.browser.dpr,
)}
${this.infoTip(translateText("troubleshooting.chromium_tip"))}
`,
)}
${this.section(
translateText("troubleshooting.rendering"),
html`
${this.row(
translateText("troubleshooting.renderer"),
this.describeRenderer(this.diagnostics!.rendering),
)}
${this.row(
translateText("troubleshooting.max_texture_size"),
this.diagnostics!.rendering.maxTextureSize ??
translateText("troubleshooting.unknown"),
)}
${this.row(
translateText("troubleshooting.high_precision_shaders"),
this.diagnostics!.rendering.shaderHighp === true
? translateText("troubleshooting.yes")
: translateText("troubleshooting.no"),
)}${this.row(
translateText("troubleshooting.gpu"),
!this.diagnostics!.rendering.gpu ||
this.diagnostics!.rendering.gpu.unavailable
? translateText("troubleshooting.unavailable")
: `${this.diagnostics!.rendering.gpu.vendor}${this.diagnostics!.rendering.gpu.renderer}`,
)}
${this.infoTip(translateText("troubleshooting.gpu_tip"))}
`,
)}
${this.section(
translateText("troubleshooting.power"),
html`
${this.diagnostics!.power.unavailable
? this.row(
translateText("troubleshooting.battery"),
translateText("troubleshooting.unavailable"),
)
: html`
${this.row(
translateText("troubleshooting.charging"),
this.diagnostics!.power.charging
? translateText("troubleshooting.yes")
: translateText("troubleshooting.no"),
)}
${this.row(
translateText("troubleshooting.battery_level"),
this.diagnostics!.power.level,
)}
`}
${this.infoTip(translateText("troubleshooting.power_saving_tip"))}
`,
)}
</div>
`;
}
@@ -237,13 +213,13 @@ export class TroubleshootingModal extends BaseModal {
}
public close(): void {
// Override BaseModal.close() to navigate back to Help (this modal is
// opened from inside HelpModal), not to page-play like other inline modals.
this.unregisterEscapeHandler();
this.onClose();
if (this.inline) {
this.style.pointerEvents = "none";
if (window.showPage) {
window.showPage?.("page-help");
}
window.showPage?.("page-help");
} else {
this.modalEl?.close();
}
+22 -37
View File
@@ -17,8 +17,6 @@ export class UserSettingModal extends BaseModal {
private userSettings: UserSettings = new UserSettings();
private readonly defaultKeybinds = getDefaultKeybinds(Platform.isMac);
@state() private activeTab: "basic" | "keybinds" = "basic";
@state() private keySequence: string[] = [];
@state() private showEasterEggSettings = false;
@@ -322,40 +320,31 @@ export class UserSettingModal extends BaseModal {
this.userSettings.togglePerformanceOverlay();
}
render() {
const activeContent =
this.activeTab === "basic"
? this.renderBasicSettings()
: this.renderKeybindSettings();
protected modalConfig() {
return {
tabs: [
{ key: "basic", label: translateText("user_setting.tab_basic") },
{ key: "keybinds", label: translateText("user_setting.tab_keybinds") },
],
};
}
const tabs = [
{ key: "basic", label: translateText("user_setting.tab_basic") },
{ key: "keybinds", label: translateText("user_setting.tab_keybinds") },
];
protected renderHeaderSlot() {
return modalHeader({
title: translateText("user_setting.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
showDivider: true,
});
}
protected renderBody(tab: string) {
const body =
tab === "keybinds"
? this.renderKeybindSettings()
: this.renderBasicSettings();
return html`
<o-modal
title="${translateText("user_setting.title")}"
?inline=${this.inline}
hideCloseButton
hideHeader
.tabs=${tabs}
.activeTab=${this.activeTab}
.onTabChange=${(key: string) =>
(this.activeTab = key as "basic" | "keybinds")}
>
<div slot="header">
${modalHeader({
title: translateText("user_setting.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
showDivider: true,
})}
</div>
<div class="flex flex-col gap-2 p-4 lg:p-[1.4rem]">
${activeContent}
</div>
</o-modal>
<div class="flex flex-col gap-2 p-4 lg:p-[1.4rem]">${body}</div>
`;
}
@@ -944,8 +933,4 @@ export class UserSettingModal extends BaseModal {
window.addEventListener("keydown", this.handleEasterEggKey);
this.loadKeybindsFromStorage();
}
public open() {
super.open();
}
}
+185 -91
View File
@@ -1,29 +1,47 @@
import { html, LitElement, TemplateResult } from "lit";
import { property, query, state } from "lit/decorators.js";
import "./baseComponents/Modal";
import type { OModalTab } from "./baseComponents/Modal";
/**
* Base class for modal components that provides unified Escape key handling and common modal patterns.
* Static-ish configuration for the <o-modal> shell.
* Subclasses return a fresh object from modalConfig(); avoid heavy work — it's
* read on every render() and during open()/setActiveTab().
*/
export interface ModalConfig {
title?: string;
tabs?: OModalTab[];
hideHeader?: boolean;
hideCloseButton?: boolean;
alwaysMaximized?: boolean;
maxWidth?: string;
}
/**
* Base class for modal components.
*
* Features:
* - Visibility tracking with isModalOpen state
* - Escape key handler with visibility check and target validation
* - Automatic listener lifecycle management
* - Common inline/modal element handling
* - Shared open/close logic with hooks for custom behavior
* - Standardized loading spinner UI
* - Consistent modal container styling
* BaseModal renders the <o-modal> shell itself — subclasses provide content
* via renderContent() (or renderTab() for tabbed modals) and declare
* configuration via modalConfig().
*
* Lifecycle:
* open(args?) → onOpen(args) hook → shell visible
* close(args?) → onClose(args) hook → shell hidden
*
* Tabs (optional):
* Return a non-empty tabs[] from modalConfig(). BaseModal owns activeTab
* state and dispatches rendering to renderTab(key). Subclasses can opt in
* to onTabEnter(key) for per-tab lifecycle (e.g. lazy load).
*/
export abstract class BaseModal extends LitElement {
@state() protected isModalOpen = false;
@state() protected activeTab = "";
@property({ type: Boolean }) inline = false;
/**
* Standard modal container class string.
* Provides consistent dark glassmorphic styling across all modals.
* No rounding on mobile for full-screen appearance.
*/
protected readonly modalContainerClass =
"h-full flex flex-col overflow-hidden bg-black/70 backdrop-blur-xl lg:rounded-2xl lg:border border-white/10";
// Re-entrancy guard: showPage() (for inline modals) re-invokes .open()
// with no args after we call it. We must not re-run onOpen(undefined)
// from that nested call, which would clobber state set by the outer call.
private opening = false;
@query("o-modal") protected modalEl?: HTMLElement & {
open: () => void;
@@ -31,14 +49,165 @@ export abstract class BaseModal extends LitElement {
onClose?: () => void;
};
// ---- Subclass configuration ----
// Override modalConfig() to configure the rendered <o-modal>. Defaults match
// the most common shape (custom in-content header, no built-in close button).
protected modalConfig(): ModalConfig {
return {};
}
/** Render slot="header" content. Default: no header slot. */
protected renderHeaderSlot(): TemplateResult | null {
return null;
}
/**
* Render the modal body. For tabbed modals, switch on `tab` to render the
* appropriate panel. Modals without tabs can ignore the argument.
*/
protected renderBody(_tab: string): TemplateResult {
return html``;
}
// ---- Lifecycle hooks ----
/** Called when the modal opens. Receives router args / direct-caller args. */
protected onOpen(_args?: Record<string, unknown>): void {}
/** Called when the modal closes. */
protected onClose(_args?: Record<string, unknown>): void {}
/** Called when the active tab changes (including initial set on open). */
protected onTabEnter(_key: string): void {}
/**
* Guard called before closing via Escape key or click-outside.
* Return false to prevent the modal from closing.
*/
public confirmBeforeClose(): boolean {
return true;
}
// ---- Rendering ----
createRenderRoot() {
return this;
}
protected willUpdate(): void {
// Default the active tab so the highlight is correct on first render,
// before open() runs (matters for inline modals rendered on page mount).
const tabs = this.modalConfig().tabs ?? [];
if (tabs.length && this.activeTab === "") {
this.activeTab = tabs[0].key;
}
}
render(): TemplateResult {
const cfg = this.modalConfig();
const tabs = cfg.tabs ?? [];
const body = this.renderBody(this.activeTab);
const headerSlot = this.renderHeaderSlot();
return html`
<o-modal
title=${cfg.title ?? ""}
?inline=${this.inline}
?hideHeader=${cfg.hideHeader ?? true}
?hideCloseButton=${cfg.hideCloseButton ?? true}
?alwaysMaximized=${cfg.alwaysMaximized ?? false}
maxWidth=${cfg.maxWidth ?? ""}
.tabs=${tabs}
.activeTab=${this.activeTab}
.onTabChange=${(key: string) => this.setActiveTab(key)}
>
${headerSlot ? html`<div slot="header">${headerSlot}</div>` : null}
${body}
</o-modal>
`;
}
// ---- Open / close ----
public isOpen(): boolean {
return this.isModalOpen;
}
/**
* Open the modal. `args` is a loose bag forwarded to onOpen(). The router
* passes parsed URL params; direct callers can pass whatever they want.
*
* Recognized keys:
* - tab: string — sets active tab (validated against modalTabs)
*/
public open(args?: Record<string, unknown>): void {
if (this.opening) return;
this.opening = true;
try {
const tabs = this.modalConfig().tabs ?? [];
if (tabs.length && this.activeTab === "") {
this.activeTab = tabs[0].key;
}
if (
typeof args?.tab === "string" &&
tabs.some((t) => t.key === args.tab)
) {
this.activeTab = args.tab;
}
const wasOpen = this.isModalOpen;
if (!wasOpen) {
this.registerEscapeHandler();
}
this.onOpen(args);
if (this.activeTab) this.onTabEnter(this.activeTab);
if (wasOpen) return;
if (this.inline) {
const needsShow =
this.classList.contains("hidden") || this.style.display === "none";
if (needsShow && window.showPage) {
const pageId = this.id || this.tagName.toLowerCase();
window.showPage?.(pageId);
}
this.style.pointerEvents = "auto";
} else {
this.modalEl?.open();
}
} finally {
this.opening = false;
}
}
public close(args?: Record<string, unknown>): void {
this.unregisterEscapeHandler();
this.onClose(args);
if (this.inline) {
this.style.pointerEvents = "none";
if (window.showPage) {
window.showPage?.("page-play");
}
} else {
this.modalEl?.close();
}
}
// ---- Tab management ----
/** Programmatically change the active tab. Triggers onTabEnter. */
public setActiveTab(key: string): void {
const tabs = this.modalConfig().tabs ?? [];
if (!tabs.some((t) => t.key === key)) return;
if (this.activeTab === key) return;
this.activeTab = key;
this.onTabEnter(key);
}
// ---- Internals ----
protected firstUpdated(): void {
if (this.modalEl) {
this.modalEl.onClose = () => {
@@ -59,10 +228,6 @@ export abstract class BaseModal extends LitElement {
super.disconnectedCallback();
}
/**
* Handle Escape key press to close the modal.
* Only closes if the modal is open.
*/
private handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && this.isModalOpen) {
e.preventDefault();
@@ -73,87 +238,16 @@ export abstract class BaseModal extends LitElement {
}
};
/**
* Register the Escape key handler and mark modal as open.
*/
protected registerEscapeHandler() {
this.isModalOpen = true;
window.addEventListener("keydown", this.handleKeyDown);
}
/**
* Unregister the Escape key handler and mark modal as closed.
*/
protected unregisterEscapeHandler() {
this.isModalOpen = false;
window.removeEventListener("keydown", this.handleKeyDown);
}
/**
* Hook for custom logic when modal opens.
* Override this in subclasses to add custom open behavior.
*/
protected onOpen(): void {
// Default implementation does nothing
}
/**
* Hook for custom logic when modal closes.
* Override this in subclasses to add custom close behavior.
*/
protected onClose(): void {
// Default implementation does nothing
}
/**
* Guard called before closing via Escape key or click-outside.
* Override in subclasses to show a confirmation dialog.
* Return false to prevent the modal from closing.
*/
public confirmBeforeClose(): boolean {
return true;
}
/**
* Open the modal. Handles both inline and modal element modes.
* Subclasses can override onOpen() for custom behavior.
*/
public open(): void {
if (this.isModalOpen) return;
this.registerEscapeHandler();
this.onOpen();
if (this.inline) {
const needsShow =
this.classList.contains("hidden") || this.style.display === "none";
if (needsShow && window.showPage) {
const pageId = this.id || this.tagName.toLowerCase();
window.showPage?.(pageId);
}
this.style.pointerEvents = "auto";
} else {
this.modalEl?.open();
}
}
/**
* Close the modal. Handles both inline and modal element modes.
* Subclasses can override onClose() for custom behavior.
*/
public close(): void {
this.unregisterEscapeHandler();
this.onClose();
if (this.inline) {
this.style.pointerEvents = "none";
if (window.showPage) {
window.showPage?.("page-play");
}
} else {
this.modalEl?.close();
}
}
protected renderLoadingSpinner(
message?: string,
spinnerColor: "blue" | "green" | "yellow" | "white" = "blue",
+32 -41
View File
@@ -78,50 +78,41 @@ export class RankedModal extends BaseModal {
return this;
}
render() {
const content = html`
<div class="${this.modalContainerClass}">
${modalHeader({
title: translateText("mode_selector.ranked_title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
})}
<div class="flex-1 min-h-0 overflow-y-auto custom-scrollbar p-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
${this.renderCard(
translateText("mode_selector.ranked_1v1_title"),
this.errorMessage ??
(hasLinkedAccount(this.userMeResponse)
? translateText("matchmaking_modal.elo", { elo: this.elo })
: translateText("mode_selector.ranked_title")),
() => this.handleRanked(),
)}
${this.renderDisabledCard(
translateText("mode_selector.ranked_2v2_title"),
translateText("mode_selector.coming_soon"),
)}
${this.renderDisabledCard(
translateText("mode_selector.coming_soon"),
"",
)}
${this.renderDisabledCard(
translateText("mode_selector.coming_soon"),
"",
)}
</div>
protected renderHeaderSlot() {
return modalHeader({
title: translateText("mode_selector.ranked_title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
});
}
protected renderBody() {
return html`
<div class="custom-scrollbar p-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
${this.renderCard(
translateText("mode_selector.ranked_1v1_title"),
this.errorMessage ??
(hasLinkedAccount(this.userMeResponse)
? translateText("matchmaking_modal.elo", { elo: this.elo })
: translateText("mode_selector.ranked_title")),
() => this.handleRanked(),
)}
${this.renderDisabledCard(
translateText("mode_selector.ranked_2v2_title"),
translateText("mode_selector.coming_soon"),
)}
${this.renderDisabledCard(
translateText("mode_selector.coming_soon"),
"",
)}
${this.renderDisabledCard(
translateText("mode_selector.coming_soon"),
"",
)}
</div>
</div>
`;
if (this.inline) {
return content;
}
return html`
<o-modal ?hideHeader=${true} ?hideCloseButton=${true}>
${content}
</o-modal>
`;
}
private renderCard(title: string, subtitle: string, onClick: () => void) {