generated from nhcarrigan/template
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:
@@ -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("")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user