218 lines
7.7 KiB
TypeScript
218 lines
7.7 KiB
TypeScript
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 />
|
||
</>
|
||
);
|
||
}
|