feat: add race/class to character sheet, public /character/:id page

Players can now set their character's race and class in the Character
tab. A new public page at /character/:id displays the full character
sheet — name, pronouns, race, class, bio, and guild lore.
This commit is contained in:
2026-03-07 13:54:13 -08:00
committed by Naomi Carrigan
parent 924b9f541d
commit 8f0d038da1
7 changed files with 416 additions and 1 deletions
+2
View File
@@ -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("")
+7 -1
View File
@@ -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,
+11
View File
@@ -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 <ProfilePage discordId={profileDiscordId} />;
}
const characterDiscordId = getCharacterDiscordId();
if (characterDiscordId) {
return <CharacterPage discordId={characterDiscordId} />;
}
if (!loggedIn) {
return <LoginPage onLogin={() => { setLoggedIn(true); }} />;
}
@@ -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<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 character sheet");
});
}, [discordId]);
const handleCopy = (): void => {
void navigator.clipboard.writeText(window.location.href).then(() => {
setCopied(true);
setTimeout(() => { setCopied(false); }, 2000);
});
};
if (error) {
return (
<div className="character-page">
<div className="character-page-error">
<p> {error}</p>
<a className="character-page-link" href="/"> Play Elysium</a>
</div>
</div>
);
}
if (!profile) {
return (
<div className="character-page">
<div className="character-page-loading">Loading character sheet</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 subtitle = [profile.characterRace, profile.characterClass].filter(Boolean).join(" · ");
const hasBadge = profile.apotheosisCount > 0 || profile.transcendenceCount > 0 || profile.prestigeCount > 0;
return (
<div className="character-page">
<div className="character-page-card">
<div className="character-page-header">
<img
alt={`${profile.characterName || profile.username}'s avatar`}
className="character-page-avatar"
src={avatarUrl}
/>
<div className="character-page-identity">
<h1 className="character-page-name">
{profile.characterName || profile.username}
</h1>
{profile.pronouns && (
<p className="character-page-pronouns">{profile.pronouns}</p>
)}
{subtitle && (
<p className="character-page-subtitle">{subtitle}</p>
)}
{hasBadge && (
<div className="character-page-badges">
{profile.apotheosisCount > 0 && (
<span className="character-page-badge character-page-badge--apotheosis">
Apotheosis {profile.apotheosisCount}
</span>
)}
{profile.transcendenceCount > 0 && (
<span className="character-page-badge character-page-badge--transcendence">
🌌 Transcendence {profile.transcendenceCount}
</span>
)}
{profile.prestigeCount > 0 && (
<span className="character-page-badge character-page-badge--prestige">
Prestige {profile.prestigeCount}
</span>
)}
</div>
)}
</div>
</div>
{profile.bio && (
<div className="character-page-section">
<h2 className="character-page-section-title"> About</h2>
<p className="character-page-bio">{profile.bio}</p>
</div>
)}
{profile.guildName && (
<div className="character-page-section">
<h2 className="character-page-section-title">🏰 Guild</h2>
<p className="character-page-guild-name">{profile.guildName}</p>
{profile.guildDescription && (
<p className="character-page-guild-desc">{profile.guildDescription}</p>
)}
</div>
)}
<div className="character-page-divider" />
<p className="character-page-player-line">
Played by <span className="character-page-username">@{profile.username}</span>
</p>
<div className="character-page-actions">
<button
className="character-page-share-btn"
onClick={handleCopy}
type="button"
>
{copied ? "✓ Copied!" : "🔗 Share Character"}
</button>
<a className="character-page-profile-link" href={`/profile/${discordId}`}>
📊 View Stats
</a>
<a className="character-page-play-link" href="/">
Play Elysium
</a>
</div>
</div>
</div>
);
};
@@ -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 => {
/>
<span className="character-sheet-hint">{draft.pronouns.length} / 20</span>
<label className="character-sheet-label" htmlFor="cs-race">Race</label>
<input
className="character-sheet-input"
id="cs-race"
maxLength={32}
placeholder="e.g. Elf, Dwarf, Human, Tiefling…"
type="text"
value={draft.characterRace}
onChange={(e) => { setDraft((d) => ({ ...d, characterRace: e.target.value })); }}
/>
<span className="character-sheet-hint">{draft.characterRace.length} / 32</span>
<label className="character-sheet-label" htmlFor="cs-class">Class</label>
<input
className="character-sheet-input"
id="cs-class"
maxLength={32}
placeholder="e.g. Paladin, Archmage, Shadow Rogue…"
type="text"
value={draft.characterClass}
onChange={(e) => { setDraft((d) => ({ ...d, characterClass: e.target.value })); }}
/>
<span className="character-sheet-hint">{draft.characterClass.length} / 32</span>
<label className="character-sheet-label" htmlFor="cs-bio">About Your Character</label>
<textarea
className="character-sheet-textarea"
@@ -198,6 +232,8 @@ export const CharacterSheetPanel = (): React.JSX.Element => {
);
}
const subtitle = [sheet.characterRace, sheet.characterClass].filter(Boolean).join(" · ");
return (
<section className="panel character-sheet-panel">
<div className="panel-header">
@@ -222,6 +258,12 @@ export const CharacterSheetPanel = (): React.JSX.Element => {
<span className="character-sheet-field-value">{sheet.pronouns}</span>
</div>
)}
{subtitle && (
<div className="character-sheet-field">
<span className="character-sheet-field-label">Identity</span>
<span className="character-sheet-field-value">{subtitle}</span>
</div>
)}
{sheet.bio && (
<div className="character-sheet-bio">
<span className="character-sheet-field-label">About</span>
+208
View File
@@ -3242,3 +3242,211 @@ body {
cursor: not-allowed;
opacity: 0.5;
}
/* ===================== CHARACTER PAGE (public /character/:id) ===================== */
.character-page {
align-items: center;
background: var(--colour-bg);
display: flex;
justify-content: center;
min-height: 100vh;
padding: 2rem 1rem;
}
.character-page-loading,
.character-page-error {
color: var(--colour-text);
display: flex;
flex-direction: column;
gap: 1rem;
text-align: center;
}
.character-page-card {
background: var(--colour-surface);
border: 1px solid var(--colour-border);
border-radius: calc(var(--radius) * 2);
box-shadow: 0 4px 32px rgba(0, 0, 0, 0.4);
display: flex;
flex-direction: column;
gap: 1.5rem;
max-width: 640px;
padding: 2.5rem 2rem;
width: 100%;
}
.character-page-header {
align-items: flex-start;
display: flex;
gap: 1.5rem;
}
.character-page-avatar {
border: 3px solid var(--colour-accent);
border-radius: 50%;
flex-shrink: 0;
height: 96px;
object-fit: cover;
width: 96px;
}
.character-page-identity {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.character-page-name {
color: var(--colour-accent);
font-size: 1.75rem;
font-weight: 800;
line-height: 1.1;
}
.character-page-pronouns {
color: var(--colour-text-muted);
font-size: 0.85rem;
}
.character-page-subtitle {
color: var(--colour-text);
font-size: 0.95rem;
font-style: italic;
}
.character-page-badges {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.25rem;
}
.character-page-badge {
border-radius: var(--radius);
font-size: 0.75rem;
font-weight: 700;
padding: 0.2rem 0.6rem;
}
.character-page-badge--apotheosis {
background: rgba(255, 215, 0, 0.15);
border: 1px solid gold;
color: gold;
}
.character-page-badge--transcendence {
background: rgba(100, 149, 237, 0.15);
border: 1px solid cornflowerblue;
color: cornflowerblue;
}
.character-page-badge--prestige {
background: rgba(160, 82, 255, 0.15);
border: 1px solid var(--colour-accent);
color: var(--colour-accent);
}
.character-page-section {
border-top: 1px solid var(--colour-border);
display: flex;
flex-direction: column;
gap: 0.5rem;
padding-top: 1.25rem;
}
.character-page-section-title {
color: var(--colour-accent);
font-size: 1rem;
font-weight: 700;
}
.character-page-bio {
color: var(--colour-text);
font-size: 0.95rem;
line-height: 1.6;
white-space: pre-wrap;
}
.character-page-guild-name {
color: var(--colour-text);
font-size: 1.1rem;
font-weight: 700;
}
.character-page-guild-desc {
color: var(--colour-text);
font-size: 0.9rem;
line-height: 1.6;
margin-top: 0.25rem;
white-space: pre-wrap;
}
.character-page-divider {
border: none;
border-top: 1px solid var(--colour-border);
}
.character-page-player-line {
color: var(--colour-text-muted);
font-size: 0.85rem;
text-align: center;
}
.character-page-username {
color: var(--colour-text);
font-weight: 600;
}
.character-page-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: center;
}
.character-page-share-btn,
.character-page-profile-link,
.character-page-play-link,
.character-page-link {
border-radius: var(--radius);
font-size: 0.85rem;
font-weight: 600;
padding: 0.5rem 1rem;
text-decoration: none;
transition: opacity 0.15s;
}
.character-page-share-btn {
background: var(--colour-surface);
border: 1px solid var(--colour-border);
color: var(--colour-text);
cursor: pointer;
}
.character-page-share-btn:hover {
border-color: var(--colour-accent);
color: var(--colour-accent);
}
.character-page-profile-link {
background: var(--colour-surface);
border: 1px solid var(--colour-border);
color: var(--colour-text);
}
.character-page-profile-link:hover {
border-color: var(--colour-accent);
color: var(--colour-accent);
}
.character-page-play-link,
.character-page-link {
background: var(--colour-accent);
border: none;
color: #fff;
}
.character-page-play-link:hover,
.character-page-link:hover {
opacity: 0.85;
}