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>
);
}

View File

@@ -12,18 +12,28 @@ export type CardMap = {
server?: { name: string } | undefined;
bounds: { minX: number; minZ: number; maxX: number; maxZ: number };
};
export function MapCard({ map, index = 0 }: { map: CardMap; index?: number }) {
export function MapCard({
map,
index = 0,
minecraftPaper = false,
}: {
map: CardMap;
index?: number;
minecraftPaper?: boolean;
}) {
return (
<Link className="map-card" href={`/servers/${map.id}`}>
<div className="card-image">
<Image
src={map.image || "/images/example-map.webp"}
alt={`${map.name} terrain`}
fill
loading={index < 3 ? "eager" : "lazy"}
unoptimized
sizes="(max-width: 640px) 100vw, 33vw"
/>
<div className={`card-image${minecraftPaper ? " minecraft-paper" : ""}`}>
<div className="card-map-content">
<Image
src={map.image || "/images/example-map.webp"}
alt={`${map.name} terrain`}
fill
loading={index < 3 ? "eager" : "lazy"}
unoptimized
sizes="(max-width: 640px) 100vw, 33vw"
/>
</div>
<span className="card-type">
{map.source === "plugin" ? "SERVER EXPORT" : "PLAYER CAPTURE"}
</span>

56
components/PixelArt.tsx Normal file
View File

@@ -0,0 +1,56 @@
export function PixelTrophy({ className = "" }: { className?: string }) {
return (
<svg
className={`pixel-trophy ${className}`}
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
shapeRendering="crispEdges"
>
<path
fill="#735523"
d="M2 5h4V3h12v2h4v7h-2v2h-4v2h-2v3h4v3H6v-3h4v-3H8v-2H4v-2H2z"
/>
<path
fill="#d6b665"
d="M6 3h12v9h-2v2h-2v2h-4v-2H8v-2H6zM10 16h4v3h-4zM6 19h12v2H6zM2 5h4v2H4v4h2v2H4v-2H2zM18 5h4v6h-2v2h-2v-2h2V7h-2z"
/>
<path fill="#f0d891" d="M6 3h12v2H8v6H6zM6 19h12v1H6z" />
<path fill="#a1803c" d="M16 5h2v7h-2zM14 12h2v2h-2zM12 16h2v3h-2z" />
</svg>
);
}
// Generated markers, not player skins. No third-party avatar requests.
export function PlayerAvatar({ id }: { id: string }) {
const seed = [...id].reduce(
(value, char) => (value * 31 + char.charCodeAt(0)) >>> 0,
0,
);
const hair = ["#654b39", "#333e44", "#95723e", "#694f59"][seed % 4];
const skin = ["#c99a77", "#a67b60", "#dab28d", "#875f4d"][(seed >>> 3) % 4];
const shirt = ["#68876a", "#697e91", "#a68a50", "#8b6e7f"][(seed >>> 5) % 4];
return (
<svg
className="player-avatar"
viewBox="0 0 8 8"
aria-hidden="true"
shapeRendering="crispEdges"
>
<path fill={shirt} d="M0 0h8v8H0z" />
<path fill={skin} d="M1 1h6v6H1z" />
<path
fill={hair}
d={
seed % 2
? "M1 0h6v2H3v1H1zM6 2h1v2H6z"
: "M1 0h6v2H1zM1 2h1v2H1zM6 2h1v2H6z"
}
/>
<path fill="#e6ded0" d="M2 3h1v1H2zM5 3h1v1H5z" />
<path fill="#3b4140" d="M3 3h1v1H3zM4 3h1v1H4z" />
<path fill="#825b49" d="M3 5h2v1H3z" />
<path fill={shirt} d="M0 7h8v1H0z" />
</svg>
);
}

View File

@@ -98,6 +98,9 @@ export function Results({ id }: { id: string }) {
<Link className="button" href="/dashboard">
My maps
</Link>
<Link className="text-link" href="/leaderboard">
View leaderboard →
</Link>
</div>
</div>
</div>

View File

@@ -3,17 +3,14 @@ import Link from "next/link";
import { Compass, ArrowUpRight, Globe2 } from "lucide-react";
import { user } from "@/lib/supabase";
import { Brand } from "./Brand";
import { SiteNav } from "./SiteNav";
export async function Header() {
const account = await user();
return (
<header className="site-header">
<div className="header-inner">
<Brand />
<nav aria-label="Main navigation">
<Link href="/servers">Play & maps</Link>
<Link href="/downloads">Downloads</Link>
<Link href="/about">Guide</Link>
</nav>
<SiteNav />
<Link
href={account ? "/dashboard" : "/log-in"}
className="header-account"
@@ -36,6 +33,7 @@ export function Footer() {
<Globe2 size={14} /> English
</span>
<Link href="/downloads">Tools & downloads</Link>
<Link href="/leaderboard">Leaderboard</Link>
<Link href="/moderation">Moderation</Link>
</div>
<p className="disclaimer">

31
components/SiteNav.tsx Normal file
View File

@@ -0,0 +1,31 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
const links = [
["/servers", "Play & maps"],
["/leaderboard", "Leaderboard"],
["/downloads", "Downloads"],
["/about", "Guide"],
] as const;
export function SiteNav() {
const pathname = usePathname();
return (
<nav aria-label="Main navigation">
{links.map(([href, label]) => (
<Link
key={href}
href={href}
aria-current={
pathname === href || pathname.startsWith(`${href}/`)
? "page"
: undefined
}
>
{label}
</Link>
))}
</nav>
);
}