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>
|
||||
);
|
||||
}
|
||||
@@ -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
56
components/PixelArt.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
31
components/SiteNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user