Add player leaderboard and homepage preview

This commit is contained in:
2026-10-06 15:21:49 +05:00
parent 2bbf5ae2c0
commit 949aa6627a
25 changed files with 2363 additions and 28 deletions

203
components/Leaderboard.tsx Normal file
View File

@@ -0,0 +1,203 @@
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { PixelTrophy, PlayerAvatar } from "./PixelArt";
import {
formatScore,
type LeaderboardData,
type LeaderboardEntry,
} from "@/shared/leaderboard";
export function LeaderboardEmpty({
unavailable = false,
search = false,
}: {
unavailable?: boolean;
search?: boolean;
}) {
return (
<div
className="leaderboard-empty"
role={unavailable ? "status" : undefined}
>
<PixelTrophy />
<h2>
{unavailable
? "The scoreboard is taking a break."
: search
? "No explorers found."
: "The first place is still open."}
</h2>
<p>
{unavailable
? "We couldn’t load the scores. Try again in a moment."
: search
? "Try another player name or clear your search."
: "Log in and finish five rounds to put your name on the board."}
</p>
{!search && (
<Link
href={unavailable ? "/leaderboard" : "/servers"}
className="button primary"
>
{unavailable ? "Reload scores" : "Play an expedition"}{" "}
<ArrowRight size={16} />
</Link>
)}
</div>
);
}
export function LeaderboardTable({
entries,
compact = false,
playerId,
}: {
entries: LeaderboardEntry[];
compact?: boolean;
playerId?: string;
}) {
return (
<div className={`leaderboard-table-wrap${compact ? " compact" : ""}`}>
<table className="leaderboard-table">
<caption className="sr-only">
Players ranked by best five-round score. Ties are broken by average
score, then games played.
</caption>
<thead>
<tr>
<th scope="col" className="rank-column">
Rank
</th>
<th scope="col">Explorer</th>
{!compact && (
<th scope="col" className="games-column">
Games
</th>
)}
{!compact && (
<th scope="col" className="average-column">
Average
</th>
)}
<th scope="col" className="score-column">
Best score
</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr
key={entry.player_id}
className={entry.player_id === playerId ? "your-row" : undefined}
>
<td className="rank-column">
<span
className={`rank-number${entry.rank <= 3 ? ` place-${entry.rank}` : ""}`}
>
{String(entry.rank).padStart(2, "0")}
</span>
</td>
<th scope="row">
<div className="leaderboard-player">
<PlayerAvatar id={entry.player_id} />
<span className="player-name">
{entry.display_name}
{entry.player_id === playerId && (
<span className="you-label">You</span>
)}
</span>
</div>
</th>
{!compact && (
<td className="games-column">
{formatScore(entry.games_played)}
</td>
)}
{!compact && (
<td className="average-column">
{formatScore(entry.average_score)}
</td>
)}
<td className="score-column">
<strong>{formatScore(entry.best_score)}</strong>
<span className="score-unit"> pts</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
export function WinnersPodium({ leaders }: { leaders: LeaderboardEntry[] }) {
return (
<section className="winners-podium" aria-label="Top three explorers">
{leaders.map((entry, index) => (
<article
className={`podium-place podium-${index + 1}`}
key={entry.player_id}
>
<div className="podium-player">
<span className="podium-label">
{index === 0 ? "TOP EXPLORER" : `PLACE 0${index + 1}`}
</span>
<PlayerAvatar id={entry.player_id} />
<h2>{entry.display_name}</h2>
<p>
{formatScore(entry.best_score)} <span>/ 25,000</span>
</p>
</div>
<div className="podium-block" aria-hidden="true">
<span>0{index + 1}</span>
{index === 0 && <PixelTrophy />}
</div>
</article>
))}
</section>
);
}
export function LeaderboardPreview({ data }: { data: LeaderboardData | null }) {
return (
<section
className="section home-leaderboard"
aria-labelledby="home-leaderboard-heading"
>
<div className="leaderboard-preview-copy">
<div className="leaderboard-kicker">
<PixelTrophy />
<span className="eyebrow">THE EXPLORERS’ BOARD</span>
</div>
<h2 id="home-leaderboard-heading">
Good eye.
<br /> <span>Great guess.</span>
</h2>
<p>
Five rounds to find your way.
<br /> One score to make your mark.
</p>
<Link className="text-link" href="/leaderboard">
See the full leaderboard <ArrowRight size={16} />
</Link>
</div>
<div className="leaderboard-preview-board">
<div className="board-heading">
<span>Top explorers</span>
<span>All difficulties · All time</span>
</div>
{data?.entries.length ? (
<LeaderboardTable entries={data.entries} compact />
) : (
<LeaderboardEmpty unavailable={!data} />
)}
<div className="board-footnote">
Best expedition score ·{" "}
{data
? `${formatScore(data.total_players)} ranked ${data.total_players === 1 ? "explorer" : "explorers"}`
: "Scores temporarily unavailable"}
</div>
</div>
</section>
);
}