generated from nhcarrigan/template
404b31bd13
## 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>
352 lines
11 KiB
TypeScript
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 };
|