mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 21:00:26 +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:
+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() {
|
||||
|
||||
Reference in New Issue
Block a user