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
@@ -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>