mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-25 01:57:13 +00:00
add game history to a profile (#4662)
## Description: adds game tab to player profile: <img width="1235" height="928" alt="image" src="https://github.com/user-attachments/assets/660fba48-18d0-4538-a9d5-f33abc0e693d" /> also make ui a bit more uniform, both account + profile modal match, and changed the filtering options to be the same as the games tab <img width="972" height="581" alt="image" src="https://github.com/user-attachments/assets/6f10f78e-e32b-42a6-8df4-7aa6f0cf5146" /> <img width="1006" height="623" alt="image" src="https://github.com/user-attachments/assets/caffb742-1d64-4d21-aa13-d3a7191088ca" /> ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: w.o.n
This commit is contained in:
+10
-36
@@ -107,25 +107,19 @@ export class AccountModal extends BaseModal {
|
||||
protected renderHeaderSlot() {
|
||||
const isLoggedIn = !!this.userMeResponse?.user;
|
||||
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
|
||||
isLoggedIn && !this.isLoadingUser && publicId
|
||||
? 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>
|
||||
<copy-button
|
||||
class="shrink-0"
|
||||
.copyText=${playerProfileUrl(publicId)}
|
||||
.displayText=${translateText("player_profile.share")}
|
||||
.showVisibilityToggle=${false}
|
||||
></copy-button>
|
||||
`
|
||||
: undefined,
|
||||
});
|
||||
@@ -412,30 +406,10 @@ export class AccountModal extends BaseModal {
|
||||
translateText("account_modal.no_stats"),
|
||||
);
|
||||
}
|
||||
const publicId = this.userMeResponse?.player?.publicId ?? "";
|
||||
return html`
|
||||
<div class="bg-white/5 rounded-xl border border-white/10 p-6">
|
||||
<div class="flex items-center justify-between gap-2 mb-4">
|
||||
<h3 class="text-lg font-bold text-white flex items-center gap-2">
|
||||
<span class="text-blue-400">📊</span>
|
||||
${translateText("account_modal.stats_overview")}
|
||||
</h3>
|
||||
${publicId
|
||||
? html`
|
||||
<copy-button
|
||||
compact
|
||||
class="shrink-0"
|
||||
.copyText=${playerProfileUrl(publicId)}
|
||||
.displayText=${translateText("player_profile.share")}
|
||||
.showVisibilityToggle=${false}
|
||||
></copy-button>
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
<player-stats-tree-view
|
||||
.statsTree=${this.statsTree}
|
||||
></player-stats-tree-view>
|
||||
</div>
|
||||
<player-stats-tree-view
|
||||
.statsTree=${this.statsTree}
|
||||
></player-stats-tree-view>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ export class GameStatsModal extends BaseModal {
|
||||
protected routerName = "stats";
|
||||
|
||||
@state() private gameId: string | null = null;
|
||||
private openedFrom: "account" | "clan" | null = null;
|
||||
private openedFrom: "account" | "clan" | "profile" | null = null;
|
||||
|
||||
protected modalConfig() {
|
||||
return { maxWidth: "960px" };
|
||||
@@ -66,6 +66,11 @@ export class GameStatsModal extends BaseModal {
|
||||
this.open({ gameID: gameId });
|
||||
}
|
||||
|
||||
public openFromProfile(gameId: string): void {
|
||||
this.openedFrom = "profile";
|
||||
this.open({ gameID: gameId });
|
||||
}
|
||||
|
||||
private back(): void {
|
||||
const openedFrom = this.openedFrom;
|
||||
this.close();
|
||||
@@ -73,6 +78,12 @@ export class GameStatsModal extends BaseModal {
|
||||
document
|
||||
.querySelector<HTMLElement & { returnToGames(): void }>("account-modal")
|
||||
?.returnToGames();
|
||||
} else if (openedFrom === "profile") {
|
||||
document
|
||||
.querySelector<
|
||||
HTMLElement & { returnToGames(): void }
|
||||
>("player-profile-modal")
|
||||
?.returnToGames();
|
||||
} else if (openedFrom === "clan") {
|
||||
document
|
||||
.querySelector<
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { ClientEnv } from "src/client/ClientEnv";
|
||||
import { isVerifiedUsername, type PlayerStatsTree } from "../core/ApiSchemas";
|
||||
import { fetchPublicPlayerProfile } from "./Api";
|
||||
import "./components/baseComponents/stats/PlayerGameHistoryView";
|
||||
import type { PlayerGameHistoryCache } from "./components/baseComponents/stats/PlayerGameHistoryView";
|
||||
import "./components/baseComponents/stats/PlayerStatsTree";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import "./components/PlayerName";
|
||||
@@ -23,9 +26,23 @@ export class PlayerProfileModal extends BaseModal {
|
||||
@state() private statsTree: PlayerStatsTree | null = null;
|
||||
@state() private loading = false;
|
||||
private openedFrom: "clan" | "leaderboard" | "account" | null = null;
|
||||
// Mirrors the account modal's Games tab: keep the accumulated history list +
|
||||
// cursor across tab switches (and the game-stats detour) so re-entering Games
|
||||
// restores the scroll position the viewer had built up.
|
||||
private gameHistoryCache: PlayerGameHistoryCache | null = null;
|
||||
private gamesScrollTop = 0;
|
||||
private restoreGamesScrollAfterOpen = false;
|
||||
// Bumped on every profile load so a superseded in-flight response is dropped.
|
||||
private loadGeneration = 0;
|
||||
|
||||
protected modalConfig() {
|
||||
return { maxWidth: "960px" };
|
||||
return {
|
||||
maxWidth: "960px",
|
||||
tabs: [
|
||||
{ key: "stats", label: translateText("account_modal.tab_stats") },
|
||||
{ key: "games", label: translateText("account_modal.tab_games") },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
protected renderHeaderSlot() {
|
||||
@@ -58,14 +75,45 @@ export class PlayerProfileModal extends BaseModal {
|
||||
});
|
||||
}
|
||||
|
||||
protected renderBody() {
|
||||
protected renderBody(tab: string) {
|
||||
return html`
|
||||
<div class="px-3 py-4 sm:px-6 sm:py-6 lg:px-8 lg:py-7">
|
||||
${this.renderProfile()}
|
||||
<div class="custom-scrollbar mr-1">
|
||||
<div class="p-6">
|
||||
${tab === "games" ? this.renderGames() : this.renderProfile()}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderGames() {
|
||||
const publicId = this.publicId;
|
||||
if (!publicId) {
|
||||
return html`
|
||||
<div class="flex flex-col items-center justify-center p-12 text-center">
|
||||
<span class="text-4xl mb-4">🎮</span>
|
||||
<p class="text-white/40 text-sm">
|
||||
${translateText("account_modal.no_games")}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return html`
|
||||
<player-game-history-view
|
||||
.publicId=${publicId}
|
||||
.cachedState=${this.gameHistoryCache?.publicId === publicId
|
||||
? this.gameHistoryCache
|
||||
: null}
|
||||
@history-updated=${(e: CustomEvent<PlayerGameHistoryCache>) => {
|
||||
this.gameHistoryCache = e.detail;
|
||||
}}
|
||||
@view-stats=${(e: CustomEvent<{ gameId: string }>) =>
|
||||
this.openGameStats(e.detail.gameId)}
|
||||
@view-game=${(e: CustomEvent<{ gameId: string }>) =>
|
||||
this.viewGame(e.detail.gameId)}
|
||||
></player-game-history-view>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderProfile() {
|
||||
if (this.loading) {
|
||||
return this.renderLoadingSpinner(translateText("player_profile.loading"));
|
||||
@@ -81,11 +129,9 @@ export class PlayerProfileModal extends BaseModal {
|
||||
`;
|
||||
}
|
||||
return html`
|
||||
<div class="bg-white/5 rounded-xl border border-white/10 p-6">
|
||||
<player-stats-tree-view
|
||||
.statsTree=${this.statsTree}
|
||||
></player-stats-tree-view>
|
||||
</div>
|
||||
<player-stats-tree-view
|
||||
.statsTree=${this.statsTree}
|
||||
></player-stats-tree-view>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -94,9 +140,33 @@ export class PlayerProfileModal extends BaseModal {
|
||||
typeof args?.publicID === "string" && args.publicID.length > 0
|
||||
? args.publicID
|
||||
: null;
|
||||
|
||||
// Returning from the game-stats modal. The page router closed this modal
|
||||
// underneath when the stats page showed, but onClose deliberately preserves
|
||||
// state (like the account modal) — so restore the scroll and keep the
|
||||
// game-history cache instead of refetching. Preserve even when the profile
|
||||
// stats never loaded (still pending / failed): the Games tab loads
|
||||
// independently, so its list should survive the detour regardless.
|
||||
if (
|
||||
this.restoreGamesScrollAfterOpen &&
|
||||
publicId !== null &&
|
||||
publicId === this.publicId
|
||||
) {
|
||||
this.restoreGamesScrollAfterOpen = false;
|
||||
void this.restoreGamesScroll();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fresh open (router/share link): clear any stale origin. The openFrom*
|
||||
// helpers re-set it right after open() so back() routes home; the
|
||||
// return-from-stats path above skips this and keeps the origin intact.
|
||||
this.openedFrom = null;
|
||||
this.publicId = publicId;
|
||||
this.username = null;
|
||||
this.statsTree = null;
|
||||
this.gameHistoryCache = null;
|
||||
this.gamesScrollTop = 0;
|
||||
this.restoreGamesScrollAfterOpen = false;
|
||||
this.loading = publicId !== null;
|
||||
if (publicId !== null) {
|
||||
void this.loadProfile(publicId);
|
||||
@@ -104,35 +174,77 @@ export class PlayerProfileModal extends BaseModal {
|
||||
}
|
||||
|
||||
private async loadProfile(publicId: string): Promise<void> {
|
||||
const gen = ++this.loadGeneration;
|
||||
const profile = await fetchPublicPlayerProfile(publicId);
|
||||
// A late response must not clobber state after close or re-open.
|
||||
if (this.publicId !== publicId) return;
|
||||
// Drop a superseded response: a newer load started, or the modal moved to a
|
||||
// different player. onClose no longer clears publicId, so the id check alone
|
||||
// can't reject a stale same-player load started before an earlier close.
|
||||
if (gen !== this.loadGeneration || this.publicId !== publicId) return;
|
||||
this.loading = false;
|
||||
this.statsTree = profile === false ? null : profile.stats;
|
||||
this.username = profile === false ? null : (profile.username ?? null);
|
||||
}
|
||||
|
||||
protected onClose(): void {
|
||||
this.publicId = null;
|
||||
this.username = null;
|
||||
this.statsTree = null;
|
||||
this.loading = false;
|
||||
this.openedFrom = null;
|
||||
// Intentionally preserves publicId/statsTree/history cache/scroll: the page
|
||||
// router closes this modal when the game-stats page opens on top, and the
|
||||
// return flow (returnToGames) needs that state intact. onOpen resets it for a
|
||||
// genuinely new player. Mirrors the account modal.
|
||||
protected onClose(): void {}
|
||||
|
||||
// Open the game-stats modal on top for a game in this player's history. Stash
|
||||
// the scroll offset so returning restores it (see returnToGames()).
|
||||
private openGameStats(gameId: string): void {
|
||||
this.gamesScrollTop = this.modalEl?.getScrollTop() ?? 0;
|
||||
const statsModal = document.querySelector<
|
||||
HTMLElement & { openFromProfile(gameId: string): void }
|
||||
>("game-stats-modal");
|
||||
statsModal?.openFromProfile(gameId);
|
||||
}
|
||||
|
||||
private viewGame(gameId: string): void {
|
||||
this.close();
|
||||
const encodedGameId = encodeURIComponent(gameId);
|
||||
const newUrl = `/${ClientEnv.workerPath(gameId)}/game/${encodedGameId}`;
|
||||
|
||||
history.pushState({ join: gameId }, "", newUrl);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("join-changed", { detail: { gameId: encodedGameId } }),
|
||||
);
|
||||
}
|
||||
|
||||
// Called by the game-stats modal's back button when it was opened from here.
|
||||
public returnToGames(): void {
|
||||
this.restoreGamesScrollAfterOpen = true;
|
||||
this.open({ publicID: this.publicId ?? undefined, tab: "games" });
|
||||
}
|
||||
|
||||
private async restoreGamesScroll(): Promise<void> {
|
||||
await this.updateComplete;
|
||||
await this.modalEl?.updateComplete;
|
||||
const historyView = this.querySelector<
|
||||
HTMLElement & { updateComplete?: Promise<boolean> }
|
||||
>("player-game-history-view");
|
||||
await historyView?.updateComplete;
|
||||
this.modalEl?.setScrollTop(this.gamesScrollTop);
|
||||
}
|
||||
|
||||
// Origin is set after open() in every openFrom* helper: onOpen clears it, and
|
||||
// back() only reads it later on the user's click, so assigning here survives
|
||||
// to route the back button home (and survives the game-stats detour, which
|
||||
// never re-runs onOpen's fresh path).
|
||||
public openFromClan(publicId: string): void {
|
||||
this.openedFrom = "clan";
|
||||
this.open({ publicID: publicId });
|
||||
this.openedFrom = "clan";
|
||||
}
|
||||
|
||||
public openFromLeaderboard(publicId: string): void {
|
||||
this.openedFrom = "leaderboard";
|
||||
this.open({ publicID: publicId });
|
||||
this.openedFrom = "leaderboard";
|
||||
}
|
||||
|
||||
public openFromAccount(publicId: string): void {
|
||||
this.openedFrom = "account";
|
||||
this.open({ publicID: publicId });
|
||||
this.openedFrom = "account";
|
||||
}
|
||||
|
||||
private back(): void {
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { html, LitElement, type TemplateResult } from "lit";
|
||||
import {
|
||||
html,
|
||||
LitElement,
|
||||
type PropertyValues,
|
||||
type TemplateResult,
|
||||
} from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import {
|
||||
type PlayerGameModeFilter,
|
||||
@@ -81,13 +86,25 @@ export class PlayerGameHistoryView extends LitElement {
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.syncToPublicId();
|
||||
}
|
||||
|
||||
// Hydrate from the cache when it matches the current player, otherwise load
|
||||
// that player's history fresh.
|
||||
private syncToPublicId() {
|
||||
if (this.cachedState && this.cachedState.publicId === this.publicId) {
|
||||
this.games = this.cachedState.games;
|
||||
this.nextCursor = this.cachedState.nextCursor;
|
||||
this.typeFilter = this.cachedState.typeFilter;
|
||||
this.modeFilter = this.cachedState.modeFilter;
|
||||
this.appendFailed = false;
|
||||
this.loadState = "ok";
|
||||
} else if (this.publicId) {
|
||||
this.reload();
|
||||
// Fresh player → show the default (All) view rather than inheriting the
|
||||
// previous player's filters. No-op on first mount (already defaults).
|
||||
this.typeFilter = "all";
|
||||
this.modeFilter = "all";
|
||||
void this.reload();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +113,21 @@ export class PlayerGameHistoryView extends LitElement {
|
||||
this.teardownObserver();
|
||||
}
|
||||
|
||||
willUpdate(changed: PropertyValues) {
|
||||
// Reload when the host swaps in a different player after mount (e.g. a
|
||||
// manual hash edit routes the profile modal to a new publicId). Lit reuses
|
||||
// this element, so connectedCallback won't fire again. The undefined check
|
||||
// skips the initial render, which connectedCallback already handled.
|
||||
const previous = changed.get("publicId");
|
||||
if (
|
||||
changed.has("publicId") &&
|
||||
previous !== undefined &&
|
||||
previous !== this.publicId
|
||||
) {
|
||||
this.syncToPublicId();
|
||||
}
|
||||
}
|
||||
|
||||
updated() {
|
||||
// The IntersectionObserver target only exists when there's more to load AND
|
||||
// we're not mid-request — wire it up after each render so it tracks the
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LitElement, PropertyValues, html } from "lit";
|
||||
import { LitElement, PropertyValues, TemplateResult, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { PlayerStatsLeaf, PlayerStatsTree } from "../../../../core/ApiSchemas";
|
||||
import {
|
||||
@@ -74,6 +74,51 @@ export class PlayerStatsTreeView extends LitElement {
|
||||
: translateText("game_mode.teams");
|
||||
}
|
||||
|
||||
private labelForType(t: GameType | "Ranked") {
|
||||
return t === "Ranked"
|
||||
? translateText("player_stats_tree.ranked")
|
||||
: t === GameType.Public
|
||||
? translateText("player_stats_tree.public")
|
||||
: t === GameType.Private
|
||||
? translateText("player_stats_tree.private")
|
||||
: translateText("player_stats_tree.solo");
|
||||
}
|
||||
|
||||
// A full-width filter row styled like the game-history tab: a pill container
|
||||
// wrapping tab-style buttons that grow to fill the row.
|
||||
private renderFilterRow(buttons: TemplateResult[]): TemplateResult {
|
||||
return html`
|
||||
<div
|
||||
role="tablist"
|
||||
class="flex flex-wrap gap-1 p-1 bg-white/5 border border-white/10 rounded-xl"
|
||||
>
|
||||
${buttons}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderFilterTab(
|
||||
label: string,
|
||||
isActive: boolean,
|
||||
onSelect: () => void,
|
||||
title?: string,
|
||||
): TemplateResult {
|
||||
return html`
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected=${isActive}
|
||||
title=${title ?? nothing}
|
||||
@click=${onSelect}
|
||||
class="grow basis-20 px-3 py-1.5 text-xs font-bold uppercase tracking-wider whitespace-nowrap rounded-lg transition-colors ${isActive
|
||||
? "bg-malibu-blue/20 text-aquarius border border-malibu-blue/30"
|
||||
: "text-white/50 hover:text-white hover:bg-white/5 border border-transparent"}"
|
||||
>
|
||||
${label}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
private labelForRankedType(r: RankedType) {
|
||||
switch (r) {
|
||||
case RankedType.OneVOne:
|
||||
@@ -275,103 +320,58 @@ export class PlayerStatsTreeView extends LitElement {
|
||||
|
||||
return html`
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Filters -->
|
||||
<div
|
||||
class="flex flex-wrap gap-2 items-center justify-between p-2 bg-black/20 rounded-lg border border-white/5"
|
||||
>
|
||||
<!-- Type selector -->
|
||||
<div class="flex gap-1">
|
||||
${types.map(
|
||||
(t) => html`
|
||||
<button
|
||||
class="text-xs px-3 py-1.5 rounded-md border font-bold uppercase tracking-wider transition-all duration-200 ${this
|
||||
.selectedType === t
|
||||
? "bg-blue-600 border-blue-500 text-white shadow-lg shadow-blue-900/40"
|
||||
: "bg-white/5 border-white/10 text-gray-400 hover:bg-white/10 hover:text-white"}"
|
||||
@click=${() => this.setGameType(t)}
|
||||
>
|
||||
${t === "Ranked"
|
||||
? translateText("player_stats_tree.ranked")
|
||||
: t === GameType.Public
|
||||
? translateText("player_stats_tree.public")
|
||||
: t === GameType.Private
|
||||
? translateText("player_stats_tree.private")
|
||||
: translateText("player_stats_tree.solo")}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<!-- Ranked type selector -->
|
||||
${this.selectedType === "Ranked" && rankedTypes.length
|
||||
? html`<div
|
||||
class="flex gap-1 bg-black/20 rounded-md p-1 border border-white/5"
|
||||
>
|
||||
${rankedTypes.map(
|
||||
(r) => html`
|
||||
<button
|
||||
class="text-xs px-3 py-1 rounded-sm transition-colors ${this
|
||||
.selectedRankedType === r
|
||||
? "bg-white/20 text-white font-bold"
|
||||
: "text-gray-400 hover:text-white"}"
|
||||
@click=${() => this.setRankedType(r)}
|
||||
>
|
||||
${this.labelForRankedType(r)}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>`
|
||||
: html``}
|
||||
|
||||
<!-- Mode selector -->
|
||||
${modes.length
|
||||
? html`<div
|
||||
class="flex gap-1 bg-black/20 rounded-md p-1 border border-white/5"
|
||||
>
|
||||
${modes.map(
|
||||
(m) => html`
|
||||
<button
|
||||
class="text-xs px-3 py-1 rounded-sm transition-colors ${this
|
||||
.selectedMode === m
|
||||
? "bg-white/20 text-white font-bold"
|
||||
: "text-gray-400 hover:text-white"}"
|
||||
@click=${() => this.setMode(m)}
|
||||
title=${translateText("player_stats_tree.mode")}
|
||||
>
|
||||
${this.labelForMode(m)}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>`
|
||||
: html``}
|
||||
|
||||
<!-- Difficulty selector -->
|
||||
${!this.shouldMergeDifficulties && diffs.length
|
||||
? html`<div
|
||||
class="flex gap-1 bg-black/20 rounded-md p-1 border border-white/5"
|
||||
>
|
||||
${diffs.map(
|
||||
(d) =>
|
||||
html` <button
|
||||
class="text-xs px-3 py-1 rounded-sm transition-colors ${this
|
||||
.selectedDifficulty === d
|
||||
? "bg-white/20 text-white font-bold"
|
||||
: "text-gray-400 hover:text-white"}"
|
||||
@click=${() => this.setDifficulty(d)}
|
||||
title=${translateText("difficulty.difficulty")}
|
||||
>
|
||||
${translateText(`difficulty.${d.toLowerCase()}`)}
|
||||
</button>`,
|
||||
)}
|
||||
</div>`
|
||||
: html``}
|
||||
</div>
|
||||
<!-- Filters: a type row, then a context-dependent second row (ranked
|
||||
type / mode / difficulty), styled like the game-history tab. -->
|
||||
<div class="space-y-2">
|
||||
${this.renderFilterRow(
|
||||
types.map((t) =>
|
||||
this.renderFilterTab(
|
||||
this.labelForType(t),
|
||||
this.selectedType === t,
|
||||
() => this.setGameType(t),
|
||||
),
|
||||
),
|
||||
)}
|
||||
${this.selectedType === "Ranked" && rankedTypes.length
|
||||
? this.renderFilterRow(
|
||||
rankedTypes.map((r) =>
|
||||
this.renderFilterTab(
|
||||
this.labelForRankedType(r) ?? "",
|
||||
this.selectedRankedType === r,
|
||||
() => this.setRankedType(r),
|
||||
),
|
||||
),
|
||||
)
|
||||
: nothing}
|
||||
${modes.length
|
||||
? this.renderFilterRow(
|
||||
modes.map((m) =>
|
||||
this.renderFilterTab(
|
||||
this.labelForMode(m),
|
||||
this.selectedMode === m,
|
||||
() => this.setMode(m),
|
||||
translateText("player_stats_tree.mode"),
|
||||
),
|
||||
),
|
||||
)
|
||||
: nothing}
|
||||
${!this.shouldMergeDifficulties && diffs.length
|
||||
? this.renderFilterRow(
|
||||
diffs.map((d) =>
|
||||
this.renderFilterTab(
|
||||
translateText(`difficulty.${d.toLowerCase()}`),
|
||||
this.selectedDifficulty === d,
|
||||
() => this.setDifficulty(d),
|
||||
translateText("difficulty.difficulty"),
|
||||
),
|
||||
),
|
||||
)
|
||||
: nothing}
|
||||
</div>
|
||||
|
||||
${leaf
|
||||
? html`
|
||||
<div class="space-y-6 mt-2">
|
||||
<div class="space-y-6 border-t border-white/10 pt-3">
|
||||
<player-stats-grid
|
||||
.titles=${[
|
||||
translateText("player_stats_tree.stats_wins"),
|
||||
|
||||
Reference in New Issue
Block a user