generated from nhcarrigan/template
feat: add public player profile page
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user