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

217
app/leaderboard/page.tsx Normal file
View File

@@ -0,0 +1,217 @@
import type { Metadata } from "next";
import Link from "next/link";
import { ArrowLeft, ArrowRight, Search } from "lucide-react";
import { Header, Footer } from "@/components/Shell";
import {
LeaderboardEmpty,
LeaderboardTable,
WinnersPodium,
} from "@/components/Leaderboard";
import { PixelTrophy } from "@/components/PixelArt";
import { leaderboard } from "@/lib/leaderboard";
import { user } from "@/lib/supabase";
import {
formatScore,
leaderboardDifficulties,
leaderboardFilters,
leaderboardHref,
} from "@/shared/leaderboard";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Leaderboard",
description:
"Meet MineGeo’s top explorers. Compare the best five-round scores across easy, normal, and hard Minecraft expeditions.",
};
export default async function LeaderboardPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { difficulty, search, page } = leaderboardFilters(await searchParams);
const [data, account] = await Promise.all([
leaderboard({ difficulty, search, page }),
user(),
]);
return (
<>
<Header />
<main className="section page-content leaderboard-page" id="main-content">
<Link href="/" className="text-link leaderboard-back">
<ArrowLeft size={14} /> Back to home
</Link>
<header className="leaderboard-intro">
<div>
<span className="eyebrow">THE EXPLORERS’ BOARD</span>
<h1>Every block counts.</h1>
<p className="lead">
The sharpest eyes in the Overworld.
<br />
Five rounds. 25,000 possible points. Where do you stand?
</p>
</div>
<PixelTrophy className="intro-trophy" />
</header>
<div className="leaderboard-toolbar">
<nav
className="leaderboard-filters"
aria-label="Filter leaderboard by difficulty"
>
{leaderboardDifficulties.map((value) => (
<Link
key={value}
href={leaderboardHref(value, search)}
aria-current={difficulty === value ? "page" : undefined}
>
{value === "all"
? "All difficulties"
: value[0].toUpperCase() + value.slice(1)}
</Link>
))}
</nav>
<span className="leaderboard-period">All time</span>
</div>
{data ? (
<>
{data.leaders.length > 0 && !search && data.page === 1 && (
<WinnersPodium leaders={data.leaders} />
)}
<section
className="rankings-board"
aria-labelledby="rankings-heading"
>
<div className="rankings-heading">
<div>
<h2 id="rankings-heading">The rankings</h2>
<p>
{formatScore(data.total_players)}{" "}
{data.total_players === 1 ? "explorer" : "explorers"} ·{" "}
{formatScore(data.total_games)} completed{" "}
{data.total_games === 1 ? "expedition" : "expeditions"}
</p>
</div>
<form
action="/leaderboard"
className="leaderboard-search"
role="search"
>
{difficulty !== "all" && (
<input type="hidden" name="difficulty" value={difficulty} />
)}
<label className="sr-only" htmlFor="player-search">
Search players
</label>
<input
key={`${difficulty}-${search}`}
id="player-search"
type="search"
name="q"
defaultValue={search}
placeholder="Find an explorer…"
maxLength={64}
/>
<button type="submit" aria-label="Search players">
<Search size={17} />
</button>
</form>
</div>
{search && (
<div className="search-summary">
<span>
{formatScore(data.matching_players)}{" "}
{data.matching_players === 1 ? "result" : "results"} for “
{search}”
</span>
<Link href={leaderboardHref(difficulty)}>Clear search ×</Link>
</div>
)}
{data.entries.length ? (
<LeaderboardTable
entries={data.entries}
playerId={account?.id}
/>
) : (
<LeaderboardEmpty search={!!search} />
)}
{data.matching_players > 0 && (
<div className="leaderboard-pagination">
<span>
Showing {formatScore((data.page - 1) * 25 + 1)}–
{formatScore(
Math.min(data.page * 25, data.matching_players),
)}{" "}
of {formatScore(data.matching_players)}
</span>
<nav aria-label="Leaderboard pages">
{data.page > 1 ? (
<Link
aria-label="Previous page"
href={leaderboardHref(
difficulty,
search,
data.page - 1,
)}
>
<ArrowLeft size={15} /> Previous
</Link>
) : (
<span aria-disabled="true">
<ArrowLeft size={15} /> Previous
</span>
)}
<span className="page-number">
{data.page} / {data.pages}
</span>
{data.page < data.pages ? (
<Link
aria-label="Next page"
href={leaderboardHref(
difficulty,
search,
data.page + 1,
)}
>
Next <ArrowRight size={15} />
</Link>
) : (
<span aria-disabled="true">
Next <ArrowRight size={15} />
</span>
)}
</nav>
</div>
)}
</section>
</>
) : (
<LeaderboardEmpty unavailable />
)}
<aside className="leaderboard-rules">
<div>
<span className="eyebrow">HOW THE BOARD WORKS</span>
<h2>A place earned, one guess at a time.</h2>
</div>
<div>
<p>
Your best completed expedition sets your rank. Ties are broken by
average score, then completed games. Difficulty filters rank only
games in that mode.
</p>
<p>
Log in before you play. All five rounds must be finished to count;
guest games stay off the board.
</p>
<Link href={account ? "/servers" : "/log-in"} className="text-link">
{account
? "Start your next expedition"
: "Log in to join the board"}{" "}
<ArrowRight size={15} />
</Link>
</div>
</aside>
</main>
<Footer />
</>
);
}