mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-26 15:34:36 +00:00
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:
+32
-60
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user