Add player leaderboard and homepage preview
This commit is contained in:
203
components/Leaderboard.tsx
Normal file
203
components/Leaderboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user