generated from nhcarrigan/template
feat: add public leaderboards with opt-out privacy setting
This commit is contained in:
@@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
import { GameProvider } from "./context/GameContext.js";
|
||||
import { CharacterPage } from "./components/game/CharacterPage.js";
|
||||
import { GameLayout } from "./components/game/GameLayout.js";
|
||||
import { LeaderboardPage } from "./components/game/LeaderboardPage.js";
|
||||
import { LoginPage } from "./components/game/LoginPage.js";
|
||||
import { ProfilePage } from "./components/game/ProfilePage.js";
|
||||
|
||||
@@ -49,6 +50,10 @@ export const App = (): React.JSX.Element => {
|
||||
return <CharacterPage discordId={characterDiscordId} />;
|
||||
}
|
||||
|
||||
if (window.location.pathname === "/leaderboards") {
|
||||
return <LeaderboardPage />;
|
||||
}
|
||||
|
||||
if (!loggedIn) {
|
||||
return <LoginPage onLogin={() => { setLoggedIn(true); }} />;
|
||||
}
|
||||
|
||||
@@ -75,6 +75,10 @@ const HOW_TO_PLAY = [
|
||||
title: "🗡️ Equipment",
|
||||
body: "Defeat bosses to earn equipment drops: weapons, armour, and trinkets. Each item provides bonuses to gold income, combat power, or click power. Only one item per slot can be equipped at a time — visit the Equipment panel to manage your loadout. Your currently equipped items are displayed on your character sheet and public profile.",
|
||||
},
|
||||
{
|
||||
title: "🏆 Leaderboards",
|
||||
body: "Compete with other adventurers on the public Leaderboards page! Categories include Lifetime Gold, Bosses Defeated, Quests Completed, Achievements, Prestige Count, Transcendence Count, and Apotheosis Count. Click any player's row to view their character sheet. You can opt out of appearing on leaderboards via the Privacy section in your profile settings.",
|
||||
},
|
||||
{
|
||||
title: "☁️ Cloud Saves",
|
||||
body: "Your progress is automatically saved to the cloud every 30 seconds whilst you play. You can also force a manual save at any time using the sync button in the resource bar. Your save is protected by HMAC validation to ensure data integrity.",
|
||||
|
||||
@@ -310,6 +310,9 @@ export const CharacterSheetPanel = (): React.JSX.Element => {
|
||||
>
|
||||
{copied ? "✓ Copied!" : "🔗 Share"}
|
||||
</button>
|
||||
<a className="character-sheet-edit-btn" href="/leaderboards">
|
||||
🏆 Boards
|
||||
</a>
|
||||
<button className="character-sheet-edit-btn" onClick={handleEdit} type="button">
|
||||
✏️ Edit
|
||||
</button>
|
||||
|
||||
@@ -179,6 +179,21 @@ export const EditProfileModal = ({ onClose }: EditProfileModalProps): React.JSX.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="edit-profile-section">
|
||||
<p className="edit-profile-label">Privacy</p>
|
||||
<p className="edit-profile-sublabel">Control your visibility on public leaderboards.</p>
|
||||
<button
|
||||
className={`stat-toggle-btn ${settings.showOnLeaderboards ? "stat-toggle-on" : "stat-toggle-off"}`}
|
||||
onClick={() => { toggleSetting("showOnLeaderboards"); }}
|
||||
type="button"
|
||||
>
|
||||
<span>🏆 Appear on Leaderboards</span>
|
||||
<span className="stat-toggle-indicator">
|
||||
{settings.showOnLeaderboards ? "✓ Shown" : "Hidden"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="edit-profile-section">
|
||||
<p className="edit-profile-label">Number Format</p>
|
||||
<p className="edit-profile-sublabel">How large numbers appear across the game.</p>
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { LeaderboardCategory, LeaderboardEntry } from "@elysium/types";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface CategoryConfig {
|
||||
id: LeaderboardCategory;
|
||||
label: string;
|
||||
icon: string;
|
||||
formatValue: (value: number) => string;
|
||||
}
|
||||
|
||||
const CATEGORIES: CategoryConfig[] = [
|
||||
{
|
||||
id: "totalGold",
|
||||
label: "Lifetime Gold",
|
||||
icon: "🪙",
|
||||
formatValue: (v) => formatGold(v),
|
||||
},
|
||||
{
|
||||
id: "bossesDefeated",
|
||||
label: "Bosses Defeated",
|
||||
icon: "💀",
|
||||
formatValue: (v) => v.toLocaleString(),
|
||||
},
|
||||
{
|
||||
id: "questsCompleted",
|
||||
label: "Quests Completed",
|
||||
icon: "📜",
|
||||
formatValue: (v) => v.toLocaleString(),
|
||||
},
|
||||
{
|
||||
id: "achievementsUnlocked",
|
||||
label: "Achievements",
|
||||
icon: "🏆",
|
||||
formatValue: (v) => v.toLocaleString(),
|
||||
},
|
||||
{
|
||||
id: "prestigeCount",
|
||||
label: "Prestige",
|
||||
icon: "⭐",
|
||||
formatValue: (v) => v.toLocaleString(),
|
||||
},
|
||||
{
|
||||
id: "transcendenceCount",
|
||||
label: "Transcendence",
|
||||
icon: "🌌",
|
||||
formatValue: (v) => v.toLocaleString(),
|
||||
},
|
||||
{
|
||||
id: "apotheosisCount",
|
||||
label: "Apotheosis",
|
||||
icon: "✨",
|
||||
formatValue: (v) => v.toLocaleString(),
|
||||
},
|
||||
];
|
||||
|
||||
const SUFFIXES = ["", "K", "M", "B", "T", "Qa", "Qt", "S", "Sp", "O", "N", "D"];
|
||||
|
||||
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, SUFFIXES.length - 1);
|
||||
const scaled = value / Math.pow(1000, clamped);
|
||||
return `${parseFloat(scaled.toFixed(2))}${SUFFIXES[clamped] ?? ""}`;
|
||||
};
|
||||
|
||||
const RANK_BADGES: Record<number, string> = { 1: "🥇", 2: "🥈", 3: "🥉" };
|
||||
|
||||
export const LeaderboardPage = (): React.JSX.Element => {
|
||||
const [category, setCategory] = useState<LeaderboardCategory>("totalGold");
|
||||
const [entries, setEntries] = useState<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 (res) => {
|
||||
if (!res.ok) throw new Error("Failed to load leaderboard");
|
||||
const data = (await res.json()) as { entries: LeaderboardEntry[] };
|
||||
setEntries(data.entries);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setError(err instanceof Error ? err.message : "Failed to load leaderboard");
|
||||
})
|
||||
.finally(() => { setLoading(false); });
|
||||
}, [category]);
|
||||
|
||||
const currentConfig = CATEGORIES.find((c) => c.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>
|
||||
</div>
|
||||
|
||||
<div className="leaderboard-tabs">
|
||||
{CATEGORIES.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
className={`leaderboard-tab ${category === cat.id ? "leaderboard-tab--active" : ""}`}
|
||||
onClick={() => { setCategory(cat.id); }}
|
||||
type="button"
|
||||
>
|
||||
{cat.icon} {cat.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="leaderboard-loading">Loading…</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="leaderboard-error">⚠️ {error}</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && entries.length === 0 && (
|
||||
<div className="leaderboard-empty">No entries yet — be the first on the board!</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && 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
|
||||
? `https://cdn.discordapp.com/avatars/${entry.discordId}/${entry.avatar}.png?size=32`
|
||||
: `https://cdn.discordapp.com/embed/avatars/${parseInt(entry.discordId, 10) % 5}.png`;
|
||||
const displayName = entry.characterName || entry.username;
|
||||
|
||||
return (
|
||||
<a
|
||||
className={`leaderboard-row ${entry.rank <= 3 ? `leaderboard-row--top${entry.rank}` : ""}`}
|
||||
href={`/character/${entry.discordId}`}
|
||||
key={entry.discordId}
|
||||
>
|
||||
<span className="leaderboard-col-rank">
|
||||
{RANK_BADGES[entry.rank] ?? `#${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 && (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -3564,3 +3564,204 @@ body {
|
||||
font-size: 0.78rem;
|
||||
margin: 0.2rem 0 0;
|
||||
}
|
||||
|
||||
/* ── Leaderboard Page ─────────────────────────────────────────────────────── */
|
||||
.leaderboard-page {
|
||||
align-items: flex-start;
|
||||
background: var(--colour-bg);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.leaderboard-card {
|
||||
background: var(--colour-bg-panel);
|
||||
border: 1px solid var(--colour-border);
|
||||
border-radius: var(--radius-lg);
|
||||
max-width: 800px;
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.leaderboard-header {
|
||||
margin-bottom: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.leaderboard-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
|
||||
.leaderboard-subtitle {
|
||||
color: var(--colour-text-muted);
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.leaderboard-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.leaderboard-tab {
|
||||
background: var(--colour-bg-alt);
|
||||
border: 1px solid var(--colour-border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--colour-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
padding: 0.4rem 0.8rem;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.leaderboard-tab:hover {
|
||||
border-color: var(--colour-accent);
|
||||
color: var(--colour-accent);
|
||||
}
|
||||
|
||||
.leaderboard-tab--active {
|
||||
background: var(--colour-accent);
|
||||
border-color: var(--colour-accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.leaderboard-loading,
|
||||
.leaderboard-error,
|
||||
.leaderboard-empty {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.leaderboard-error {
|
||||
color: var(--colour-danger, #ef4444);
|
||||
}
|
||||
|
||||
.leaderboard-empty {
|
||||
color: var(--colour-text-muted);
|
||||
}
|
||||
|
||||
.leaderboard-table {
|
||||
border: 1px solid var(--colour-border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.leaderboard-table-header {
|
||||
background: var(--colour-bg-alt);
|
||||
color: var(--colour-text-muted);
|
||||
display: grid;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
grid-template-columns: 3rem 1fr 8rem;
|
||||
padding: 0.6rem 1rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.leaderboard-row {
|
||||
align-items: center;
|
||||
border-top: 1px solid var(--colour-border);
|
||||
color: var(--colour-text);
|
||||
display: grid;
|
||||
grid-template-columns: 3rem 1fr 8rem;
|
||||
padding: 0.75rem 1rem;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.leaderboard-row:hover {
|
||||
background: var(--colour-bg-alt);
|
||||
}
|
||||
|
||||
.leaderboard-row--top1 {
|
||||
background: rgba(251, 191, 36, 0.08);
|
||||
}
|
||||
|
||||
.leaderboard-row--top2 {
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
|
||||
.leaderboard-row--top3 {
|
||||
background: rgba(180, 120, 70, 0.08);
|
||||
}
|
||||
|
||||
.leaderboard-col-rank {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.leaderboard-col-player {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.leaderboard-avatar {
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.leaderboard-player-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.leaderboard-player-name {
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.leaderboard-player-title {
|
||||
color: var(--colour-accent);
|
||||
font-size: 0.75rem;
|
||||
font-style: italic;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.leaderboard-col-value {
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.leaderboard-footer {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.leaderboard-play-link {
|
||||
background: var(--colour-accent);
|
||||
border-radius: var(--radius);
|
||||
color: #fff;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
padding: 0.6rem 1.5rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.leaderboard-play-link:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.leaderboard-privacy-note {
|
||||
color: var(--colour-text-muted);
|
||||
font-size: 0.75rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user