diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index a0694f1..4ae8727 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -2,6 +2,12 @@ import { useState } from "react"; import { GameProvider } from "./context/GameContext.js"; import { GameLayout } from "./components/game/GameLayout.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 => { if (window.location.pathname !== "/auth/callback") { @@ -27,6 +33,11 @@ const isAuthenticated = (): boolean => { export const App = (): React.JSX.Element => { const [loggedIn, setLoggedIn] = useState(isAuthenticated); + const profileDiscordId = getProfileDiscordId(); + if (profileDiscordId) { + return ; + } + if (!loggedIn) { return { setLoggedIn(true); }} />; } diff --git a/apps/web/src/components/game/GameLayout.tsx b/apps/web/src/components/game/GameLayout.tsx index 85a43f3..91bf0a8 100644 --- a/apps/web/src/components/game/GameLayout.tsx +++ b/apps/web/src/components/game/GameLayout.tsx @@ -47,11 +47,14 @@ export const GameLayout = (): React.JSX.Element => { if (!state) return

Loading...

; + const profileUrl = `/profile/${state.player.discordId}`; + return (
diff --git a/apps/web/src/components/game/ProfilePage.tsx b/apps/web/src/components/game/ProfilePage.tsx new file mode 100644 index 0000000..67cb820 --- /dev/null +++ b/apps/web/src/components/game/ProfilePage.tsx @@ -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(null); + const [error, setError] = useState(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; + }) + .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 ( +
+
+

⚠️ {error}

+ ← Play Elysium +
+
+ ); + } + + if (!profile) { + return ( +
+
Loading profile…
+
+ ); + } + + 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 ( +
+
+
+ {`${profile.username}'s +
+

{profile.characterName}

+

@{profile.username}

+ {profile.prestigeCount > 0 && ( + + ⭐ Prestige {profile.prestigeCount} + + )} +
+
+ +
+
+ 🪙 + {formatNumber(profile.totalGoldEarned)} + Total Gold Earned +
+
+ 👆 + {formatNumber(profile.totalClicks)} + Total Clicks +
+
+ 📅 + {memberSince} + Guild Founded +
+
+ +
+ + + ⚔️ Play Elysium + +
+
+
+ ); +}; diff --git a/apps/web/src/components/ui/ResourceBar.tsx b/apps/web/src/components/ui/ResourceBar.tsx index 93ea415..ebaf800 100644 --- a/apps/web/src/components/ui/ResourceBar.tsx +++ b/apps/web/src/components/ui/ResourceBar.tsx @@ -4,9 +4,10 @@ import { formatNumber } from "../../utils/format.js"; interface ResourceBarProps { resources: Resource; prestigeCount: number; + profileUrl: string; } -export const ResourceBar = ({ resources, prestigeCount }: ResourceBarProps): React.JSX.Element => ( +export const ResourceBar = ({ resources, prestigeCount, profileUrl }: ResourceBarProps): React.JSX.Element => (
🪙 @@ -33,5 +34,14 @@ export const ResourceBar = ({ resources, prestigeCount }: ResourceBarProps): Rea ⭐ Prestige {prestigeCount}
)} + + 👤 Profile +
); diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 8f9cd4b..14698f1 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -1107,6 +1107,179 @@ body { 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 {