Files
elysium/apps/web/src/components/game/characterSheetPanel.tsx
T
hikari a36c8e72a5
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m3s
CI / Lint, Build & Test (push) Successful in 1m8s
feat: error handling, logging, analytics, OG tags, and sticky sidebar (#44)
## Summary

- Add comprehensive try/catch error handling across all API routes, middleware, and the Hono global error handler, piping every unhandled error to the `@nhcarrigan/logger` service to prevent silent crashes and unhandled Promise rejections
- Add a `logError` utility on the frontend that forwards errors through the overridden `console.error` to the backend telemetry endpoint; apply it to every silent `catch {}` block in the game context, sound, notification, and clipboard utilities, and wrap the React tree in an `ErrorBoundary`
- Add Plausible analytics, Open Graph + Twitter Card meta tags, Tree-Nation widget, and Google Ads to `index.html`
- Make the game sidebar sticky with a `--resource-bar-height` CSS custom property offset so it stays viewport-height without overlapping the resource bar; reset sticky behaviour in the mobile responsive override

## Test plan

- [ ] Lint passes: `pnpm lint`
- [ ] Build passes: `pnpm build`
- [ ] Verify errors thrown in API routes appear in the logger service rather than crashing the process
- [ ] Verify frontend errors appear in the `/api/fe/error` backend log
- [ ] Verify Open Graph tags render correctly when sharing the URL
- [ ] Verify Plausible analytics fires on page load
- [ ] Verify Tree-Nation badge renders in the sidebar
- [ ] Verify sidebar stays fixed while the main content scrolls on desktop
- [ ] Verify mobile layout is unaffected

โœจ This issue was created with help from Hikari~ ๐ŸŒธ

Reviewed-on: #44
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-09 19:54:42 -07:00

697 lines
22 KiB
TypeScript

/**
* @file Character sheet panel for viewing and editing the player's character.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Complex component with many fields */
/* eslint-disable complexity -- Many conditional render paths for optional fields */
/* eslint-disable max-statements -- Component requires many state declarations */
/* eslint-disable max-lines -- Large component with editing and view modes */
import {
DEFAULT_PROFILE_SETTINGS,
STORY_CHAPTERS,
type EquipmentBonus,
type EquipmentRarity,
type EquipmentType,
type ProfileSettings,
} from "@elysium/types";
import { type ChangeEvent, type JSX, useEffect, useRef, useState } from "react";
import { updateProfile } from "../../api/client.js";
import { useGame } from "../../context/gameContext.js";
import { logError } from "../../utils/logError.js";
interface EquippedItem {
name: string;
type: EquipmentType;
rarity: EquipmentRarity;
bonus: EquipmentBonus;
}
interface CharacterSheetData {
characterName: string;
pronouns: string;
characterRace: string;
characterClass: string;
bio: string;
guildName: string;
guildDescription: string;
activeTitle: string;
unlockedTitles: Array<{ id: string; name: string }>;
equippedItems: Array<EquippedItem>;
}
const emptySheet: CharacterSheetData = {
activeTitle: "",
bio: "",
characterClass: "",
characterName: "",
characterRace: "",
equippedItems: [],
guildDescription: "",
guildName: "",
pronouns: "",
unlockedTitles: [],
};
const slotIcons: Record<EquipmentType, string> = {
armour: "๐Ÿ›ก๏ธ",
trinket: "๐Ÿ’",
weapon: "โš”๏ธ",
};
/**
* Formats an equipment bonus as a human-readable string.
* @param bonus - The equipment bonus to format.
* @returns The formatted bonus string.
*/
const formatBonus = (bonus: EquipmentBonus): string => {
const parts: Array<string> = [];
if (bonus.goldMultiplier !== undefined) {
const pct = Math.round((bonus.goldMultiplier - 1) * 100);
parts.push(`+${String(pct)}% Gold Income`);
}
if (bonus.combatMultiplier !== undefined) {
const pct = Math.round((bonus.combatMultiplier - 1) * 100);
parts.push(`+${String(pct)}% Combat Power`);
}
if (bonus.clickMultiplier !== undefined) {
const pct = Math.round((bonus.clickMultiplier - 1) * 100);
parts.push(`+${String(pct)}% Click Power`);
}
return parts.join(" ยท ");
};
/**
* Renders the character sheet panel for viewing and editing player profile.
* @returns The JSX element.
*/
const CharacterSheetPanel = (): JSX.Element => {
const { state, loginStreak } = useGame();
const player = state?.player;
const [ sheet, setSheet ] = useState<CharacterSheetData>(emptySheet);
const [ draft, setDraft ] = useState<CharacterSheetData>(emptySheet);
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 [ copied, setCopied ] = useState(false);
const savedSettingsReference = useRef<ProfileSettings>({
...DEFAULT_PROFILE_SETTINGS,
});
useEffect(() => {
if (player?.discordId === undefined || player.discordId === "") {
return;
}
fetch(`/api/profile/${player.discordId}`).
then(async(response) => {
if (!response.ok) {
return;
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- API response cast
const data = (await response.json()) as {
characterName: string;
pronouns: string;
characterRace: string;
characterClass: string;
bio: string;
guildName: string;
guildDescription: string;
profileSettings: ProfileSettings;
activeTitle: string;
unlockedTitles: Array<{ id: string; name: string }>;
equippedItems: Array<EquippedItem>;
};
const loaded: CharacterSheetData = {
activeTitle: data.activeTitle,
bio: data.bio,
characterClass: data.characterClass,
characterName: data.characterName,
characterRace: data.characterRace,
equippedItems: data.equippedItems,
guildDescription: data.guildDescription,
guildName: data.guildName,
pronouns: data.pronouns,
unlockedTitles: data.unlockedTitles,
};
setSheet(loaded);
setDraft(loaded);
savedSettingsReference.current = {
...DEFAULT_PROFILE_SETTINGS,
...data.profileSettings,
};
}).
catch(() => {
/* Fall back to empty */
}).
finally(() => {
setLoading(false);
});
}, [ player?.discordId ]);
function handleEdit(): void {
setDraft({ ...sheet });
setEditing(true);
setError(null);
setSaved(false);
}
function handleCancel(): void {
setEditing(false);
setError(null);
}
async function handleSave(): Promise<void> {
setSaving(true);
setError(null);
try {
const characterName
= draft.characterName === ""
? player?.characterName ?? ""
: draft.characterName;
await updateProfile({
activeTitle: draft.activeTitle,
bio: draft.bio,
characterClass: draft.characterClass,
characterName: characterName,
characterRace: draft.characterRace,
guildDescription: draft.guildDescription,
guildName: draft.guildName,
profileSettings: savedSettingsReference.current,
pronouns: draft.pronouns,
});
setSheet({ ...draft });
setSaved(true);
setTimeout(() => {
setEditing(false);
setSaved(false);
}, 900);
} catch (error_) {
setError(error_ instanceof Error
? error_.message
: "Failed to save");
} finally {
setSaving(false);
}
}
function handleSaveClick(): void {
void handleSave();
}
function handleShareClick(): void {
const discordId = player?.discordId ?? "";
const url = `${window.location.origin}/character/${discordId}`;
void navigator.clipboard.writeText(url).
then(() => {
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 2000);
}).
catch((error_: unknown) => {
logError("clipboard_copy", error_);
});
}
function handleNameChange(event: ChangeEvent<HTMLInputElement>): void {
const { value } = event.target;
setDraft((current) => {
return { ...current, characterName: value };
});
}
function handlePronounsChange(event: ChangeEvent<HTMLInputElement>): void {
const { value } = event.target;
setDraft((current) => {
return { ...current, pronouns: value };
});
}
function handleRaceChange(event: ChangeEvent<HTMLInputElement>): void {
const { value } = event.target;
setDraft((current) => {
return { ...current, characterRace: value };
});
}
function handleClassChange(event: ChangeEvent<HTMLInputElement>): void {
const { value } = event.target;
setDraft((current) => {
return { ...current, characterClass: value };
});
}
function handleBioChange(event: ChangeEvent<HTMLTextAreaElement>): void {
const { value } = event.target;
setDraft((current) => {
return { ...current, bio: value };
});
}
function handleTitleChange(event: ChangeEvent<HTMLSelectElement>): void {
const { value } = event.target;
setDraft((current) => {
return { ...current, activeTitle: value };
});
}
function handleGuildNameChange(event: ChangeEvent<HTMLInputElement>): void {
const { value } = event.target;
setDraft((current) => {
return { ...current, guildName: value };
});
}
function handleGuildDescChange(
event: ChangeEvent<HTMLTextAreaElement>,
): void {
const { value } = event.target;
setDraft((current) => {
return { ...current, guildDescription: value };
});
}
if (loading) {
return (
<section className="panel">
<p>{"Loading character sheetโ€ฆ"}</p>
</section>
);
}
if (editing) {
const isSaveDisabled = saving || draft.characterName.trim() === "";
let saveLabel = "Save";
if (saving) {
saveLabel = "Savingโ€ฆ";
}
if (saved) {
saveLabel = "โœ“ Saved!";
}
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}
onChange={handleNameChange}
placeholder="Your character's name"
type="text"
value={draft.characterName}
/>
<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}
onChange={handlePronounsChange}
placeholder="e.g. she/her, he/him, they/them"
type="text"
value={draft.pronouns}
/>
<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}
onChange={handleRaceChange}
placeholder="e.g. Elf, Dwarf, Human, Tieflingโ€ฆ"
type="text"
value={draft.characterRace}
/>
<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}
onChange={handleClassChange}
placeholder="e.g. Paladin, Archmage, Shadow Rogueโ€ฆ"
type="text"
value={draft.characterClass}
/>
<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"
id="cs-bio"
maxLength={200}
onChange={handleBioChange}
placeholder={
"Describe your character's story, personality, or appearanceโ€ฆ"
}
rows={4}
value={draft.bio}
/>
<span className="character-sheet-hint">
{draft.bio.length}
{" / 200"}
</span>
{draft.unlockedTitles.length > 0
&& <>
<label className="character-sheet-label" htmlFor="cs-title">
{"Active Title"}
</label>
<select
className="character-sheet-input"
id="cs-title"
onChange={handleTitleChange}
value={draft.activeTitle}
>
<option value="">{"โ€” None โ€”"}</option>
{draft.unlockedTitles.map((title) => {
return (
<option key={title.id} value={title.id}>
{title.name}
</option>
);
})}
</select>
</>
}
</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}
onChange={handleGuildNameChange}
placeholder="Name your guild"
type="text"
value={draft.guildName}
/>
<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}
onChange={handleGuildDescChange}
placeholder="Describe your guild's history, goals, or loreโ€ฆ"
rows={6}
value={draft.guildDescription}
/>
<span className="character-sheet-hint">
{draft.guildDescription.length}
{" / 500"}
</span>
</div>
{error === null
? null
: <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={isSaveDisabled}
onClick={handleSaveClick}
type="button"
>
{saveLabel}
</button>
</div>
</div>
</section>
);
}
const subtitleParts = [ sheet.characterRace, sheet.characterClass ].filter(
(part) => {
return part !== "";
},
);
const subtitle = subtitleParts.join(" ยท ");
const completedChapters = state?.story?.completedChapters ?? [];
return (
<section className="panel character-sheet-panel">
<div className="panel-header">
<h2>{"๐Ÿ“‹ Character Sheet"}</h2>
<div className="character-sheet-header-actions">
<button
className="character-sheet-edit-btn"
onClick={handleShareClick}
type="button"
>
{copied
? "โœ“ Copied!"
: "๐Ÿ”— Share"}
</button>
<a className="character-sheet-edit-btn" href="/leaderboards">
{"๐Ÿ† Boards"}
</a>
<button
className="character-sheet-edit-btn"
onClick={handleEdit}
type="button"
>
{"โœ๏ธ Edit"}
</button>
</div>
</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>
: sheet.characterName
}
</span>
</div>
<div className="character-sheet-field">
<span className="character-sheet-field-label">{"Streak"}</span>
<span className="character-sheet-streak">
{"๐Ÿ”ฅ "}
{loginStreak}
{"-day login streak"}
</span>
</div>
{sheet.activeTitle === ""
? null
: <div className="character-sheet-field">
<span className="character-sheet-field-label">{"Title"}</span>
<span
className={"character-sheet-field-value character-sheet-title"}
>
{sheet.unlockedTitles.find((title) => {
return title.id === sheet.activeTitle;
})?.name ?? sheet.activeTitle}
</span>
</div>
}
{sheet.pronouns === ""
? null
: <div className="character-sheet-field">
<span className="character-sheet-field-label">{"Pronouns"}</span>
<span className="character-sheet-field-value">
{sheet.pronouns}
</span>
</div>
}
{subtitle === ""
? null
: <div className="character-sheet-field">
<span className="character-sheet-field-label">{"Identity"}</span>
<span className="character-sheet-field-value">{subtitle}</span>
</div>
}
{sheet.bio === ""
? null
: <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">{"๐Ÿ—ก๏ธ Equipment"}</h3>
{sheet.equippedItems.length > 0
? <div className="character-sheet-equipment-list">
{sheet.equippedItems.map((item) => {
return (
<div
className="character-sheet-equipment-item"
key={item.type}
>
<div className="character-sheet-equipment-header">
<span className="character-sheet-equipment-slot">
{slotIcons[item.type]}
</span>
<span
className={
"character-sheet-equipment-name"
+ ` character-sheet-rarity--${item.rarity}`
}
>
{item.name}
</span>
<span
className={
"character-sheet-equipment-rarity"
+ ` character-sheet-rarity--${item.rarity}`
}
>
{item.rarity}
</span>
</div>
<p className="character-sheet-equipment-bonus">
{formatBonus(item.bonus)}
</p>
</div>
);
})}
</div>
: <p className="character-sheet-empty">
{"No equipment found. Defeat bosses to earn gear!"}
</p>
}
</div>
<div className="character-sheet-section">
<h3 className="character-sheet-section-title">{"๐Ÿฐ Guild"}</h3>
{sheet.guildName === ""
? <p className="character-sheet-empty">
{"No guild registered yet. Click โœ๏ธ Edit to add one!"}
</p>
: <>
<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 === ""
? null
: <div className="character-sheet-bio">
<span className="character-sheet-field-label">{"Lore"}</span>
<p className="character-sheet-bio-text">
{sheet.guildDescription}
</p>
</div>
}
</>
}
</div>
{completedChapters.length === 0
? null
: <div className="character-sheet-section">
<h3 className="character-sheet-section-title">
{"๐Ÿ“– Story Choices"}
</h3>
{completedChapters.map((completion) => {
const chapter = STORY_CHAPTERS.find((candidate) => {
return candidate.id === completion.chapterId;
});
if (chapter === undefined) {
return null;
}
const choice = chapter.choices.find((candidate) => {
return candidate.id === completion.choiceId;
});
if (choice === undefined) {
return null;
}
const characterName
= player?.characterName === ""
|| player?.characterName === undefined
? "the guild leader"
: player.characterName;
const outcome = choice.outcome.replaceAll(
"{characterName}",
characterName,
);
return (
<div
className="character-sheet-story-entry"
key={completion.chapterId}
>
<span className="character-sheet-story-chapter">
{chapter.title}
</span>
<span className="character-sheet-story-choice">
{choice.label}
</span>
<p className="character-sheet-story-outcome">{outcome}</p>
</div>
);
})}
</div>
}
</div>
</section>
);
};
export { CharacterSheetPanel };