Files
elysium/apps/web/src/components/game/leaderboardPage.tsx
T
hikari a8a465f293
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m4s
CI / Lint, Build & Test (push) Successful in 1m14s
feat: display leaderboard update frequency in the UI (#78)
## Summary

Adds a note below the leaderboard subtitle informing players that rankings update when they prestige. This addresses a recurring community question from `tau.deusmortis` and `minjo70`.

Closes #63

✨ This PR was created with help from Hikari~ 🌸

Reviewed-on: #78
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-19 15:44:14 -07:00

277 lines
7.9 KiB
TypeScript

/**
* @file Leaderboard page component showing top players across categories.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
/* eslint-disable complexity -- Many conditional render paths for categories and entries */
import { useEffect, useState, type JSX } from "react";
import type { LeaderboardCategory, LeaderboardEntry } from "@elysium/types";
interface CategoryConfig {
id: LeaderboardCategory;
label: string;
icon: string;
formatValue: (value: number)=> string;
}
const goldSuffixes = [
"",
"K",
"M",
"B",
"T",
"Qa",
"Qt",
"S",
"Sp",
"O",
"N",
"D",
];
/**
* Formats a gold value with a short suffix.
* @param value - The gold amount to format.
* @returns The formatted string.
*/
const formatGold = (value: number): string => {
if (value === 0) {
return "0";
}
const tier = Math.floor(Math.log10(Math.abs(value)) / 3);
const clamped = Math.min(tier, goldSuffixes.length - 1);
const scaled = value / Math.pow(1000, clamped);
return `${String(Number.parseFloat(scaled.toFixed(2)))}${goldSuffixes[clamped] ?? ""}`;
};
const categories: Array<CategoryConfig> = [
{
formatValue: (v): string => {
return formatGold(v);
},
icon: "πŸͺ™",
id: "totalGold",
label: "Lifetime Gold",
},
{
formatValue: (v): string => {
return v.toLocaleString();
},
icon: "πŸ’€",
id: "bossesDefeated",
label: "Bosses Defeated",
},
{
formatValue: (v): string => {
return v.toLocaleString();
},
icon: "πŸ“œ",
id: "questsCompleted",
label: "Quests Completed",
},
{
formatValue: (v): string => {
return v.toLocaleString();
},
icon: "πŸ†",
id: "achievementsUnlocked",
label: "Achievements",
},
{
formatValue: (v): string => {
return v.toLocaleString();
},
icon: "⭐",
id: "prestigeCount",
label: "Prestige",
},
{
formatValue: (v): string => {
return v.toLocaleString();
},
icon: "🌌",
id: "transcendenceCount",
label: "Transcendence",
},
{
formatValue: (v): string => {
return v.toLocaleString();
},
icon: "✨",
id: "apotheosisCount",
label: "Apotheosis",
},
];
const rankBadges: Record<number, string> = { 1: "πŸ₯‡", 2: "πŸ₯ˆ", 3: "πŸ₯‰" };
/**
* Renders the leaderboard page with category tabs and player rankings.
* @returns The JSX element.
*/
const LeaderboardPage = (): JSX.Element => {
const [ category, setCategory ] = useState<LeaderboardCategory>("totalGold");
const [ entries, setEntries ] = useState<Array<LeaderboardEntry>>([]);
const [ loading, setLoading ] = useState(true);
const [ error, setError ] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
setError(null);
fetch(`/api/leaderboards?category=${category}&limit=100`).
then(async(response) => {
if (!response.ok) {
throw new Error("Failed to load leaderboard");
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- API response cast
const data = (await response.json()) as {
entries: Array<LeaderboardEntry>;
};
setEntries(data.entries);
}).
catch((error_: unknown) => {
setError(
error_ instanceof Error
? error_.message
: "Failed to load leaderboard",
);
}).
finally(() => {
setLoading(false);
});
}, [ category ]);
const currentConfig
= categories.find((cat) => {
return cat.id === category;
}) ?? categories[0];
return (
<div className="leaderboard-page">
<div className="leaderboard-card">
<div className="leaderboard-header">
<h1 className="leaderboard-title">{"πŸ† Leaderboards"}</h1>
<p className="leaderboard-subtitle">
{"The mightiest adventurers in Elysium"}
</p>
<p className="leaderboard-update-note">
{"πŸ”„ Rankings update when you prestige."}
</p>
</div>
<div className="leaderboard-tabs">
{categories.map((cat) => {
function handleCategoryClick(): void {
setCategory(cat.id);
}
return (
<button
className={`leaderboard-tab ${
category === cat.id
? "leaderboard-tab--active"
: ""
}`}
key={cat.id}
onClick={handleCategoryClick}
type="button"
>
{cat.icon} {cat.label}
</button>
);
})}
</div>
{loading
? <div className="leaderboard-loading">{"Loading…"}</div>
: null}
{error === null
? null
: <div className="leaderboard-error">
{"⚠️ "}
{error}
</div>
}
{!loading && error === null && entries.length === 0
&& <div className="leaderboard-empty">
{"No entries yet β€” be the first on the board!"}
</div>
}
{!loading && error === null && entries.length > 0
&& <div className="leaderboard-table">
<div className="leaderboard-table-header">
<span className="leaderboard-col-rank">{"Rank"}</span>
<span className="leaderboard-col-player">{"Player"}</span>
<span className="leaderboard-col-value">
{currentConfig?.icon} {currentConfig?.label}
</span>
</div>
{entries.map((entry) => {
const avatarUrl
= entry.avatar === null
? `https://cdn.discordapp.com/embed/avatars/${String(Number.parseInt(entry.discordId, 10) % 5)}.png`
: `https://cdn.discordapp.com/avatars/${entry.discordId}/${entry.avatar}.png?size=32`;
const displayName
= entry.characterName === ""
? entry.username
: entry.characterName;
return (
<a
className={`leaderboard-row ${
entry.rank <= 3
? `leaderboard-row--top${String(entry.rank)}`
: ""
}`}
href={`/character/${entry.discordId}`}
key={entry.discordId}
>
<span className="leaderboard-col-rank">
{rankBadges[entry.rank] ?? `#${String(entry.rank)}`}
</span>
<span className="leaderboard-col-player">
<img
alt={displayName}
className="leaderboard-avatar"
src={avatarUrl}
/>
<span className="leaderboard-player-info">
<span className="leaderboard-player-name">
{displayName}
</span>
{entry.activeTitle === ""
? null
: <span className="leaderboard-player-title">
{entry.activeTitle}
</span>
}
</span>
</span>
<span className="leaderboard-col-value">
{currentConfig?.formatValue(entry.value)}
</span>
</a>
);
})}
</div>
}
<div className="leaderboard-footer">
<a className="leaderboard-play-link" href="/">
{"βš”οΈ Play Elysium"}
</a>
<p className="leaderboard-privacy-note">
{"Players can opt out via their profile settings."}
</p>
</div>
</div>
</div>
);
};
export { LeaderboardPage };