generated from nhcarrigan/template
a8a465f293
## 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>
277 lines
7.9 KiB
TypeScript
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 };
|