mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 22:05:24 +00:00
feat(client): shareable player profile modal (#modal=profile&publicID=x) (#4639)
## Description: Add player profile (stats only for now): <img width="952" height="241" alt="image" src="https://github.com/user-attachments/assets/2cc0c1ed-baf6-4cb5-837c-1f82d2850005" /> and clans modal: <img width="927" height="778" alt="image" src="https://github.com/user-attachments/assets/87e7ae53-6531-41cd-8795-4ea1d8fb67af" /> with dedicated url: <img width="1273" height="916" alt="image" src="https://github.com/user-attachments/assets/dd1ccff7-5d3c-4c70-a7d2-0c7399a88a08" /> ## 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 Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -34,6 +34,7 @@ import "./components/SubscriptionPanel";
|
||||
import { modalHeader } from "./components/ui/ModalHeader";
|
||||
import { fetchCosmetics } from "./Cosmetics";
|
||||
import { crazyGamesSDK, type CrazyGamesUser } from "./CrazyGamesSDK";
|
||||
import { playerProfileUrl } from "./PlayerProfileModal";
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
@customElement("account-modal")
|
||||
@@ -393,12 +394,26 @@ 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">
|
||||
<h3 class="text-lg font-bold text-white mb-4 flex items-center gap-2">
|
||||
<span class="text-blue-400">📊</span>
|
||||
${translateText("account_modal.stats_overview")}
|
||||
</h3>
|
||||
<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>
|
||||
|
||||
@@ -66,6 +66,44 @@ export async function fetchPlayerById(
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/player/:publicId — public player profile (stats tree). No auth,
|
||||
// so logged-out visitors can view shared profiles.
|
||||
export async function fetchPublicPlayerProfile(
|
||||
publicId: string,
|
||||
): Promise<PlayerProfile | false> {
|
||||
try {
|
||||
const url = `${getApiBase()}/public/player/${encodeURIComponent(publicId)}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
console.warn(
|
||||
"fetchPublicPlayerProfile: unexpected status",
|
||||
res.status,
|
||||
res.statusText,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
const parsed = PlayerProfileSchema.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
console.warn(
|
||||
"fetchPublicPlayerProfile: Zod validation failed",
|
||||
parsed.error,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
} catch (err) {
|
||||
console.warn("fetchPublicPlayerProfile: request failed", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/player/:publicId/games — keyset-paginated personal game history.
|
||||
// Public (no auth). `filter` (mode bucket) and `type` (game-type split) are
|
||||
// orthogonal; `cursor` is the opaque token from the previous response's
|
||||
|
||||
@@ -444,6 +444,8 @@ export class ClanModal extends BaseModal {
|
||||
pendingRequestCount: e.detail.pendingRequestCount,
|
||||
};
|
||||
}}
|
||||
@view-profile=${(e: CustomEvent<{ publicId: string }>) =>
|
||||
this.openPlayerProfile(e.detail.publicId)}
|
||||
@navigate-manage=${() => (this.view = "manage")}
|
||||
@navigate-requests=${() => (this.view = "requests")}
|
||||
@clan-joined=${(e: CustomEvent<{ tag: string }>) => {
|
||||
@@ -532,6 +534,30 @@ export class ClanModal extends BaseModal {
|
||||
}
|
||||
}
|
||||
|
||||
private openPlayerProfile(publicId: string): void {
|
||||
const profileModal = document.querySelector<
|
||||
HTMLElement & { openFromClan(publicId: string): void }
|
||||
>("player-profile-modal");
|
||||
if (!profileModal) return;
|
||||
|
||||
// Same handoff as openGameStats: keep clan state so the profile modal's
|
||||
// back button can land on the members tab without a refetch.
|
||||
this.preserveStateForGameStats = true;
|
||||
try {
|
||||
profileModal.openFromClan(publicId);
|
||||
} finally {
|
||||
this.preserveStateForGameStats = false;
|
||||
}
|
||||
}
|
||||
|
||||
public returnToMembers(): void {
|
||||
const tag = this.selectedClanTag;
|
||||
if (!tag) return;
|
||||
|
||||
this.returningFromGameStats = true;
|
||||
this.open({ clan: tag, tab: "members" });
|
||||
}
|
||||
|
||||
public returnToGameHistory(): void {
|
||||
const tag = this.selectedClanTag;
|
||||
if (!tag) return;
|
||||
|
||||
@@ -234,6 +234,7 @@ export class LangSelector extends LitElement {
|
||||
"news-button",
|
||||
"account-modal",
|
||||
"game-stats-modal",
|
||||
"player-profile-modal",
|
||||
"game-info-view",
|
||||
"ranking-controls",
|
||||
"leaderboard-modal",
|
||||
|
||||
@@ -49,6 +49,7 @@ import { MatchmakingModal } from "./Matchmaking";
|
||||
import { modalRouter } from "./ModalRouter";
|
||||
import { initNavigation } from "./Navigation";
|
||||
import "./NewsModal";
|
||||
import "./PlayerProfileModal";
|
||||
import { RewardsModal } from "./RewardsModal";
|
||||
import "./SinglePlayerModal";
|
||||
import { StoreModal } from "./Store";
|
||||
@@ -306,6 +307,10 @@ class Client {
|
||||
tag: "game-stats-modal",
|
||||
pageId: "page-stats",
|
||||
});
|
||||
modalRouter.register("profile", {
|
||||
tag: "player-profile-modal",
|
||||
pageId: "page-profile",
|
||||
});
|
||||
modalRouter.register("help", { tag: "help-modal", pageId: "page-help" });
|
||||
modalRouter.register("news", { tag: "news-modal", pageId: "page-news" });
|
||||
modalRouter.register("language", {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import type { PlayerStatsTree } from "../core/ApiSchemas";
|
||||
import { fetchPublicPlayerProfile } from "./Api";
|
||||
import "./components/baseComponents/stats/PlayerStatsTree";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import "./components/CopyButton";
|
||||
import { modalHeader } from "./components/ui/ModalHeader";
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
/** Build a shareable profile URL for a publicId. */
|
||||
export function playerProfileUrl(publicId: string): string {
|
||||
return `${window.location.origin}${window.location.pathname}#modal=profile&publicID=${encodeURIComponent(publicId)}`;
|
||||
}
|
||||
|
||||
@customElement("player-profile-modal")
|
||||
export class PlayerProfileModal extends BaseModal {
|
||||
protected routerName = "profile";
|
||||
|
||||
@state() private publicId: string | null = null;
|
||||
@state() private statsTree: PlayerStatsTree | null = null;
|
||||
@state() private loading = false;
|
||||
private openedFrom: "clan" | null = null;
|
||||
|
||||
protected modalConfig() {
|
||||
return { maxWidth: "960px" };
|
||||
}
|
||||
|
||||
protected renderHeaderSlot() {
|
||||
return modalHeader({
|
||||
title: translateText("player_profile.title"),
|
||||
onBack: () => this.back(),
|
||||
ariaLabel: translateText("common.back"),
|
||||
rightContent: this.publicId
|
||||
? html`
|
||||
<copy-button
|
||||
compact
|
||||
class="shrink-0"
|
||||
.copyText=${playerProfileUrl(this.publicId)}
|
||||
.displayText=${this.publicId}
|
||||
.showVisibilityToggle=${false}
|
||||
></copy-button>
|
||||
`
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
protected renderBody() {
|
||||
return html`
|
||||
<div class="px-3 py-4 sm:px-6 sm:py-6 lg:px-8 lg:py-7">
|
||||
${this.renderProfile()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderProfile() {
|
||||
if (this.loading) {
|
||||
return this.renderLoadingSpinner(translateText("player_profile.loading"));
|
||||
}
|
||||
if (!this.publicId || !this.statsTree) {
|
||||
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("player_profile.not_found")}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
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>
|
||||
`;
|
||||
}
|
||||
|
||||
protected onOpen(args?: Record<string, unknown>): void {
|
||||
const publicId =
|
||||
typeof args?.publicID === "string" && args.publicID.length > 0
|
||||
? args.publicID
|
||||
: null;
|
||||
this.publicId = publicId;
|
||||
this.statsTree = null;
|
||||
this.loading = publicId !== null;
|
||||
if (publicId !== null) {
|
||||
void this.loadProfile(publicId);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadProfile(publicId: string): Promise<void> {
|
||||
const profile = await fetchPublicPlayerProfile(publicId);
|
||||
// A late response must not clobber state after close or re-open.
|
||||
if (this.publicId !== publicId) return;
|
||||
this.loading = false;
|
||||
this.statsTree = profile === false ? null : profile.stats;
|
||||
}
|
||||
|
||||
protected onClose(): void {
|
||||
this.publicId = null;
|
||||
this.statsTree = null;
|
||||
this.loading = false;
|
||||
this.openedFrom = null;
|
||||
}
|
||||
|
||||
public openFromClan(publicId: string): void {
|
||||
this.openedFrom = "clan";
|
||||
this.open({ publicID: publicId });
|
||||
}
|
||||
|
||||
private back(): void {
|
||||
const openedFrom = this.openedFrom;
|
||||
this.close();
|
||||
if (openedFrom === "clan") {
|
||||
document
|
||||
.querySelector<HTMLElement & { returnToMembers(): void }>("clan-modal")
|
||||
?.returnToMembers();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -548,7 +548,17 @@ export class ClanDetailView extends LitElement {
|
||||
),
|
||||
)}
|
||||
<div class="space-y-2">
|
||||
${filtered.map((m) => renderMemberRow(m, this.myPublicId))}
|
||||
${filtered.map((m) =>
|
||||
renderMemberRow(m, this.myPublicId, (publicId) =>
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("view-profile", {
|
||||
detail: { publicId },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
${renderMemberPagination(
|
||||
this.memberPage,
|
||||
|
||||
@@ -378,6 +378,7 @@ export function renderMemberStats(
|
||||
export function renderMemberRow(
|
||||
member: ClanMember,
|
||||
myPublicId: string | null,
|
||||
onViewProfile?: (publicId: string) => void,
|
||||
): TemplateResult {
|
||||
const isMe = member.publicId === myPublicId;
|
||||
return html`
|
||||
@@ -407,12 +408,24 @@ export function renderMemberRow(
|
||||
.showCopyIcon=${false}
|
||||
></copy-button>
|
||||
</div>
|
||||
<span
|
||||
class="text-white/30 text-[10px] shrink-0 text-right whitespace-nowrap"
|
||||
>${translateText("clan_modal.joined_date", {
|
||||
date: formatClanDate(member.joinedAt),
|
||||
})}</span
|
||||
>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<span
|
||||
class="text-white/30 text-[10px] text-right whitespace-nowrap"
|
||||
>${translateText("clan_modal.joined_date", {
|
||||
date: formatClanDate(member.joinedAt),
|
||||
})}</span
|
||||
>
|
||||
${onViewProfile
|
||||
? html`<button
|
||||
class="shrink-0 px-1.5 py-0.5 rounded-lg bg-white/5 hover:bg-white/10 border border-white/10 transition-colors"
|
||||
title=${translateText("player_profile.view")}
|
||||
aria-label=${translateText("player_profile.view")}
|
||||
@click=${() => onViewProfile(member.publicId)}
|
||||
>
|
||||
📊
|
||||
</button>`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user