mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 22:41:57 +00:00
bb1ddfbcaa
## Description:
**TerritoryPatternsModal.ts**
- Changed the "Not Logged In" indicator from a static `div` to a
clickable `button`
- Clicking it now closes the skins modal and navigates to the Account
page via `window.showPage("page-account")`
- Added hover effect (`hover:bg-red-500/30`) for visual feedback
**AccountModal.ts**
- Fixed the inline Account modal's loading state ("Fetching account
information...") rendering without a background or header (white text on
light background 💀)
- The loading spinner is now wrapped in `modalContainerClass` (dark
glassmorphic background) with a proper `modalHeader` including the title
and back button, matching the loaded state's appearance
**SinglePlayerModal.ts**
- Changed the "Sign in for achievements" banner from a static `div` to a
clickable `button` that closes the modal and navigates to the Account
page
- Added hover effect for visual feedback
**Matchmaking.ts**
- When the "You must be logged in to play ranked matchmaking" toast
appears, the user is now also navigated to the Account page so they can
log in immediately
## 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
- [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:
FloPinguin
416 lines
14 KiB
TypeScript
416 lines
14 KiB
TypeScript
import { html, TemplateResult } from "lit";
|
|
import { customElement, state } from "lit/decorators.js";
|
|
import {
|
|
PlayerGame,
|
|
PlayerStatsTree,
|
|
UserMeResponse,
|
|
} from "../core/ApiSchemas";
|
|
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
|
|
import { fetchPlayerById, getUserMe } from "./Api";
|
|
import { discordLogin, logOut, sendMagicLink } from "./Auth";
|
|
import "./components/baseComponents/stats/DiscordUserHeader";
|
|
import "./components/baseComponents/stats/GameList";
|
|
import "./components/baseComponents/stats/PlayerStatsTable";
|
|
import "./components/baseComponents/stats/PlayerStatsTree";
|
|
import { BaseModal } from "./components/BaseModal";
|
|
import "./components/CopyButton";
|
|
import "./components/Difficulties";
|
|
import "./components/PatternButton";
|
|
import { modalHeader } from "./components/ui/ModalHeader";
|
|
import { translateText } from "./Utils";
|
|
|
|
@customElement("account-modal")
|
|
export class AccountModal extends BaseModal {
|
|
@state() private email: string = "";
|
|
@state() private isLoadingUser: boolean = false;
|
|
|
|
private userMeResponse: UserMeResponse | null = null;
|
|
private statsTree: PlayerStatsTree | null = null;
|
|
private recentGames: PlayerGame[] = [];
|
|
|
|
constructor() {
|
|
super();
|
|
|
|
document.addEventListener("userMeResponse", (event: Event) => {
|
|
const customEvent = event as CustomEvent;
|
|
if (customEvent.detail) {
|
|
this.userMeResponse = customEvent.detail as UserMeResponse;
|
|
if (this.userMeResponse?.player?.publicId === undefined) {
|
|
this.statsTree = null;
|
|
this.recentGames = [];
|
|
}
|
|
} else {
|
|
this.statsTree = null;
|
|
this.recentGames = [];
|
|
this.requestUpdate();
|
|
}
|
|
});
|
|
}
|
|
|
|
private hasAnyStats(): boolean {
|
|
if (!this.statsTree) return false;
|
|
// Check if statsTree has any data
|
|
return (
|
|
Object.keys(this.statsTree).length > 0 &&
|
|
Object.values(this.statsTree).some(
|
|
(gameTypeStats) =>
|
|
gameTypeStats && Object.keys(gameTypeStats).length > 0,
|
|
)
|
|
);
|
|
}
|
|
|
|
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() {
|
|
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 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.personal_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>
|
|
`;
|
|
}
|
|
|
|
private renderAccountInfo() {
|
|
const me = this.userMeResponse?.user;
|
|
const isLinked = me?.discord ?? me?.email;
|
|
|
|
if (!isLinked) {
|
|
return this.renderLoginOptions();
|
|
}
|
|
|
|
return html`
|
|
<div class="p-6">
|
|
<div class="flex flex-col gap-6">
|
|
<!-- Top Row: Connected As -->
|
|
<div class="bg-white/5 rounded-xl border border-white/10 p-6">
|
|
<div class="flex flex-col items-center gap-4">
|
|
<div
|
|
class="text-xs text-white/40 uppercase tracking-widest font-bold border-b border-white/5 pb-2 px-8"
|
|
>
|
|
${translateText("account_modal.connected_as")}
|
|
</div>
|
|
<div class="flex items-center gap-8 justify-center flex-wrap">
|
|
<discord-user-header
|
|
.data=${this.userMeResponse?.user?.discord ?? null}
|
|
></discord-user-header>
|
|
${this.renderLoggedInAs()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Middle Row: Stats Section -->
|
|
${this.hasAnyStats()
|
|
? 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>
|
|
<player-stats-tree-view
|
|
.statsTree=${this.statsTree}
|
|
></player-stats-tree-view>
|
|
</div>`
|
|
: ""}
|
|
|
|
<!-- Bottom Row: Recent Games Section -->
|
|
<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("game_list.recent_games")}
|
|
</h3>
|
|
<game-list
|
|
.games=${this.recentGames}
|
|
.onViewGame=${(id: string) => void this.viewGame(id)}
|
|
></game-list>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderLoggedInAs(): TemplateResult {
|
|
const me = this.userMeResponse?.user;
|
|
if (me?.discord) {
|
|
return html`
|
|
<div class="flex flex-col items-center gap-3 w-full">
|
|
${this.renderLogoutButton()}
|
|
</div>
|
|
`;
|
|
} else if (me?.email) {
|
|
return html`
|
|
<div class="flex flex-col items-center gap-3 w-full">
|
|
<div class="text-white text-lg font-medium">
|
|
${translateText("account_modal.linked_account", {
|
|
account_name: me.email,
|
|
})}
|
|
</div>
|
|
${this.renderLogoutButton()}
|
|
</div>
|
|
`;
|
|
}
|
|
return html``;
|
|
}
|
|
|
|
private async viewGame(gameId: string): Promise<void> {
|
|
this.close();
|
|
const config = await getServerConfigFromClient();
|
|
const encodedGameId = encodeURIComponent(gameId);
|
|
const newUrl = `/${config.workerPath(gameId)}/game/${encodedGameId}`;
|
|
|
|
history.pushState({ join: gameId }, "", newUrl);
|
|
window.dispatchEvent(
|
|
new CustomEvent("join-changed", { detail: { gameId: encodedGameId } }),
|
|
);
|
|
}
|
|
|
|
private renderLogoutButton(): TemplateResult {
|
|
return html`
|
|
<button
|
|
@click="${this.handleLogout}"
|
|
class="px-6 py-2 text-sm font-bold text-white uppercase tracking-wider bg-red-600/80 hover:bg-red-600 border border-red-500/50 rounded-lg transition-all shadow-lg hover:shadow-red-900/40"
|
|
>
|
|
${translateText("account_modal.log_out")}
|
|
</button>
|
|
`;
|
|
}
|
|
|
|
private renderLoginOptions() {
|
|
return html`
|
|
<div class="flex items-center justify-center p-6 min-h-full">
|
|
<div
|
|
class="w-full max-w-md bg-white/5 rounded-2xl border border-white/10 p-8"
|
|
>
|
|
<div class="text-center mb-8">
|
|
<div
|
|
class="w-16 h-16 bg-gradient-to-br from-blue-500/20 to-purple-500/20 rounded-2xl flex items-center justify-center mx-auto mb-6 border border-white/10 shadow-inner"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="w-8 h-8 text-blue-400"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"></path>
|
|
<polyline points="10 17 15 12 10 7"></polyline>
|
|
<line x1="15" y1="12" x2="3" y2="12"></line>
|
|
</svg>
|
|
</div>
|
|
<p class="text-white/50 text-sm font-medium">
|
|
${translateText("account_modal.sign_in_desc")}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="space-y-6">
|
|
<!-- Discord Login Button -->
|
|
<button
|
|
@click="${this.handleDiscordLogin}"
|
|
class="w-full px-6 py-4 text-white bg-[#5865F2] hover:bg-[#4752C4] border border-transparent rounded-xl focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#5865F2] transition-colors duration-200 flex items-center justify-center gap-3 group relative overflow-hidden shadow-lg hover:shadow-[#5865F2]/20"
|
|
>
|
|
<img
|
|
src="/images/DiscordLogo.svg"
|
|
alt="Discord"
|
|
class="w-6 h-6 relative z-10"
|
|
/>
|
|
<span class="font-bold relative z-10 tracking-wide"
|
|
>${translateText("main.login_discord") ||
|
|
translateText("account_modal.link_discord")}</span
|
|
>
|
|
</button>
|
|
|
|
<!-- Divider -->
|
|
<div class="flex items-center gap-4 py-2">
|
|
<div class="h-px bg-white/10 flex-1"></div>
|
|
<span
|
|
class="text-[10px] uppercase tracking-widest text-white/30 font-bold"
|
|
>
|
|
${translateText("account_modal.or")}
|
|
</span>
|
|
<div class="h-px bg-white/10 flex-1"></div>
|
|
</div>
|
|
|
|
<!-- Email Recovery -->
|
|
<div class="space-y-3">
|
|
<div class="relative group">
|
|
<input
|
|
type="email"
|
|
id="email"
|
|
name="email"
|
|
.value="${this.email}"
|
|
@input="${this.handleEmailInput}"
|
|
class="w-full pl-4 pr-12 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/20 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500/50 transition-all font-medium hover:bg-white/10"
|
|
placeholder="${translateText(
|
|
"account_modal.email_placeholder",
|
|
)}"
|
|
required
|
|
/>
|
|
</div>
|
|
<button
|
|
@click="${this.handleSubmit}"
|
|
class="w-full px-6 py-3 text-sm font-bold text-white uppercase tracking-wider bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-500 hover:to-blue-600 rounded-xl transition-all shadow-lg hover:shadow-blue-900/40 border border-white/5"
|
|
>
|
|
${translateText("account_modal.get_magic_link")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-8 text-center border-t border-white/10 pt-6">
|
|
<button
|
|
@click="${this.handleLogout}"
|
|
class="text-[10px] font-bold text-white/20 hover:text-red-400 transition-colors uppercase tracking-widest pb-0.5"
|
|
>
|
|
${translateText("account_modal.clear_session")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private handleEmailInput(e: Event) {
|
|
const target = e.target as HTMLInputElement;
|
|
this.email = target.value;
|
|
}
|
|
|
|
private async handleSubmit() {
|
|
if (!this.email) {
|
|
alert(translateText("account_modal.enter_email_address"));
|
|
return;
|
|
}
|
|
|
|
const success = await sendMagicLink(this.email);
|
|
if (success) {
|
|
alert(
|
|
translateText("account_modal.recovery_email_sent", {
|
|
email: this.email,
|
|
}),
|
|
);
|
|
} else {
|
|
alert(translateText("account_modal.failed_to_send_recovery_email"));
|
|
}
|
|
}
|
|
|
|
private handleDiscordLogin() {
|
|
discordLogin();
|
|
}
|
|
|
|
protected onOpen(): void {
|
|
this.isLoadingUser = true;
|
|
|
|
void getUserMe()
|
|
.then((userMe) => {
|
|
if (userMe) {
|
|
this.userMeResponse = userMe;
|
|
if (this.userMeResponse?.player?.publicId) {
|
|
this.loadPlayerProfile(this.userMeResponse.player.publicId);
|
|
}
|
|
}
|
|
this.isLoadingUser = false;
|
|
this.requestUpdate();
|
|
})
|
|
.catch((err) => {
|
|
console.warn("Failed to fetch user info in AccountModal.open():", err);
|
|
this.isLoadingUser = false;
|
|
this.requestUpdate();
|
|
});
|
|
this.requestUpdate();
|
|
}
|
|
|
|
protected onClose(): void {
|
|
this.dispatchEvent(
|
|
new CustomEvent("close", { bubbles: true, composed: true }),
|
|
);
|
|
}
|
|
|
|
private async handleLogout() {
|
|
await logOut();
|
|
this.close();
|
|
// Refresh the page after logout to update the UI state
|
|
window.location.reload();
|
|
}
|
|
|
|
private async loadPlayerProfile(publicId: string): Promise<void> {
|
|
try {
|
|
const data = await fetchPlayerById(publicId);
|
|
if (!data) {
|
|
this.requestUpdate();
|
|
return;
|
|
}
|
|
|
|
this.recentGames = data.games;
|
|
this.statsTree = data.stats;
|
|
|
|
this.requestUpdate();
|
|
} catch (err) {
|
|
console.warn("Failed to load player data:", err);
|
|
this.requestUpdate();
|
|
}
|
|
}
|
|
}
|