diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 613ea91..eab9caa 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -15,6 +15,8 @@ model Player { avatar String? characterName String @default("") pronouns String @default("") + characterRace String @default("") + characterClass String @default("") bio String @default("") guildName String @default("") guildDescription String @default("") diff --git a/apps/api/src/routes/profile.ts b/apps/api/src/routes/profile.ts index 3185fd8..64ecb43 100644 --- a/apps/api/src/routes/profile.ts +++ b/apps/api/src/routes/profile.ts @@ -70,6 +70,8 @@ profileRouter.get("/:discordId", async (context) => { return context.json({ characterName: player.characterName, pronouns: player.pronouns ?? "", + characterRace: player.characterRace ?? "", + characterClass: player.characterClass ?? "", username: player.username, avatar: player.avatar ?? null, bio: player.bio ?? "", @@ -103,6 +105,8 @@ profileRouter.put("/", authMiddleware, async (context) => { const characterName = (body.characterName ?? "").trim().slice(0, 32); const pronouns = (body.pronouns ?? "").trim().slice(0, 20); + const characterRace = (body.characterRace ?? "").trim().slice(0, 32); + const characterClass = (body.characterClass ?? "").trim().slice(0, 32); const bio = (body.bio ?? "").trim().slice(0, 200); const guildName = (body.guildName ?? "").trim().slice(0, 64); const guildDescription = (body.guildDescription ?? "").trim().slice(0, 500); @@ -135,12 +139,14 @@ profileRouter.put("/", authMiddleware, async (context) => { const updated = await prisma.player.update({ where: { discordId }, - data: { characterName, pronouns, bio, guildName, guildDescription, profileSettings: profileSettings as object }, + data: { characterName, pronouns, characterRace, characterClass, bio, guildName, guildDescription, profileSettings: profileSettings as object }, }); return context.json({ characterName: updated.characterName, pronouns: updated.pronouns, + characterRace: updated.characterRace, + characterClass: updated.characterClass, bio: updated.bio, guildName: updated.guildName, guildDescription: updated.guildDescription, diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 4ae8727..05fbf71 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { GameProvider } from "./context/GameContext.js"; +import { CharacterPage } from "./components/game/CharacterPage.js"; import { GameLayout } from "./components/game/GameLayout.js"; import { LoginPage } from "./components/game/LoginPage.js"; import { ProfilePage } from "./components/game/ProfilePage.js"; @@ -9,6 +10,11 @@ const getProfileDiscordId = (): string | null => { return match?.[1] ?? null; }; +const getCharacterDiscordId = (): string | null => { + const match = /^\/character\/(\d+)$/.exec(window.location.pathname); + return match?.[1] ?? null; +}; + const handleAuthCallback = (): boolean => { if (window.location.pathname !== "/auth/callback") { return false; @@ -38,6 +44,11 @@ export const App = (): React.JSX.Element => { return ; } + const characterDiscordId = getCharacterDiscordId(); + if (characterDiscordId) { + return ; + } + if (!loggedIn) { return { setLoggedIn(true); }} />; } diff --git a/apps/web/src/components/game/CharacterPage.tsx b/apps/web/src/components/game/CharacterPage.tsx new file mode 100644 index 0000000..1c5a794 --- /dev/null +++ b/apps/web/src/components/game/CharacterPage.tsx @@ -0,0 +1,140 @@ +import type { PublicProfileResponse } from "@elysium/types"; +import { useEffect, useState } from "react"; + +interface CharacterPageProps { + discordId: string; +} + +export const CharacterPage = ({ discordId }: CharacterPageProps): 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 character sheet"); + }); + }, [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 character sheet…
+
+ ); + } + + 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 subtitle = [profile.characterRace, profile.characterClass].filter(Boolean).join(" · "); + const hasBadge = profile.apotheosisCount > 0 || profile.transcendenceCount > 0 || profile.prestigeCount > 0; + + return ( +
+
+
+ {`${profile.characterName +
+

+ {profile.characterName || profile.username} +

+ {profile.pronouns && ( +

{profile.pronouns}

+ )} + {subtitle && ( +

{subtitle}

+ )} + {hasBadge && ( +
+ {profile.apotheosisCount > 0 && ( + + ✨ Apotheosis {profile.apotheosisCount} + + )} + {profile.transcendenceCount > 0 && ( + + 🌌 Transcendence {profile.transcendenceCount} + + )} + {profile.prestigeCount > 0 && ( + + ⭐ Prestige {profile.prestigeCount} + + )} +
+ )} +
+
+ + {profile.bio && ( +
+

⚔️ About

+

{profile.bio}

+
+ )} + + {profile.guildName && ( +
+

🏰 Guild

+

{profile.guildName}

+ {profile.guildDescription && ( +

{profile.guildDescription}

+ )} +
+ )} + +
+ +

+ Played by @{profile.username} +

+ +
+ + + 📊 View Stats + + + ⚔️ Play Elysium + +
+
+
+ ); +}; diff --git a/apps/web/src/components/game/CharacterSheetPanel.tsx b/apps/web/src/components/game/CharacterSheetPanel.tsx index 2283257..17b6d2b 100644 --- a/apps/web/src/components/game/CharacterSheetPanel.tsx +++ b/apps/web/src/components/game/CharacterSheetPanel.tsx @@ -7,6 +7,8 @@ import { useGame } from "../../context/GameContext.js"; interface CharacterSheetData { characterName: string; pronouns: string; + characterRace: string; + characterClass: string; bio: string; guildName: string; guildDescription: string; @@ -15,6 +17,8 @@ interface CharacterSheetData { const EMPTY_SHEET: CharacterSheetData = { characterName: "", pronouns: "", + characterRace: "", + characterClass: "", bio: "", guildName: "", guildDescription: "", @@ -41,6 +45,8 @@ export const CharacterSheetPanel = (): React.JSX.Element => { const data = (await res.json()) as { characterName: string; pronouns: string; + characterRace: string; + characterClass: string; bio: string; guildName: string; guildDescription: string; @@ -49,6 +55,8 @@ export const CharacterSheetPanel = (): React.JSX.Element => { const loaded: CharacterSheetData = { characterName: data.characterName ?? "", pronouns: data.pronouns ?? "", + characterRace: data.characterRace ?? "", + characterClass: data.characterClass ?? "", bio: data.bio ?? "", guildName: data.guildName ?? "", guildDescription: data.guildDescription ?? "", @@ -80,6 +88,8 @@ export const CharacterSheetPanel = (): React.JSX.Element => { await updateProfile({ characterName: draft.characterName || (player?.characterName ?? ""), pronouns: draft.pronouns, + characterRace: draft.characterRace, + characterClass: draft.characterClass, bio: draft.bio, guildName: draft.guildName, guildDescription: draft.guildDescription, @@ -137,6 +147,30 @@ export const CharacterSheetPanel = (): React.JSX.Element => { /> {draft.pronouns.length} / 20 + + { setDraft((d) => ({ ...d, characterRace: e.target.value })); }} + /> + {draft.characterRace.length} / 32 + + + { setDraft((d) => ({ ...d, characterClass: e.target.value })); }} + /> + {draft.characterClass.length} / 32 +