generated from nhcarrigan/template
a36c8e72a5
## 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>
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.type}
|
|
>
|
|
<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 };
|