feat: add public player profile page

This commit is contained in:
2026-03-06 13:49:14 -08:00
committed by Naomi Carrigan
parent f734176965
commit 32c13f73c4
5 changed files with 313 additions and 1 deletions
+11
View File
@@ -2,6 +2,12 @@ import { useState } from "react";
import { GameProvider } from "./context/GameContext.js"; import { GameProvider } from "./context/GameContext.js";
import { GameLayout } from "./components/game/GameLayout.js"; import { GameLayout } from "./components/game/GameLayout.js";
import { LoginPage } from "./components/game/LoginPage.js"; import { LoginPage } from "./components/game/LoginPage.js";
import { ProfilePage } from "./components/game/ProfilePage.js";
const getProfileDiscordId = (): string | null => {
const match = /^\/profile\/(\d+)$/.exec(window.location.pathname);
return match?.[1] ?? null;
};
const handleAuthCallback = (): boolean => { const handleAuthCallback = (): boolean => {
if (window.location.pathname !== "/auth/callback") { if (window.location.pathname !== "/auth/callback") {
@@ -27,6 +33,11 @@ const isAuthenticated = (): boolean => {
export const App = (): React.JSX.Element => { export const App = (): React.JSX.Element => {
const [loggedIn, setLoggedIn] = useState(isAuthenticated); const [loggedIn, setLoggedIn] = useState(isAuthenticated);
const profileDiscordId = getProfileDiscordId();
if (profileDiscordId) {
return <ProfilePage discordId={profileDiscordId} />;
}
if (!loggedIn) { if (!loggedIn) {
return <LoginPage onLogin={() => { setLoggedIn(true); }} />; return <LoginPage onLogin={() => { setLoggedIn(true); }} />;
} }
@@ -47,11 +47,14 @@ export const GameLayout = (): React.JSX.Element => {
if (!state) return <div className="loading-screen"><p>Loading...</p></div>; if (!state) return <div className="loading-screen"><p>Loading...</p></div>;
const profileUrl = `/profile/${state.player.discordId}`;
return ( return (
<div className="game-layout"> <div className="game-layout">
<ResourceBar <ResourceBar
resources={state.resources} resources={state.resources}
prestigeCount={state.prestige.count} prestigeCount={state.prestige.count}
profileUrl={profileUrl}
/> />
<OfflineModal /> <OfflineModal />
<AchievementToast /> <AchievementToast />
@@ -0,0 +1,115 @@
import type { PublicProfileResponse } from "@elysium/types";
import { useEffect, useState } from "react";
import { formatNumber } from "../../utils/format.js";
interface ProfilePageProps {
discordId: string;
}
export const ProfilePage = ({ discordId }: ProfilePageProps): React.JSX.Element => {
const [profile, setProfile] = useState<PublicProfileResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
useEffect(() => {
fetch(`/api/profile/${discordId}`)
.then(async (res) => {
if (!res.ok) throw new Error("Player not found");
return res.json() as Promise<PublicProfileResponse>;
})
.then(setProfile)
.catch((err: unknown) => {
setError(err instanceof Error ? err.message : "Failed to load profile");
});
}, [discordId]);
const handleCopy = (): void => {
void navigator.clipboard.writeText(window.location.href).then(() => {
setCopied(true);
setTimeout(() => { setCopied(false); }, 2000);
});
};
if (error) {
return (
<div className="profile-page">
<div className="profile-error">
<p> {error}</p>
<a className="profile-play-link" href="/"> Play Elysium</a>
</div>
</div>
);
}
if (!profile) {
return (
<div className="profile-page">
<div className="profile-loading">Loading profile</div>
</div>
);
}
const avatarUrl = profile.avatar
? `https://cdn.discordapp.com/avatars/${discordId}/${profile.avatar}.png?size=128`
: `https://cdn.discordapp.com/embed/avatars/${parseInt(discordId, 10) % 5}.png`;
const memberSince = new Date(profile.createdAt).toLocaleDateString("en-GB", {
year: "numeric",
month: "long",
day: "numeric",
});
return (
<div className="profile-page">
<div className="profile-card">
<div className="profile-header">
<img
alt={`${profile.username}'s avatar`}
className="profile-avatar"
src={avatarUrl}
/>
<div className="profile-identity">
<h1 className="profile-character-name">{profile.characterName}</h1>
<p className="profile-username">@{profile.username}</p>
{profile.prestigeCount > 0 && (
<span className="profile-prestige-badge">
Prestige {profile.prestigeCount}
</span>
)}
</div>
</div>
<div className="profile-stats">
<div className="profile-stat">
<span className="profile-stat-icon">🪙</span>
<span className="profile-stat-value">{formatNumber(profile.totalGoldEarned)}</span>
<span className="profile-stat-label">Total Gold Earned</span>
</div>
<div className="profile-stat">
<span className="profile-stat-icon">👆</span>
<span className="profile-stat-value">{formatNumber(profile.totalClicks)}</span>
<span className="profile-stat-label">Total Clicks</span>
</div>
<div className="profile-stat">
<span className="profile-stat-icon">📅</span>
<span className="profile-stat-value profile-stat-date">{memberSince}</span>
<span className="profile-stat-label">Guild Founded</span>
</div>
</div>
<div className="profile-actions">
<button
className="profile-share-button"
onClick={handleCopy}
type="button"
>
{copied ? "✓ Copied!" : "🔗 Copy Profile Link"}
</button>
<a className="profile-play-link" href="/">
Play Elysium
</a>
</div>
</div>
</div>
);
};
+11 -1
View File
@@ -4,9 +4,10 @@ import { formatNumber } from "../../utils/format.js";
interface ResourceBarProps { interface ResourceBarProps {
resources: Resource; resources: Resource;
prestigeCount: number; prestigeCount: number;
profileUrl: string;
} }
export const ResourceBar = ({ resources, prestigeCount }: ResourceBarProps): React.JSX.Element => ( export const ResourceBar = ({ resources, prestigeCount, profileUrl }: ResourceBarProps): React.JSX.Element => (
<header className="resource-bar"> <header className="resource-bar">
<div className="resource"> <div className="resource">
<span className="resource-icon">🪙</span> <span className="resource-icon">🪙</span>
@@ -33,5 +34,14 @@ export const ResourceBar = ({ resources, prestigeCount }: ResourceBarProps): Rea
Prestige {prestigeCount} Prestige {prestigeCount}
</div> </div>
)} )}
<a
className="profile-link-button"
href={profileUrl}
rel="noreferrer"
target="_blank"
title="View your public profile"
>
👤 Profile
</a>
</header> </header>
); );
+173
View File
@@ -1107,6 +1107,179 @@ body {
font-size: 0.85rem; font-size: 0.85rem;
} }
/* ── Profile link button in ResourceBar ────────────────────────────────── */
.profile-link-button {
align-items: center;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(147, 51, 234, 0.4);
border-radius: 1rem;
color: var(--colour-text-muted);
display: flex;
font-size: 0.8rem;
gap: 0.3rem;
margin-left: auto;
padding: 0.3rem 0.8rem;
text-decoration: none;
transition: all 0.2s;
white-space: nowrap;
}
.profile-link-button:hover {
background: rgba(147, 51, 234, 0.2);
border-color: var(--colour-primary);
color: var(--colour-text);
}
/* ── Public Profile Page ────────────────────────────────────────────────── */
.profile-page {
align-items: center;
background: var(--colour-bg);
display: flex;
justify-content: center;
min-height: 100vh;
padding: 2rem 1rem;
}
.profile-card {
background: var(--colour-surface);
border: 1px solid rgba(147, 51, 234, 0.3);
border-radius: 1rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
max-width: 520px;
padding: 2rem;
width: 100%;
}
.profile-header {
align-items: center;
display: flex;
gap: 1.5rem;
margin-bottom: 1.75rem;
}
.profile-avatar {
border: 3px solid var(--colour-primary);
border-radius: 50%;
height: 96px;
width: 96px;
}
.profile-identity {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.profile-character-name {
font-size: 1.6rem;
font-weight: 700;
margin: 0;
}
.profile-username {
color: var(--colour-text-muted);
font-size: 0.9rem;
margin: 0;
}
.profile-prestige-badge {
background: rgba(255, 215, 0, 0.15);
border: 1px solid rgba(255, 215, 0, 0.4);
border-radius: 1rem;
color: gold;
font-size: 0.8rem;
padding: 0.2rem 0.6rem;
width: fit-content;
}
.profile-stats {
display: grid;
gap: 1rem;
grid-template-columns: repeat(3, 1fr);
margin-bottom: 1.75rem;
}
.profile-stat {
align-items: center;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(147, 51, 234, 0.2);
border-radius: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.75rem 0.5rem;
text-align: center;
}
.profile-stat-icon {
font-size: 1.4rem;
}
.profile-stat-value {
font-size: 1rem;
font-weight: 700;
}
.profile-stat-date {
font-size: 0.72rem;
}
.profile-stat-label {
color: var(--colour-text-muted);
font-size: 0.7rem;
}
.profile-actions {
display: flex;
gap: 0.75rem;
}
.profile-share-button {
background: rgba(147, 51, 234, 0.2);
border: 1px solid var(--colour-primary);
border-radius: 0.5rem;
color: var(--colour-text);
cursor: pointer;
flex: 1;
font-family: inherit;
font-size: 0.9rem;
padding: 0.65rem 1rem;
transition: background 0.2s;
}
.profile-share-button:hover {
background: rgba(147, 51, 234, 0.35);
}
.profile-play-link {
background: var(--colour-primary);
border-radius: 0.5rem;
color: #fff;
flex: 1;
font-size: 0.9rem;
font-weight: 600;
padding: 0.65rem 1rem;
text-align: center;
text-decoration: none;
transition: opacity 0.2s;
}
.profile-play-link:hover {
opacity: 0.85;
}
.profile-loading,
.profile-error {
color: var(--colour-text-muted);
display: flex;
flex-direction: column;
font-size: 1rem;
gap: 1rem;
text-align: center;
}
/* ── Panel Header (title + lock toggle row) ────────────────────────────── */ /* ── Panel Header (title + lock toggle row) ────────────────────────────── */
.panel-header { .panel-header {