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

69
app/home-map.css Normal file
View File

@@ -0,0 +1,69 @@
/* Vanilla 1.21.11 map_background.png, rendered with its original alpha edges. */
.card-map-content {
position: absolute;
inset: 0;
}
.home-page .minecraft-paper {
--map-paper-inset: 9.375%;
border: 0;
outline: 0;
clip-path: none;
background: url("/images/minecraft-map-background.png") center / 100% 100%
no-repeat;
box-shadow: none;
image-rendering: pixelated;
filter: drop-shadow(4px 6px 0 #04080c99);
}
.home-page .map-art-frame {
width: 320px;
height: 320px;
transform: none;
padding: 0;
}
.home-page .map-art-frame img {
position: absolute;
inset: var(--map-paper-inset);
width: 81.25%;
height: 81.25%;
display: block;
object-fit: contain;
image-rendering: pixelated;
}
.home-page .card-image .card-map-content {
inset: var(--map-paper-inset);
}
.home-page .minecraft-paper.card-image .card-map-content img {
object-fit: contain;
}
.home-page .map-card:hover .minecraft-paper img {
transform: none;
}
.home-page .minecraft-paper .card-type {
top: -16px;
left: 0;
}
.home-page .empty-map-art {
aspect-ratio: 1;
padding: 0;
transform: none;
}
.home-page .empty-map-art img {
position: absolute;
inset: var(--map-paper-inset);
width: 81.25%;
height: 81.25%;
object-fit: contain;
filter: none;
}
@media (max-width: 1000px) {
.home-page .map-art-frame {
width: 288px;
height: 288px;
}
}
@media (max-width: 700px) {
.home-page .map-art-frame {
width: 256px;
height: 256px;
}
}

View File

@@ -4,6 +4,9 @@ import "./block-theme.css";
import "./account.css";
import "./map-theme.css";
import "./workshop.css";
import "./leaderboard.css";
import "./home-map.css";
import "./rounded-theme.css";
export const metadata: Metadata = {
title: {
default: "MineGeo — Get lost in a familiar world",

1080
app/leaderboard.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,24 @@
export default function LoadingLeaderboard() {
return (
<main
className="section page-content leaderboard-page"
aria-busy="true"
aria-label="Loading leaderboard"
>
<span className="eyebrow">THE EXPLORERS’ BOARD</span>
<h1>Every block counts.</h1>
<p className="lead" role="status">
Fetching the explorers’ scores…
</p>
<div className="leaderboard-skeleton" aria-hidden="true">
{Array.from({ length: 6 }, (_, index) => (
<div key={index}>
<span />
<span />
<span />
</div>
))}
</div>
</main>
);
}

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

View File

@@ -11,20 +11,23 @@ import {
import { Header, Footer } from "@/components/Shell";
import { MapCard } from "@/components/MapCard";
import { catalog } from "@/lib/catalog";
import { leaderboard } from "@/lib/leaderboard";
import { LeaderboardPreview } from "@/components/Leaderboard";
export const dynamic = "force-dynamic";
export default async function Home() {
const maps = await catalog();
const [maps, rankings] = await Promise.all([
catalog(),
leaderboard({ limit: 5 }),
]);
const servers = new Set(maps.map((map) => map.server_id)).size;
const locations = maps.reduce((total, map) => total + map.panorama_count, 0);
return (
<>
<Header />
<main className="home-page">
<main className="home-page" id="main-content">
<section className="hero">
<div className="hero-copy">
<div className="status-label">
<span className="live-dot" /> MINECRAFT · EXPLORE · GUESS
</div>
<div className="status-label">A MINECRAFT GUESSING GAME</div>
<h1>
Guess where you are
<br />
@@ -40,10 +43,13 @@ export default async function Home() {
<Link href="/servers" className="button primary">
Start exploring <ArrowRight size={18} />
</Link>
<span className="hero-mode-note">3 difficulty levels</span>
<Link href="/leaderboard" className="text-link">
Meet the top explorers <ArrowRight size={14} />
</Link>
</div>
<div className="hero-notes">
<span className="mode-dots" aria-hidden="true">
<span className="round-blocks" aria-hidden="true">
<i />
<i />
<i />
<i />
@@ -53,10 +59,10 @@ export default async function Home() {
</div>
</div>
<div className="hero-art" aria-label="Minecraft surface map">
<div className="map-art-frame">
<div className="map-art-frame minecraft-paper">
<Image
src={maps[0]?.mapImage || "/images/example-map.webp"}
alt="A Minecraft surface map framed in parchment"
alt="A Minecraft surface map on the original Minecraft parchment texture"
width={1024}
height={1024}
loading="eager"
@@ -123,12 +129,12 @@ export default async function Home() {
{maps.length ? (
<div className="cards-grid">
{maps.slice(0, 4).map((map, index) => (
<MapCard map={map} index={index} key={map.id} />
<MapCard map={map} index={index} key={map.id} minecraftPaper />
))}
</div>
) : (
<div className="first-map-panel">
<div className="empty-map-art" aria-hidden="true">
<div className="empty-map-art minecraft-paper" aria-hidden="true">
<Image
src="/images/example-map.webp"
alt=""
@@ -155,6 +161,7 @@ export default async function Home() {
</div>
)}
</section>
<LeaderboardPreview data={rankings} />
<section className="section home-panels">
<article className="home-panel">
<span className="eyebrow">HOW TO PLAY</span>

272
app/rounded-theme.css Normal file
View File

@@ -0,0 +1,272 @@
/* Soft charcoal panels around the original pixel artwork and Minecraft maps. */
:root {
--bg: #080b10;
--panel: #1a222c;
--panel2: #222c37;
--line: #35404c;
--green: #8dce66;
--muted: #a1acb8;
--text: #eef1f4;
--board-stone: #1a222c;
--board-edge: #35404c;
--radius-panel: 16px;
--radius-control: 9px;
}
body {
background-image: radial-gradient(
ellipse at 80% 12%,
#25303d55,
transparent 55%
);
}
.site-header {
background: #0c1017;
border-bottom: 1px solid #2c3540;
box-shadow: none;
}
.hero {
grid-template-columns: 1.35fr 1fr;
padding-top: 52px;
padding-bottom: 52px;
}
.hero h1 {
font-size: clamp(48px, 4.4vw, 64px);
line-height: 1.05;
}
.hero h1 span {
color: var(--green);
}
.button {
border-radius: var(--radius-control);
background: #293542;
border: 1px solid #526071;
box-shadow:
inset 0 1px #ffffff14,
0 3px 8px #04070b55;
padding: 10px 20px;
}
.button.primary {
background: linear-gradient(#9ada72, #77b852);
border: 1px solid #a6df80;
color: #14200e;
box-shadow:
inset 0 1px #ddffc080,
0 3px 8px #04070b55;
}
.button.primary:hover {
background: linear-gradient(#ace783, #88c962);
}
.button:hover {
transform: translateY(-1px);
}
.button:active {
transform: translateY(1px);
}
/* .header-account deliberately retains its existing square, beveled styling. */
.compass-art {
border-radius: 50%;
border: 1px solid #536170;
background: #202b36;
width: 62px;
height: 62px;
box-shadow:
inset 0 1px #ffffff15,
0 5px 14px #04070b66;
}
.floating-tag {
border-radius: var(--radius-control);
background: #171f29;
border-color: #35404c;
box-shadow: 0 4px 12px #04070b55;
}
.world-stats {
position: relative;
z-index: 1;
width: calc(100% - 56px);
max-width: 1104px;
margin: -9px auto 0;
padding: 23px 24px;
border: 1px solid #35404c;
border-radius: var(--radius-panel);
background: #1a222c;
box-shadow: 0 8px 24px #03060a55;
}
.world-stats > div {
border-color: #35404c;
}
.world-stats .stat-cube.gold-cube {
background: #cba950;
border-color: #edca74;
}
.world-stats .stat-cube.cyan-cube {
background: #60b8bb;
border-color: #94dcde;
}
.world-stats .stat-cube.red-cube {
background: #c16067;
border-color: #e98a91;
}
.map-card,
.home-page .map-card {
border: 1px solid #35404c;
border-radius: var(--radius-panel);
background: #1a222c;
box-shadow: 0 4px 16px #03060a40;
overflow: hidden;
}
.map-card:hover {
border-color: #6c8b59;
transform: translateY(-3px);
}
.card-body,
.home-page .card-body {
background: #1a222c;
}
.card-type,
.card-number,
.badge {
border-radius: 4px;
}
.leaderboard-preview-board,
.rankings-board,
.home-panel,
.tool-card,
.start-panel,
.auth-card,
.settings-card,
.editor-panel,
.report-dialog,
.result-summary,
.round-result,
.empty-state,
.first-map-panel {
border-radius: var(--radius-panel);
border: 1px solid var(--line);
background: var(--panel);
box-shadow: 0 5px 18px #03060a40;
}
.leaderboard-preview-board,
.rankings-board {
overflow: hidden;
}
.leaderboard-table thead {
background: #131b24;
}
.home-panels {
gap: 24px;
}
.home-panel,
.home-panel + .home-panel {
padding: 28px;
border: 1px solid var(--line);
}
.mode-row {
border-radius: var(--radius-control);
background: #202b36;
border-color: #3b4856;
box-shadow: none;
}
.mode-row:hover {
background: #293642;
border-color: #6c8b59;
}
.contribute-banner {
border-radius: var(--radius-panel);
background: #19251f;
border: 1px solid #435942;
box-shadow: none;
}
.leaderboard-filters a,
.leaderboard-pagination a,
.difficulty-options button,
.filter-tabs button,
.icon-button,
.auth-tabs a {
border-radius: var(--radius-control);
}
.field input,
.field textarea,
.field select,
.search-field,
.leaderboard-search,
.preview-toolbar select {
border-radius: var(--radius-control);
}
.draft-row,
.token-box,
.preview-tile,
.leaderboard-rules article {
border-radius: var(--radius-control);
}
.player-avatar {
border-radius: 5px;
overflow: hidden;
}
/* Native vector cursors track directly, with normal text/drag cursors retained. */
@media (hover: hover) and (pointer: fine) {
body {
cursor:
url("/cursors/orbit.svg") 14 14,
auto;
}
a,
button,
select,
summary,
label[for],
input[type="checkbox"],
input[type="radio"],
input[type="file"],
[role="button"] {
cursor:
url("/cursors/orbit-link.svg") 18 18,
pointer;
}
input:not([type="checkbox"]):not([type="radio"]):not([type="file"]),
textarea,
[contenteditable="true"] {
cursor: text;
}
button:disabled,
[aria-disabled="true"] {
cursor: not-allowed;
}
}
@media (max-width: 1000px) {
.hero {
grid-template-columns: 1.15fr 1fr;
}
.hero h1 {
font-size: 54px;
}
}
@media (max-width: 700px) {
.hero {
grid-template-columns: 1fr;
padding: 36px 22px 32px;
}
.hero h1 {
font-size: clamp(43px, 10.8vw, 58px);
}
.world-stats {
width: calc(100% - 40px);
padding: 22px 18px;
gap: 24px 12px;
}
.world-stats strong {
font-size: 28px;
}
.home-panel,
.home-panel + .home-panel {
padding: 23px 20px;
}
.home-panels {
gap: 20px;
}
}
@media (prefers-reduced-motion: reduce) {
a,
button {
transition: none;
}
}