204 lines
6.0 KiB
TypeScript
204 lines
6.0 KiB
TypeScript
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>
|
||
);
|
||
}
|