generated from nhcarrigan/template
feat: add character sheet panel with guild info
Adds pronouns, guildName, and guildDescription fields to the player profile. A new Character tab provides an in-game view/edit panel for character and guild lore. Closes #16
This commit is contained in:
@@ -63,6 +63,10 @@ const HOW_TO_PLAY = [
|
||||
title: "📖 Codex",
|
||||
body: "Defeating bosses, completing quests, acquiring equipment, hiring adventurers, purchasing upgrades, unlocking prestige upgrades, discovering new zones, collecting from exploration areas, and crafting recipes all permanently unlock lore entries in the Codex. A badge appears on the Codex tab and a toast notification pops up each time new lore is discovered. Collect all 472 entries to build a complete picture of the world of Elysium.",
|
||||
},
|
||||
{
|
||||
title: "📋 Character Sheet",
|
||||
body: "Visit the Character tab to write about your character and guild. Fill in your character's name, pronouns, and backstory, then create a guild with its own name and lore. Your character sheet is visible on your public profile page.",
|
||||
},
|
||||
{
|
||||
title: "☁️ Cloud Saves",
|
||||
body: "Your progress is automatically saved to the cloud every 30 seconds whilst you play. You can also force a manual save at any time using the sync button in the resource bar. Your save is protected by HMAC validation to ensure data integrity.",
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import type { ProfileSettings } from "@elysium/types";
|
||||
import { DEFAULT_PROFILE_SETTINGS } from "@elysium/types";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { updateProfile } from "../../api/client.js";
|
||||
import { useGame } from "../../context/GameContext.js";
|
||||
|
||||
interface CharacterSheetData {
|
||||
characterName: string;
|
||||
pronouns: string;
|
||||
bio: string;
|
||||
guildName: string;
|
||||
guildDescription: string;
|
||||
}
|
||||
|
||||
const EMPTY_SHEET: CharacterSheetData = {
|
||||
characterName: "",
|
||||
pronouns: "",
|
||||
bio: "",
|
||||
guildName: "",
|
||||
guildDescription: "",
|
||||
};
|
||||
|
||||
export const CharacterSheetPanel = (): React.JSX.Element => {
|
||||
const { state } = useGame();
|
||||
const player = state?.player;
|
||||
|
||||
const [sheet, setSheet] = useState<CharacterSheetData>(EMPTY_SHEET);
|
||||
const [draft, setDraft] = useState<CharacterSheetData>(EMPTY_SHEET);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const savedSettingsRef = useRef<ProfileSettings>({ ...DEFAULT_PROFILE_SETTINGS });
|
||||
|
||||
useEffect(() => {
|
||||
if (!player?.discordId) return;
|
||||
fetch(`/api/profile/${player.discordId}`)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as {
|
||||
characterName: string;
|
||||
pronouns: string;
|
||||
bio: string;
|
||||
guildName: string;
|
||||
guildDescription: string;
|
||||
profileSettings: ProfileSettings;
|
||||
};
|
||||
const loaded: CharacterSheetData = {
|
||||
characterName: data.characterName ?? "",
|
||||
pronouns: data.pronouns ?? "",
|
||||
bio: data.bio ?? "",
|
||||
guildName: data.guildName ?? "",
|
||||
guildDescription: data.guildDescription ?? "",
|
||||
};
|
||||
setSheet(loaded);
|
||||
setDraft(loaded);
|
||||
savedSettingsRef.current = { ...DEFAULT_PROFILE_SETTINGS, ...data.profileSettings };
|
||||
})
|
||||
.catch(() => { /* fall back to empty */ })
|
||||
.finally(() => { setLoading(false); });
|
||||
}, [player?.discordId]);
|
||||
|
||||
const handleEdit = (): void => {
|
||||
setDraft({ ...sheet });
|
||||
setEditing(true);
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
};
|
||||
|
||||
const handleCancel = (): void => {
|
||||
setEditing(false);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await updateProfile({
|
||||
characterName: draft.characterName || (player?.characterName ?? ""),
|
||||
pronouns: draft.pronouns,
|
||||
bio: draft.bio,
|
||||
guildName: draft.guildName,
|
||||
guildDescription: draft.guildDescription,
|
||||
profileSettings: savedSettingsRef.current,
|
||||
});
|
||||
setSheet({ ...draft });
|
||||
setSaved(true);
|
||||
setTimeout(() => {
|
||||
setEditing(false);
|
||||
setSaved(false);
|
||||
}, 900);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to save");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <section className="panel"><p>Loading character sheet…</p></section>;
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<section className="panel character-sheet-panel">
|
||||
<div className="panel-header">
|
||||
<h2>📋 Character Sheet</h2>
|
||||
</div>
|
||||
|
||||
<div className="character-sheet-form">
|
||||
<div className="character-sheet-section">
|
||||
<h3 className="character-sheet-section-title">⚔️ Character</h3>
|
||||
|
||||
<label className="character-sheet-label" htmlFor="cs-name">Character Name</label>
|
||||
<input
|
||||
className="character-sheet-input"
|
||||
id="cs-name"
|
||||
maxLength={32}
|
||||
placeholder="Your character's name"
|
||||
type="text"
|
||||
value={draft.characterName}
|
||||
onChange={(e) => { setDraft((d) => ({ ...d, characterName: e.target.value })); }}
|
||||
/>
|
||||
<span className="character-sheet-hint">{draft.characterName.length} / 32</span>
|
||||
|
||||
<label className="character-sheet-label" htmlFor="cs-pronouns">Pronouns</label>
|
||||
<input
|
||||
className="character-sheet-input"
|
||||
id="cs-pronouns"
|
||||
maxLength={20}
|
||||
placeholder="e.g. she/her, he/him, they/them"
|
||||
type="text"
|
||||
value={draft.pronouns}
|
||||
onChange={(e) => { setDraft((d) => ({ ...d, pronouns: e.target.value })); }}
|
||||
/>
|
||||
<span className="character-sheet-hint">{draft.pronouns.length} / 20</span>
|
||||
|
||||
<label className="character-sheet-label" htmlFor="cs-bio">About Your Character</label>
|
||||
<textarea
|
||||
className="character-sheet-textarea"
|
||||
id="cs-bio"
|
||||
maxLength={200}
|
||||
placeholder="Describe your character's story, personality, or appearance…"
|
||||
rows={4}
|
||||
value={draft.bio}
|
||||
onChange={(e) => { setDraft((d) => ({ ...d, bio: e.target.value })); }}
|
||||
/>
|
||||
<span className="character-sheet-hint">{draft.bio.length} / 200</span>
|
||||
</div>
|
||||
|
||||
<div className="character-sheet-section">
|
||||
<h3 className="character-sheet-section-title">🏰 Guild</h3>
|
||||
|
||||
<label className="character-sheet-label" htmlFor="cs-guild-name">Guild Name</label>
|
||||
<input
|
||||
className="character-sheet-input"
|
||||
id="cs-guild-name"
|
||||
maxLength={64}
|
||||
placeholder="Name your guild"
|
||||
type="text"
|
||||
value={draft.guildName}
|
||||
onChange={(e) => { setDraft((d) => ({ ...d, guildName: e.target.value })); }}
|
||||
/>
|
||||
<span className="character-sheet-hint">{draft.guildName.length} / 64</span>
|
||||
|
||||
<label className="character-sheet-label" htmlFor="cs-guild-desc">Guild Description</label>
|
||||
<textarea
|
||||
className="character-sheet-textarea"
|
||||
id="cs-guild-desc"
|
||||
maxLength={500}
|
||||
placeholder="Describe your guild's history, goals, or lore…"
|
||||
rows={6}
|
||||
value={draft.guildDescription}
|
||||
onChange={(e) => { setDraft((d) => ({ ...d, guildDescription: e.target.value })); }}
|
||||
/>
|
||||
<span className="character-sheet-hint">{draft.guildDescription.length} / 500</span>
|
||||
</div>
|
||||
|
||||
{error && <p className="character-sheet-error">{error}</p>}
|
||||
|
||||
<div className="character-sheet-actions">
|
||||
<button className="character-sheet-cancel" onClick={handleCancel} type="button">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="character-sheet-save"
|
||||
disabled={saving || !draft.characterName.trim()}
|
||||
onClick={() => { void handleSave(); }}
|
||||
type="button"
|
||||
>
|
||||
{saved ? "✓ Saved!" : saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel character-sheet-panel">
|
||||
<div className="panel-header">
|
||||
<h2>📋 Character Sheet</h2>
|
||||
<button className="character-sheet-edit-btn" onClick={handleEdit} type="button">
|
||||
✏️ Edit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="character-sheet-view">
|
||||
<div className="character-sheet-section">
|
||||
<h3 className="character-sheet-section-title">⚔️ Character</h3>
|
||||
<div className="character-sheet-field">
|
||||
<span className="character-sheet-field-label">Name</span>
|
||||
<span className="character-sheet-field-value">
|
||||
{sheet.characterName || <em className="character-sheet-empty">Not set</em>}
|
||||
</span>
|
||||
</div>
|
||||
{sheet.pronouns && (
|
||||
<div className="character-sheet-field">
|
||||
<span className="character-sheet-field-label">Pronouns</span>
|
||||
<span className="character-sheet-field-value">{sheet.pronouns}</span>
|
||||
</div>
|
||||
)}
|
||||
{sheet.bio && (
|
||||
<div className="character-sheet-bio">
|
||||
<span className="character-sheet-field-label">About</span>
|
||||
<p className="character-sheet-bio-text">{sheet.bio}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="character-sheet-section">
|
||||
<h3 className="character-sheet-section-title">🏰 Guild</h3>
|
||||
{sheet.guildName ? (
|
||||
<>
|
||||
<div className="character-sheet-field">
|
||||
<span className="character-sheet-field-label">Name</span>
|
||||
<span className="character-sheet-field-value">{sheet.guildName}</span>
|
||||
</div>
|
||||
{sheet.guildDescription && (
|
||||
<div className="character-sheet-bio">
|
||||
<span className="character-sheet-field-label">Lore</span>
|
||||
<p className="character-sheet-bio-text">{sheet.guildDescription}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="character-sheet-empty">No guild registered yet. Click ✏️ Edit to add one!</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -21,9 +21,10 @@ import { StatisticsPanel } from "./StatisticsPanel.js";
|
||||
import { UpgradePanel } from "./UpgradePanel.js";
|
||||
import { DailyChallengePanel } from "./DailyChallengePanel.js";
|
||||
import { ExplorationPanel } from "./ExplorationPanel.js";
|
||||
import { CharacterSheetPanel } from "./CharacterSheetPanel.js";
|
||||
import { CraftingPanel } from "./CraftingPanel.js";
|
||||
|
||||
type Tab = "adventurers" | "upgrades" | "quests" | "bosses" | "equipment" | "achievements" | "prestige" | "transcendence" | "apotheosis" | "statistics" | "daily" | "codex" | "about" | "exploration" | "crafting";
|
||||
type Tab = "adventurers" | "upgrades" | "quests" | "bosses" | "equipment" | "achievements" | "prestige" | "transcendence" | "apotheosis" | "statistics" | "daily" | "codex" | "about" | "exploration" | "crafting" | "character";
|
||||
|
||||
const BASE_TABS: { id: Tab; label: string }[] = [
|
||||
{ id: "adventurers", label: "⚔️ Adventurers" },
|
||||
@@ -38,6 +39,7 @@ const BASE_TABS: { id: Tab; label: string }[] = [
|
||||
{ id: "transcendence", label: "🌌 Transcendence" },
|
||||
{ id: "apotheosis", label: "✨ Apotheosis" },
|
||||
{ id: "statistics", label: "📊 Statistics" },
|
||||
{ id: "character", label: "📋 Character" },
|
||||
{ id: "achievements", label: "🏆 Achievements" },
|
||||
{ id: "codex", label: "📖 Codex" },
|
||||
{ id: "about", label: "ℹ️ About" },
|
||||
@@ -129,6 +131,7 @@ export const GameLayout = (): React.JSX.Element => {
|
||||
{activeTab === "crafting" && <CraftingPanel />}
|
||||
{activeTab === "statistics" && <StatisticsPanel />}
|
||||
{activeTab === "daily" && <DailyChallengePanel />}
|
||||
{activeTab === "character" && <CharacterSheetPanel />}
|
||||
{activeTab === "codex" && <CodexPanel />}
|
||||
{activeTab === "about" && <AboutPanel />}
|
||||
</div>
|
||||
|
||||
@@ -3070,3 +3070,175 @@ body {
|
||||
padding: 0.3rem 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================== CHARACTER SHEET ===================== */
|
||||
.character-sheet-panel .panel-header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.character-sheet-edit-btn {
|
||||
background: var(--colour-surface);
|
||||
border: 1px solid var(--colour-border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--colour-text);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.3rem 0.75rem;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.character-sheet-edit-btn:hover {
|
||||
border-color: var(--colour-accent);
|
||||
color: var(--colour-accent);
|
||||
}
|
||||
|
||||
.character-sheet-view,
|
||||
.character-sheet-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.character-sheet-section {
|
||||
background: var(--colour-surface);
|
||||
border: 1px solid var(--colour-border);
|
||||
border-radius: var(--radius);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.character-sheet-section-title {
|
||||
color: var(--colour-accent);
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.character-sheet-field {
|
||||
align-items: baseline;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.character-sheet-field-label {
|
||||
color: var(--colour-text-muted);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
min-width: 5rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.character-sheet-field-value {
|
||||
color: var(--colour-text);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.character-sheet-bio {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.character-sheet-bio-text {
|
||||
color: var(--colour-text);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.character-sheet-empty {
|
||||
color: var(--colour-text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.character-sheet-label {
|
||||
color: var(--colour-text-muted);
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.character-sheet-input {
|
||||
background: var(--colour-bg);
|
||||
border: 1px solid var(--colour-border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--colour-text);
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.character-sheet-input:focus {
|
||||
border-color: var(--colour-accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.character-sheet-textarea {
|
||||
background: var(--colour-bg);
|
||||
border: 1px solid var(--colour-border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--colour-text);
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
resize: vertical;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.character-sheet-textarea:focus {
|
||||
border-color: var(--colour-accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.character-sheet-hint {
|
||||
color: var(--colour-text-muted);
|
||||
font-size: 0.75rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.character-sheet-error {
|
||||
color: #e74c3c;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.character-sheet-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.character-sheet-cancel {
|
||||
background: transparent;
|
||||
border: 1px solid var(--colour-border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--colour-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem 1.25rem;
|
||||
}
|
||||
|
||||
.character-sheet-save {
|
||||
background: var(--colour-accent);
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
padding: 0.5rem 1.25rem;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.character-sheet-save:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user