Files
elysium/apps/web/src/components/game/characterPage.tsx
T
hikari 404b31bd13
CI / Lint, Build & Test (push) Successful in 1m10s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m11s
fix: persist UI preferences across navigation and sessions (#48)
## Summary

- **#35** — Adventure multiplier selection is now persisted in `localStorage` (`"elysium_batch_size"`). The chosen batch size is restored automatically on the next visit, with a graceful fallback to `1` for missing or unrecognisable values.
- **#36** — Zone selection in the boss panel and quest panel is now persisted in `sessionStorage` (`"elysium_boss_zone"` / `"elysium_quest_zone"`). The selected zone survives navigation within a session and resets cleanly when the session ends, defaulting to Verdant Vale if no stored value exists.

## Test plan

- [x] Lint — zero errors, zero warnings
- [x] Build — all packages build cleanly
- [x] Tests — 415 tests passing, 100% coverage across all packages
- [ ] Manual: select a non-default batch size, refresh the page — multiplier should be restored
- [ ] Manual: switch to a non-default zone in the boss panel, navigate away and back — zone should still be selected
- [ ] Manual: repeat for the quest panel
- [ ] Manual: log out and back in — zone selection should reset to Verdant Vale

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #48
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-09 22:17:12 -07:00

352 lines
11 KiB
TypeScript

/**
* @file Public character page for viewing a player's character sheet.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
/* eslint-disable max-lines -- Story section adds lines beyond the file limit */
/* eslint-disable complexity -- Many conditional render paths for optional fields */
import {
STORY_CHAPTERS,
type EquipmentBonus,
type EquipmentType,
type PublicProfileResponse,
} from "@elysium/types";
import { type JSX, useEffect, useState } from "react";
import { logError } from "../../utils/logError.js";
interface CharacterPageProperties {
readonly discordId: string;
}
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 public character page for a given Discord user.
* @param props - The character page properties.
* @param props.discordId - The Discord ID of the player to display.
* @returns The JSX element.
*/
const CharacterPage = ({ discordId }: CharacterPageProperties): 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(response) => {
if (!response.ok) {
throw new Error("Player not found");
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- API response requires cast
return await (response.json() as Promise<PublicProfileResponse>);
}).
then(setProfile).
catch((error_: unknown) => {
setError(
error_ instanceof Error
? error_.message
: "Failed to load character sheet",
);
});
}, [ discordId ]);
function handleCopy(): void {
void navigator.clipboard.writeText(window.location.href).
then(() => {
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 2000);
}).
catch((error_: unknown) => {
logError("clipboard_copy", error_);
});
}
if (error !== null) {
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 === null) {
return (
<div className="character-page">
<div className="character-page-loading">
{"Loading character sheet…"}
</div>
</div>
);
}
const discordIndex = Number.parseInt(discordId, 10) % 5;
const avatarUrl
= profile.avatar === null
? `https://cdn.discordapp.com/embed/avatars/${String(discordIndex)}.png`
: `https://cdn.discordapp.com/avatars/${discordId}/${profile.avatar}.png?size=128`;
const subtitleParts = [
profile.characterRace,
profile.characterClass,
].filter((part) => {
return part !== "";
});
const subtitle = subtitleParts.join(" · ");
const activeTitleEntry
= profile.activeTitle === ""
? undefined
: profile.unlockedTitles.find((title) => {
return title.id === profile.activeTitle;
});
const activeTitleName
= activeTitleEntry === undefined
? null
: activeTitleEntry.name;
const hasBadge
= profile.apotheosisCount > 0
|| profile.transcendenceCount > 0
|| profile.prestigeCount > 0;
const displayName
= profile.characterName === ""
? profile.username
: profile.characterName;
return (
<div className="character-page">
<div className="character-page-card">
<div className="character-page-header">
<img
alt={`${displayName}'s avatar`}
className="character-page-avatar"
src={avatarUrl}
/>
<div className="character-page-identity">
<h1 className="character-page-name">{displayName}</h1>
{activeTitleName === null
? null
: <p className="character-page-title">{activeTitleName}</p>
}
{profile.pronouns === ""
? null
: <p className="character-page-pronouns">{profile.pronouns}</p>
}
{subtitle === ""
? null
: <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>
: null}
</div>
</div>
{profile.bio === ""
? null
: <div className="character-page-section">
<h2 className="character-page-section-title">{"⚔️ About"}</h2>
<p className="character-page-bio">{profile.bio}</p>
</div>
}
{profile.guildName === ""
? null
: <div className="character-page-section">
<h2 className="character-page-section-title">{"🏰 Guild"}</h2>
<p className="character-page-guild-name">{profile.guildName}</p>
{profile.guildDescription === ""
? null
: <p className="character-page-guild-desc">
{profile.guildDescription}
</p>
}
</div>
}
{profile.equippedItems.length > 0
&& <div className="character-page-section">
<h2 className="character-page-section-title">{"🗡️ Equipment"}</h2>
<div className="character-page-equipment-list">
{profile.equippedItems.map((item) => {
return (
<div
className="character-page-equipment-item"
key={item.name}
>
<div className="character-page-equipment-header">
<span className="character-page-equipment-slot">
{slotIcons[item.type]}
</span>
<span
className={
"character-page-equipment-name"
+ ` character-sheet-rarity--${item.rarity}`
}
>
{item.name}
</span>
<span
className={
"character-page-equipment-rarity"
+ ` character-sheet-rarity--${item.rarity}`
}
>
{item.rarity}
</span>
</div>
<p className="character-page-equipment-bonus">
{formatBonus(item.bonus)}
</p>
</div>
);
})}
</div>
</div>
}
{profile.completedChapters.length === 0
? null
: <div className="character-page-section">
<h2 className="character-page-section-title">{"📖 Story"}</h2>
{profile.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;
}
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">
{choice.description}
</p>
</div>
);
})}
</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>
);
};
export { CharacterPage };