Improve MineGeo interface and functionality

This commit is contained in:
2026-10-06 19:24:41 +05:00
parent 7404f1dc1c
commit 233e4d57f0
67 changed files with 3053 additions and 643 deletions

View File

@@ -1,45 +1,49 @@
import { getI18n } from "@/lib/i18n";
import { Header, Footer } from "@/components/Shell";
import Link from "next/link";
export default function About() {
export default async function About() {
const { t } = await getI18n();
return (
<>
<Header />
<main className="section page-content prose">
<span className="eyebrow">THE EXPLORER’S FIELD GUIDE</span>
<span className="eyebrow">{t("THE EXPLORER’S FIELD GUIDE")}</span>
<h1>
You know this place.
{t("You know this place.")}
<br />
<span className="green">Probably.</span>
<span className="green">{t("Probably.")}</span>
</h1>
<p className="lead">
MineGeo drops you into a panorama from a community Minecraft world.
Find out where you are before the clock runs out.
{t(
"MineGeo drops you into a panorama from a community Minecraft world. Find out where you are before the clock runs out.",
)}
</p>
<h2>Look for the little things</h2>
<h2>{t("Look for the little things")}</h2>
<p>
Drag to look around. Mountains, shorelines, roofs, and landmarks all
help. You stay in one place throughout a round.
{t(
"Drag to look around. Mountains, shorelines, roofs, and landmarks all help. You stay in one place throughout a round.",
)}
</p>
<h2>Make your guess</h2>
<h2>{t("Make your guess")}</h2>
<p>
Expand the map in the bottom-right corner and click to place a pin.
North is up, X increases east, and Z increases south. Move your pin
until you’re ready, then submit.
{t(
"Expand the map in the bottom-right corner and click to place a pin. North is up, X increases east, and Z increases south. Move your pin until you’re ready, then submit.",
)}
</p>
<h2>Five chances to find your way</h2>
<h2>{t("Five chances to find your way")}</h2>
<p>
Easy gives you 180 seconds, Normal 90, and Hard 30. Hard disables
zoom. A timed-out round scores zero; you can still finish the
expedition.
{t(
"Easy gives you 180 seconds, Normal 90, and Hard 30. Hard disables zoom. A timed-out round scores zero; you can still finish the expedition.",
)}
</p>
<h2>Closer means better</h2>
<h2>{t("Closer means better")}</h2>
<p>
Each round awards up to 5,000 points based on horizontal distance. A
guess within one block earns a perfect score. Compare all five guesses
at the end.
{t(
"Each round awards up to 5,000 points based on horizontal distance. A guess within one block earns a perfect score. Compare all five guesses at the end.",
)}
</p>
<Link href="/servers" className="button primary">
Let’s find a world →
{t("Let’s find a world →")}
</Link>
</main>
<Footer />

View File

@@ -1,11 +1,16 @@
import { getI18n } from "@/lib/i18n";
import { cookies } from "next/headers";
import Link from "next/link";
import { Header, Footer } from "@/components/Shell";
import { PasswordForm } from "@/components/PasswordForm";
import { authClient, user } from "@/lib/supabase";
import { recoveryCookie, verifyRecoveryGrant } from "@/lib/password-recovery";
export const metadata = { title: "Reset password" };
export async function generateMetadata() {
const { t } = await getI18n();
return { title: t("Reset password") };
}
export default async function ResetPassword() {
const { t } = await getI18n();
const [account, jar, client] = await Promise.all([
user(),
cookies(),
@@ -27,24 +32,27 @@ export default async function ResetPassword() {
<Header />
<main className="section page-content">
<section className="auth-card reset-card">
<span className="eyebrow">ACCOUNT RECOVERY</span>
<h1>A fresh start.</h1>
<span className="eyebrow">{t("ACCOUNT RECOVERY")}</span>
<h1>{t("A fresh start.")}</h1>
{valid ? (
<>
<p className="muted">Choose a new password for your account.</p>
<p className="muted">
{t("Choose a new password for your account.")}
</p>
<PasswordForm recovery />
</>
) : (
<>
<p>
This reset link has expired or has already been used. Request a
fresh link to continue.
{t(
"This reset link has expired or has already been used. Request a fresh link to continue.",
)}
</p>
<Link
className="button primary"
href={account ? "/settings" : "/log-in"}
>
Request a new reset link →
{t("Request a new reset link →")}
</Link>
</>
)}

View File

@@ -1,3 +1,4 @@
import { getI18n } from "@/lib/i18n";
import { Header, Footer } from "@/components/Shell";
import { DraftEditor } from "@/components/DraftEditor";
import { user } from "@/lib/supabase";
@@ -7,6 +8,7 @@ export default async function Draft({
}: {
params: Promise<{ id: string }>;
}) {
const { t } = await getI18n();
const { id } = await params;
if (!(await user()))
redirect(`/log-in?next=${encodeURIComponent("/dashboard/" + id)}`);
@@ -14,8 +16,8 @@ export default async function Draft({
<>
<Header />
<main className="section page-content">
<span className="eyebrow">YOUR WORLD · YOUR LOCATIONS</span>
<h1>Map workshop</h1>
<span className="eyebrow">{t("YOUR WORLD · YOUR LOCATIONS")}</span>
<h1>{t("Map workshop")}</h1>
<DraftEditor id={id} />
</main>
<Footer />

View File

@@ -1,3 +1,4 @@
import { getI18n } from "@/lib/i18n";
import { Header, Footer } from "@/components/Shell";
import { SignIn } from "@/components/SignIn";
import { Dashboard } from "@/components/Dashboard";
@@ -6,16 +7,21 @@ import Link from "next/link";
import { accountProfile } from "@/lib/profile";
export const dynamic = "force-dynamic";
export default async function DashboardPage() {
const { t, formatDate } = await getI18n();
const account = await user();
const profile = account ? await accountProfile(account) : null;
const historyCutoff = new Date(
Date.now() - 30 * 24 * 60 * 60 * 1000,
).toISOString();
const history = account
? required(
await admin()
.from("game_sessions")
.select("id,difficulty,created_at,current_round")
.eq("owner_id", account.id)
.gte("created_at", historyCutoff)
.order("created_at", { ascending: false })
.limit(20),
.limit(5),
)
: [];
return (
@@ -24,24 +30,31 @@ export default async function DashboardPage() {
<main className="section page-content">
{account ? (
<>
<span className="eyebrow">THE CARTOGRAPHER’S DESK</span>
<h1>My expedition.</h1>
<p className="lead">Welcome back, {profile?.display_name}.</p>
<span className="eyebrow">{t("THE CARTOGRAPHER’S DESK")}</span>
<h1>{t("My expedition.")}</h1>
<p className="lead">
{t("Welcome back,")} {profile?.display_name}.
</p>
<Dashboard />
{history.length > 0 && (
<section className="token-box">
<h2>Recent journeys</h2>
<h2>{t("Recent journeys")}</h2>
<p className="muted">
{t("Your five latest journeys from the past 30 days.")}
</p>
{history.map((s) => (
<div className="draft-row" key={s.id}>
<span>
{new Date(s.created_at).toLocaleDateString("en-GB")} ·{" "}
{s.difficulty} · {s.current_round}/5 rounds
{formatDate(s.created_at)} ·{" "}
{t(s.difficulty[0].toUpperCase() + s.difficulty.slice(1))}{" "}
· {s.current_round}
{t("/5 rounds")}
</span>
<Link
className="button"
href={`/${s.current_round >= 5 ? "results" : "play"}/${s.id}`}
>
Open →
{t("Open →")}
</Link>
</div>
))}

View File

@@ -1,9 +1,11 @@
import { getI18n } from "@/lib/i18n";
import { Header, Footer } from "@/components/Shell";
import { Blocks, Server, Download, ExternalLink } from "lucide-react";
import { existsSync } from "node:fs";
import Link from "next/link";
export const dynamic = "force-dynamic";
export default function Downloads() {
export default async function Downloads() {
const { t } = await getI18n();
const fabric = existsSync("public/tools/mod/minegeo-fabric-1.21.11.jar"),
plugin = existsSync("public/tools/plugin/minegeo-plugin-1.21.11.jar");
return (
@@ -11,32 +13,40 @@ export default function Downloads() {
<Header />
<main className="section page-content">
<span className="eyebrow">
MADE FOR THE PEOPLE WHO KNOW THEIR WORLDS
{t("MADE FOR THE PEOPLE WHO KNOW THEIR WORLDS")}
</span>
<h1>Become a cartographer.</h1>
<h1>{t("Become a cartographer.")}</h1>
<p className="lead">
Two ways to bring your world to MineGeo. Both create private drafts
for you to preview before submitting for review.
{t(
"Two ways to bring your world to MineGeo. Both create private drafts for you to preview before submitting for review.",
)}
</p>
<div className="tool-grid">
<article className="tool-card">
<Blocks size={34} />
<span className="eyebrow">FOR PLAYERS</span>
<h2>Fabric capture mod.</h2>
<span className="eyebrow">{t("FOR PLAYERS")}</span>
<h2>{t("Fabric capture mod.")}</h2>
<p className="muted">
Explore normally and capture the places worth remembering. Your
server doesn’t need a plugin.
{t(
"Explore normally and capture the places worth remembering. Your server doesn’t need a plugin.",
)}
</p>
<ul>
<li>Minecraft Java 1.21.11 + Fabric Loader + Fabric API</li>
<li>Open the capture panel with G</li>
<li>Link your account through a browser pairing code</li>
<li>Capture 5–24 viewpoints for a new map, then upload</li>
<li>
Paste a map ID into “Add to map” to contribute 1–12 panoramas
{t("Minecraft Java 1.21.11 + Fabric Loader + Fabric API")}
</li>
<li>Only terrain received by your client enters the map</li>
<li>Contributions check the recorded server address</li>
<li>{t("Open the capture panel with G")}</li>
<li>{t("Link your account through a browser pairing code")}</li>
<li>{t("Capture 5–24 viewpoints for a new map, then upload")}</li>
<li>
{t(
"Paste a map ID into “Add to map” to contribute 1–12 panoramas",
)}
</li>
<li>
{t("Only terrain received by your client enters the map")}
</li>
<li>{t("Contributions check the recorded server address")}</li>
</ul>
{fabric ? (
<a
@@ -44,34 +54,40 @@ export default function Downloads() {
href="/tools/mod/minegeo-fabric-1.21.11.jar"
download
>
<Download size={16} /> Download Fabric mod
<Download size={16} /> {t("Download Fabric mod")}
</a>
) : (
<p className="muted">The Fabric build is not available yet.</p>
<p className="muted">
{t("The Fabric build is not available yet.")}
</p>
)}
<p className="muted" style={{ fontSize: 11, marginTop: 20 }}>
Install the JAR in your Minecraft mods folder alongside Fabric
API.
{t(
"Install the JAR in your Minecraft mods folder alongside Fabric API.",
)}
</p>
</article>
<article className="tool-card">
<Server size={34} />
<span className="eyebrow">FOR SERVER ADMINISTRATORS</span>
<h2>Server export plugin.</h2>
<span className="eyebrow">{t("FOR SERVER ADMINISTRATORS")}</span>
<h2>{t("Server export plugin.")}</h2>
<p className="muted">
Export generated Overworld terrain. MineGeo renders panoramas
without a connected player or BlueMap.
{t(
"Export generated Overworld terrain. MineGeo renders panoramas without a connected player or BlueMap.",
)}
</p>
<ul>
<li>Bukkit/Paper 1.21.11 · Java 21</li>
<li>Select a bounded area and export existing chunks</li>
<li>Choose up to 24 random, safe surface viewpoints</li>
<li>{t("Bukkit/Paper 1.21.11 · Java 21")}</li>
<li>{t("Select a bounded area and export existing chunks")}</li>
<li>{t("Choose up to 24 random, safe surface viewpoints")}</li>
<li>
Use /minegeo contribute &lt;map ID&gt; to join an existing map
</li>
<li>Link a contributor token and upload resumably</li>
<li>Set the public Minecraft server address before exporting</li>
<li>Vanilla blocks/fluids, simplified lighting</li>
<li>{t("Link a contributor token and upload resumably")}</li>
<li>
{t("Set the public Minecraft server address before exporting")}
</li>
<li>{t("Vanilla blocks/fluids, simplified lighting")}</li>
</ul>
{plugin ? (
<a
@@ -79,10 +95,12 @@ export default function Downloads() {
href="/tools/plugin/minegeo-plugin-1.21.11.jar"
download
>
<Download size={16} /> Download server plugin
<Download size={16} /> {t("Download server plugin")}
</a>
) : (
<p className="muted">The plugin build is not available yet.</p>
<p className="muted">
{t("The plugin build is not available yet.")}
</p>
)}
<pre>
{
@@ -90,10 +108,13 @@ export default function Downloads() {
}
</pre>
<p className="muted">
Start with a small, explored area around your builds. Exports are
saved in <code>plugins/MineGeo/exports/export-…/</code>. The
manifest appears when export finishes. Upload sends the latest
completed export; it does not capture the world.
{t(
"Start with a small, explored area around your builds. Exports are saved in",
)}{" "}
<code>plugins/MineGeo/exports/export-…/</code>
{t(
". The manifest appears when export finishes. Upload sends the latest completed export; it does not capture the world.",
)}
</p>
<p className="muted" style={{ fontSize: 11 }}>
Put the JAR in plugins/ and restart. Set website, server-name and
@@ -105,15 +126,15 @@ export default function Downloads() {
</div>
<div className="contribute-banner">
<div>
<h2>Your map, your credit.</h2>
<h2>{t("Your map, your credit.")}</h2>
<p>
Map owners can allow player contributions. Each player may add up
to 12 panoramas per map. Owners approve the selection, then
moderators review the map update before it becomes playable.
{t(
"Map owners can allow player contributions. Each player may add up to 12 panoramas per map. Owners approve the selection, then moderators review the map update before it becomes playable.",
)}
</p>
</div>
<Link className="button" href="/dashboard">
Open my dashboard <ExternalLink size={15} />
{t("Open my dashboard")} <ExternalLink size={15} />
</Link>
</div>
</main>

View File

@@ -1,14 +1,18 @@
"use client";
import { useI18n } from "@/components/I18nProvider";
export default function ErrorPage({ retry }: { retry: () => void }) {
const { t } = useI18n();
return (
<main className="section page-content">
<h1>Lost connection.</h1>
<h1>{t("Lost connection.")}</h1>
<p>
We couldn’t load this part of the world. Check your connection and try
again.
{t(
"We couldn’t load this part of the world. Check your connection and try again.",
)}
</p>
<button className="button primary" onClick={retry}>
Try again
{t("Try again")}
</button>
</main>
);

View File

@@ -982,7 +982,9 @@ pre {
position: absolute;
inset: 0;
touch-action: none;
cursor: grab;
}
.panorama:active { cursor: grabbing; }
.panorama canvas {
width: 100%;
height: 100%;

View File

@@ -9,24 +9,45 @@ import "./home-map.css";
import "./rounded-theme.css";
import "./cursor.css";
import { SiteCursor } from "@/components/SiteCursor";
export const metadata: Metadata = {
title: {
default: "MineGeo — Get lost in a familiar world",
template: "%s · MineGeo",
},
description:
"Guess where you are in Minecraft. Explore community servers through 360° panoramas and place your pin on the map.",
};
export default function RootLayout({
import { I18nProvider } from "@/components/I18nProvider";
import { getI18n } from "@/lib/i18n";
import "./polish.css";
import { SiteSettingsProvider } from "@/components/SiteSettingsProvider";
import { initialSiteSettings } from "@/lib/site-settings";
export async function generateMetadata(): Promise<Metadata> {
const { t } = await getI18n();
return {
title: {
default: t("MineGeo — Get lost in a familiar world"),
template: "%s · MineGeo",
},
description: t(
"Guess where you are in Minecraft. Explore community servers through 360° panoramas and place your pin on the map.",
),
};
}
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const [{ locale, preference, detectedLocale }, settings] = await Promise.all([
getI18n(),
initialSiteSettings(),
]);
return (
<html lang="en">
<html lang={locale}>
<body>
{children}
<SiteCursor />
<I18nProvider
locale={locale}
preference={preference}
detectedLocale={detectedLocale}
>
<SiteSettingsProvider initialSettings={settings}>
{children}
<SiteCursor />
</SiteSettingsProvider>
</I18nProvider>
</body>
</html>
);

View File

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

View File

@@ -1,3 +1,4 @@
import { getI18n } from "@/lib/i18n";
import type { Metadata } from "next";
import Link from "next/link";
import { ArrowLeft, ArrowRight, Search } from "lucide-react";
@@ -11,24 +12,28 @@ 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 async function generateMetadata(): Promise<Metadata> {
const { t } = await getI18n();
return {
title: t("Leaderboard"),
description: t(
"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 { t, formatNumber } = await getI18n();
const { difficulty, search, page } = leaderboardFilters(await searchParams);
const [data, account] = await Promise.all([
leaderboard({ difficulty, search, page }),
@@ -39,16 +44,16 @@ export default async function LeaderboardPage({
<Header />
<main className="section page-content leaderboard-page" id="main-content">
<Link href="/" className="text-link leaderboard-back">
<ArrowLeft size={14} /> Back to home
<ArrowLeft size={14} /> {t("Back to home")}
</Link>
<header className="leaderboard-intro">
<div>
<span className="eyebrow">THE EXPLORERS’ BOARD</span>
<h1>Every block counts.</h1>
<span className="eyebrow">{t("THE EXPLORERS’ BOARD")}</span>
<h1>{t("Every block counts.")}</h1>
<p className="lead">
The sharpest eyes in the Overworld.
{t("The sharpest eyes in the Overworld.")}
<br />
Five rounds. 25,000 possible points. Where do you stand?
{t("Five rounds. 25,000 possible points. Where do you stand?")}
</p>
</div>
<PixelTrophy className="intro-trophy" />
@@ -56,7 +61,7 @@ export default async function LeaderboardPage({
<div className="leaderboard-toolbar">
<nav
className="leaderboard-filters"
aria-label="Filter leaderboard by difficulty"
aria-label={t("Filter leaderboard by difficulty")}
>
{leaderboardDifficulties.map((value) => (
<Link
@@ -65,12 +70,12 @@ export default async function LeaderboardPage({
aria-current={difficulty === value ? "page" : undefined}
>
{value === "all"
? "All difficulties"
: value[0].toUpperCase() + value.slice(1)}
? t("All difficulties")
: t(value[0].toUpperCase() + value.slice(1))}
</Link>
))}
</nav>
<span className="leaderboard-period">All time</span>
<span className="leaderboard-period">{t("All time")}</span>
</div>
{data ? (
<>
@@ -83,12 +88,12 @@ export default async function LeaderboardPage({
>
<div className="rankings-heading">
<div>
<h2 id="rankings-heading">The rankings</h2>
<h2 id="rankings-heading">{t("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"}
{t("Players: {players} · Completed expeditions: {games}", {
players: formatNumber(data.total_players),
games: formatNumber(data.total_games),
})}
</p>
</div>
<form
@@ -100,7 +105,7 @@ export default async function LeaderboardPage({
<input type="hidden" name="difficulty" value={difficulty} />
)}
<label className="sr-only" htmlFor="player-search">
Search players
{t("Search players")}
</label>
<input
key={`${difficulty}-${search}`}
@@ -108,10 +113,10 @@ export default async function LeaderboardPage({
type="search"
name="q"
defaultValue={search}
placeholder="Find an explorer…"
placeholder={t("Find an explorer…")}
maxLength={64}
/>
<button type="submit" aria-label="Search players">
<button type="submit" aria-label={t("Search players")}>
<Search size={17} />
</button>
</form>
@@ -119,11 +124,14 @@ export default async function LeaderboardPage({
{search && (
<div className="search-summary">
<span>
{formatScore(data.matching_players)}{" "}
{data.matching_players === 1 ? "result" : "results"} for “
{search}”
{t("Results for “{search}”: {count}", {
search,
count: formatNumber(data.matching_players),
})}
</span>
<Link href={leaderboardHref(difficulty)}>Clear search ×</Link>
<Link href={leaderboardHref(difficulty)}>
{t("Clear search ×")}
</Link>
</div>
)}
{data.entries.length ? (
@@ -137,27 +145,27 @@ export default async function LeaderboardPage({
{data.matching_players > 0 && (
<div className="leaderboard-pagination">
<span>
Showing {formatScore((data.page - 1) * 25 + 1)}–
{formatScore(
{t("Showing")} {formatNumber((data.page - 1) * 25 + 1)}–
{formatNumber(
Math.min(data.page * 25, data.matching_players),
)}{" "}
of {formatScore(data.matching_players)}
{t("of")} {formatNumber(data.matching_players)}
</span>
<nav aria-label="Leaderboard pages">
<nav aria-label={t("Leaderboard pages")}>
{data.page > 1 ? (
<Link
aria-label="Previous page"
aria-label={t("Previous page")}
href={leaderboardHref(
difficulty,
search,
data.page - 1,
)}
>
<ArrowLeft size={15} /> Previous
<ArrowLeft size={15} /> {t("Previous")}
</Link>
) : (
<span aria-disabled="true">
<ArrowLeft size={15} /> Previous
<ArrowLeft size={15} /> {t("Previous")}
</span>
)}
<span className="page-number">
@@ -165,18 +173,18 @@ export default async function LeaderboardPage({
</span>
{data.page < data.pages ? (
<Link
aria-label="Next page"
aria-label={t("Next page")}
href={leaderboardHref(
difficulty,
search,
data.page + 1,
)}
>
Next <ArrowRight size={15} />
{t("Next")} <ArrowRight size={15} />
</Link>
) : (
<span aria-disabled="true">
Next <ArrowRight size={15} />
{t("Next")} <ArrowRight size={15} />
</span>
)}
</nav>
@@ -189,23 +197,24 @@ export default async function LeaderboardPage({
)}
<aside className="leaderboard-rules">
<div>
<span className="eyebrow">HOW THE BOARD WORKS</span>
<h2>A place earned, one guess at a time.</h2>
<span className="eyebrow">{t("HOW THE BOARD WORKS")}</span>
<h2>{t("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.
{t(
"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.
{t(
"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"}{" "}
? t("Start your next expedition")
: t("Log in to join the board")}{" "}
<ArrowRight size={15} />
</Link>
</div>

View File

@@ -1,9 +1,13 @@
import { getI18n } from "@/lib/i18n";
import { redirect } from "next/navigation";
import { Header, Footer } from "@/components/Shell";
import { SignIn } from "@/components/SignIn";
import { user } from "@/lib/supabase";
import { authDestination } from "@/shared/account";
export const metadata = { title: "Log in" };
export async function generateMetadata() {
const { t } = await getI18n();
return { title: t("Log in") };
}
export default async function LogIn({
searchParams,
}: {

View File

@@ -1,15 +1,18 @@
import { getI18n } from "@/lib/i18n";
import { Header, Footer } from "@/components/Shell";
import { Moderation } from "@/components/Moderation";
export default function Review() {
export default async function Review() {
const { t } = await getI18n();
return (
<>
<Header />
<main className="section page-content">
<span className="eyebrow">THE COMMUNITY’S COMPASS</span>
<h1>Map moderation.</h1>
<span className="eyebrow">{t("THE COMMUNITY’S COMPASS")}</span>
<h1>{t("Map moderation.")}</h1>
<p className="lead">
Review map quality and coverage before a world enters the public
catalog.
{t(
"Review map quality and coverage before a world enters the public catalog.",
)}
</p>
<Moderation />
</main>

View File

@@ -1,12 +1,14 @@
import { getI18n } from "@/lib/i18n";
import Link from "next/link";
export default function NotFound() {
export default async function NotFound() {
const { t } = await getI18n();
return (
<main className="section page-content">
<span className="eyebrow">UNKNOWN COORDINATES</span>
<h1>Uncharted territory.</h1>
<p>This map or page isn’t available.</p>
<span className="eyebrow">{t("UNKNOWN COORDINATES")}</span>
<h1>{t("Uncharted territory.")}</h1>
<p>{t("This map or page isn’t available.")}</p>
<Link className="button primary" href="/servers">
Explore worlds →
{t("Explore worlds →")}
</Link>
</main>
);

View File

@@ -1,3 +1,4 @@
import { getI18n } from "@/lib/i18n";
import Link from "next/link";
import Image from "next/image";
import {
@@ -15,6 +16,7 @@ import { leaderboard } from "@/lib/leaderboard";
import { LeaderboardPreview } from "@/components/Leaderboard";
export const dynamic = "force-dynamic";
export default async function Home() {
const { t, formatNumber } = await getI18n();
const [maps, rankings] = await Promise.all([
catalog(),
leaderboard({ limit: 5 }),
@@ -27,24 +29,24 @@ export default async function Home() {
<main className="home-page" id="main-content">
<section className="hero">
<div className="hero-copy">
<div className="status-label">A MINECRAFT GUESSING GAME</div>
<div className="status-label">{t("A MINECRAFT GUESSING GAME")}</div>
<h1>
Guess where you are
{t("Guess where you are")}
<br />
in a <span>blocky world.</span>
{t("in a")} <span>{t("blocky world.")}</span>
</h1>
<p>
Drop into a random corner of a Minecraft world.
<br className="desktop" /> Look around, spot the landmarks, and
place your pin
<br className="desktop" /> before the clock runs out.
{t("Drop into a random corner of a Minecraft world.")}
<br className="desktop" />{" "}
{t("Look around, spot the landmarks, and place your pin")}
<br className="desktop" /> {t("before the clock runs out.")}
</p>
<div className="hero-actions">
<Link href="/servers" className="button primary">
Start exploring <ArrowRight size={18} />
{t("Start exploring")} <ArrowRight size={18} />
</Link>
<Link href="/leaderboard" className="text-link">
Meet the top explorers <ArrowRight size={14} />
{t("Meet the top explorers")} <ArrowRight size={14} />
</Link>
</div>
<div className="hero-notes">
@@ -55,14 +57,16 @@ export default async function Home() {
<i />
<i />
</span>
<span>Five rounds. No roads. Just your instincts.</span>
<span>{t("Five rounds. No roads. Just your instincts.")}</span>
</div>
</div>
<div className="hero-art" aria-label="Minecraft surface map">
<div className="hero-art" aria-label={t("Minecraft surface map")}>
<div className="map-art-frame minecraft-paper">
<Image
src={maps[0]?.mapImage || "/images/example-map.webp"}
alt="A Minecraft surface map on the original Minecraft parchment texture"
alt={t(
"A Minecraft surface map on the original Minecraft parchment texture",
)}
width={1024}
height={1024}
loading="eager"
@@ -79,51 +83,52 @@ export default async function Home() {
<div className="floating-tag">
<MapPin size={14} />
<strong>X: ??? Z: ???</strong>
<span>Overworld</span>
<span>{t("Overworld")}</span>
</div>
</div>
</section>
<div className="grass-divider" aria-hidden="true">
<span />
</div>
<section className="world-stats" aria-label="Community statistics">
<section className="world-stats" aria-label={t("Community statistics")}>
<div>
<i className="stat-cube green-cube" />
<div>
<strong>{servers.toLocaleString()}</strong>
<span>COMMUNITY SERVERS</span>
<strong>{formatNumber(servers)}</strong>
<span>{t("COMMUNITY SERVERS")}</span>
</div>
</div>
<div>
<i className="stat-cube gold-cube" />
<div>
<strong>{maps.length.toLocaleString()}</strong>
<span>PUBLISHED MAPS</span>
<strong>{formatNumber(maps.length)}</strong>
<span>{t("PUBLISHED MAPS")}</span>
</div>
</div>
<div>
<i className="stat-cube cyan-cube" />
<div>
<strong>{locations.toLocaleString()}</strong>
<span>PLACES TO DISCOVER</span>
<strong>{formatNumber(locations)}</strong>
<span>{t("PLACES TO DISCOVER")}</span>
</div>
</div>
<div>
<i className="stat-cube red-cube" />
<div>
<strong>5 rounds</strong>
<span>EVERY EXPEDITION</span>
<strong>{t("5 rounds")}</strong>
<span>{t("EVERY EXPEDITION")}</span>
</div>
</div>
</section>
<section className="section catalog-section">
<div className="section-heading">
<div>
<span className="eyebrow">COMMUNITY MAPS</span>
<h2>Where will you go today?</h2>
<span className="eyebrow">{t("COMMUNITY MAPS")}</span>
<h2>{t("Where will you go today?")}</h2>
</div>
<Link className="text-link" href="/servers">
All {maps.length} maps <ArrowRight size={15} />
{t("All maps ({count})", { count: maps.length })}{" "}
<ArrowRight size={15} />
</Link>
</div>
{maps.length ? (
@@ -142,20 +147,20 @@ export default async function Home() {
height={180}
unoptimized
/>
<span>YOUR WORLD HERE</span>
<span>{t("YOUR WORLD HERE")}</span>
</div>
<div>
<span className="eyebrow">
THE FIRST EXPEDITION STARTS WITH YOU
{t("THE FIRST EXPEDITION STARTS WITH YOU")}
</span>
<h3>Put your server on the map.</h3>
<h3>{t("Put your server on the map.")}</h3>
<p>
No community maps have been published yet. Export your world
with the server plugin, or capture five locations with the
Fabric mod. Preview your map and submit it for review.
{t(
"No community maps have been published yet. Export your world with the server plugin, or capture five locations with the Fabric mod. Preview your map and submit it for review.",
)}
</p>
<Link className="button primary" href="/downloads">
<Upload size={16} /> Get the capture tools
<Upload size={16} /> {t("Get the capture tools")}
</Link>
</div>
</div>
@@ -164,58 +169,64 @@ export default async function Home() {
<LeaderboardPreview data={rankings} />
<section className="section home-panels">
<article className="home-panel">
<span className="eyebrow">HOW TO PLAY</span>
<h2>Know your blocks?</h2>
<span className="eyebrow">{t("HOW TO PLAY")}</span>
<h2>{t("Know your blocks?")}</h2>
<ol className="play-steps">
<li>
<span>01</span>
<div>
<h3>Pick your world</h3>
<p>Choose a community server, map, and difficulty.</p>
<h3>{t("Pick your world")}</h3>
<p>{t("Choose a community server, map, and difficulty.")}</p>
</div>
</li>
<li>
<span>02</span>
<div>
<h3>Look for the clues</h3>
<h3>{t("Look for the clues")}</h3>
<p>
Rotate the panorama. Find builds, biomes, and landmarks.
{t(
"Rotate the panorama. Find builds, biomes, and landmarks.",
)}
</p>
</div>
</li>
<li>
<span>03</span>
<div>
<h3>Make your guess</h3>
<p>Drop a pin on the map. Closer guesses earn more points.</p>
<h3>{t("Make your guess")}</h3>
<p>
{t(
"Drop a pin on the map. Closer guesses earn more points.",
)}
</p>
</div>
</li>
</ol>
<Link className="text-link" href="/about">
Read the field guide <ArrowRight size={14} />
{t("Read the field guide")} <ArrowRight size={14} />
</Link>
</article>
<article className="home-panel">
<span className="eyebrow">CHOOSE YOUR CHALLENGE</span>
<h2>One world. Three ways.</h2>
<span className="eyebrow">{t("CHOOSE YOUR CHALLENGE")}</span>
<h2>{t("One world. Three ways.")}</h2>
<div className="mode-list">
{[
{
name: "Easy",
time: "180 seconds",
detail: "Take your time. Rotate and zoom.",
name: t("Easy"),
time: t("180 seconds"),
detail: t("Take your time. Rotate and zoom."),
color: "green",
},
{
name: "Normal",
time: "90 seconds",
detail: "Trust your eye. Rotate and zoom.",
name: t("Normal"),
time: t("90 seconds"),
detail: t("Trust your eye. Rotate and zoom."),
color: "gold",
},
{
name: "Hard",
time: "30 seconds",
detail: "Think fast. No zoom allowed.",
name: t("Hard"),
time: t("30 seconds"),
detail: t("Think fast. No zoom allowed."),
color: "red",
},
].map((mode) => (
@@ -223,32 +234,34 @@ export default async function Home() {
<i className={`stat-cube ${mode.color}-cube`} />
<div>
<h3>
{mode.name}
{t(mode.name)}
<span>
<Timer size={12} />
{mode.time}
{t(mode.time)}
</span>
</h3>
<p>{mode.detail}</p>
<p>{t(mode.detail)}</p>
</div>
<ArrowRight size={15} />
</Link>
))}
</div>
<p className="mode-footnote">
Up to 5,000 points per round. How close can you get?
{t("Up to 5,000 points per round. How close can you get?")}
</p>
</article>
</section>
<section className="section contribute-banner">
<Blocks size={38} />
<div>
<span className="eyebrow">BUILT BY THE COMMUNITY</span>
<h2>Your world deserves to be explored.</h2>
<p>Share your builds and your favorite corners of Minecraft.</p>
<span className="eyebrow">{t("BUILT BY THE COMMUNITY")}</span>
<h2>{t("Your world deserves to be explored.")}</h2>
<p>
{t("Share your builds and your favorite corners of Minecraft.")}
</p>
</div>
<Link href="/downloads" className="button">
Create a map <ArrowRight size={16} />
{t("Create a map")} <ArrowRight size={16} />
</Link>
</section>
</main>

View File

@@ -1,3 +1,4 @@
import { getI18n } from "@/lib/i18n";
import { Header, Footer } from "@/components/Shell";
import { Pairing } from "@/components/Pairing";
import { SignIn } from "@/components/SignIn";
@@ -7,6 +8,7 @@ export default async function Pair({
}: {
searchParams: Promise<{ code?: string }>;
}) {
const { t } = await getI18n();
const { code } = await searchParams;
const account = await user();
return (
@@ -16,7 +18,7 @@ export default async function Pair({
{!account ? (
<>
<p className="muted">
Sign in first, then reopen the pairing link from Minecraft.
{t("Sign in first, then reopen the pairing link from Minecraft.")}
</p>
<SignIn
next={
@@ -29,7 +31,7 @@ export default async function Pair({
) : code && /^[A-F0-9]{8}$/.test(code) ? (
<Pairing code={code} />
) : (
<p>Open the pairing link shown by your Fabric mod.</p>
<p>{t("Open the pairing link shown by your Fabric mod.")}</p>
)}
</main>
<Footer />

220
app/polish.css Normal file
View File

@@ -0,0 +1,220 @@
/* Responsive corrections shared by both languages. */
.language-switcher {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--muted);
}
.language-switcher select {
color: var(--text);
background: #151e28;
border: 1px solid var(--line);
border-radius: 8px;
padding: 8px 28px 8px 10px;
max-width: 100%;
cursor: pointer;
}
.language-switcher select:focus-visible,
textarea:focus-visible,
select:focus-visible {
outline: 2px solid var(--green);
outline-offset: 3px;
}
@font-face {
font-family: MineGeoPixelCyrillic;
src: url("/fonts/Monocraft.ttf") format("truetype");
font-weight: 400;
font-style: normal;
font-display: swap;
}
html[lang="ru"] h1,
html[lang="ru"] h2,
html[lang="ru"] h3,
html[lang="ru"] .button,
html[lang="ru"] .header-account,
html[lang="ru"] .site-header nav,
html[lang="ru"] .world-stats strong {
font-family: MineGeoPixelCyrillic, monospace;
line-height: 1.45;
}
html[lang="ru"] h1 {
font-size: clamp(28px, 3vw, 42px);
letter-spacing: -1px;
}
html[lang="ru"] h2 {
font-size: clamp(22px, 2.1vw, 30px);
letter-spacing: -0.5px;
}
html[lang="ru"] h3 {
font-size: 18px;
letter-spacing: 0;
}
html[lang="ru"] .button,
html[lang="ru"] .header-account {
font-size: 14px;
}
html[lang="ru"] .site-header nav {
font-size: 12px;
gap: 24px;
}
html[lang="ru"] .world-stats strong {
font-size: 24px;
}
html[lang="ru"] .leaderboard-preview-copy h2 {
font-size: clamp(27px, 3.2vw, 42px);
}
html[lang="ru"] .mode-row h3 {
font-size: 14px;
flex-wrap: wrap;
}
html[lang="ru"] .mode-row h3 span {
font-family: Arial, sans-serif;
}
.header-inner > *,
.hero > *,
.section-heading > *,
.home-panels > *,
.contribute-banner > *,
.site-footer > * {
min-width: 0;
}
.site-footer > div:last-of-type {
flex-wrap: wrap;
align-items: center;
}
.site-footer .disclaimer {
color: var(--muted);
}
.card-body h3,
.podium-player h2 {
overflow-wrap: anywhere;
}
.board-heading {
gap: 12px;
flex-wrap: wrap;
}
.mode-row h3 {
flex-wrap: wrap;
}
.tool-card {
display: flex;
flex-direction: column;
}
.tool-card pre,
.token-box code {
max-width: 100%;
overflow-x: auto;
}
.tool-card .button {
align-self: flex-start;
}
.leaderboard-filters {
flex-wrap: wrap;
}
.hero-notes > span:last-child {
min-width: 0;
}
.round-blocks {
flex-shrink: 0;
}
.button,
.header-account {
text-align: center;
}
.auth-tabs button {
border-radius: var(--radius-control);
}
@media (max-width: 850px) {
.site-header {
height: auto;
}
.header-inner {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
padding: 14px 20px;
gap: 14px;
}
.header-inner nav {
grid-column: 1 / -1;
grid-row: 2;
width: 100%;
display: flex;
flex-wrap: wrap;
gap: 12px 22px;
padding: 12px 0 2px;
border-top: 1px solid var(--line);
justify-content: flex-start;
}
html[lang="ru"] .header-inner nav {
gap: 12px 20px;
font-size: 11px;
}
.header-account {
justify-self: end;
white-space: nowrap;
}
.site-header nav a {
padding-block: 6px;
}
.site-header nav a[aria-current="page"]::after {
bottom: 0;
}
}
@media (max-width: 700px) {
.section-heading {
flex-wrap: wrap;
align-items: flex-start;
gap: 12px;
}
.section-heading .text-link {
white-space: normal;
}
.world-stats {
margin-inline: auto;
}
.world-stats > div {
padding-left: 0;
}
.home-page .map-art-frame {
width: min(256px, 76vw);
height: auto;
aspect-ratio: 1;
}
.site-footer {
gap: 24px;
}
.site-footer > div:last-of-type {
width: 100%;
gap: 16px 24px;
}
.language-switcher {
flex-basis: 100%;
}
html[lang="ru"] .hero h1 {
font-size: clamp(26px, 7vw, 34px);
}
html[lang="ru"] .world-stats strong {
font-size: 20px;
}
html[lang="ru"] .card-body h3 {
font-size: 15px;
}
html[lang="ru"] .card-type {
font-size: 8px;
}
.hero-actions .text-link {
font-size: 12px;
}
.home-panel + .home-panel {
min-width: 0;
}
}
.moderator-settings { margin-bottom: 28px; }
.moderator-setting-row { display: flex; gap: 24px; justify-content: space-between; align-items: center; margin-top: 24px; }
.moderator-setting-row h3 { margin: 0 0 8px; }
.moderator-setting-row p { margin: 6px 0; }
.moderator-setting-row .button { flex-shrink: 0; }
@media (max-width: 600px) {
.moderator-setting-row { align-items: stretch; flex-direction: column; }
}

View File

@@ -1,3 +1,4 @@
import { getI18n } from "@/lib/i18n";
import { notFound } from "next/navigation";
import Image from "next/image";
import { Header, Footer } from "@/components/Shell";
@@ -10,6 +11,7 @@ export default async function ServerPage({
}: {
params: Promise<{ id: string }>;
}) {
const { t } = await getI18n();
const { id } = await params;
const map = await getMap(id);
if (!map) notFound();
@@ -18,15 +20,19 @@ export default async function ServerPage({
<Header />
<main className="section page-content">
<span className="eyebrow">
{map.source === "plugin" ? "SERVER EXPORT" : "PLAYER CONTRIBUTION"} ·
MINECRAFT 1.21.11
{map.source === "plugin"
? t("SERVER EXPORT")
: t("PLAYER CONTRIBUTION")}{" "}
{t("· MINECRAFT 1.21.11")}
</span>
<h1>{map.name}</h1>
<p className="lead">{map.description}</p>
<p className="map-credit">Created by {map.credit}</p>
<p className="map-credit">
{t("Created by")} {map.credit}
</p>
<ReportContent datasetId={id} />
<p className="muted">
Map ID: <code>{map.id}</code>
{t("Map ID:")} <code>{map.id}</code>
</p>
<div className="detail-grid">
<div>
@@ -41,11 +47,13 @@ export default async function ServerPage({
/>
</div>
<div className="card-meta">
<span>{map.panorama_count} panoramas</span>
<span>Overworld surface</span>
<span>
{map.panorama_count} {t("panoramas")}
</span>
<span>{t("Overworld surface")}</span>
<span>
{map.bounds.maxX - map.bounds.minX} ×{" "}
{map.bounds.maxZ - map.bounds.minZ} blocks
{map.bounds.maxZ - map.bounds.minZ} {t("blocks")}
</span>
</div>
</div>

View File

@@ -1,24 +1,31 @@
import { getI18n } from "@/lib/i18n";
import { Header, Footer, EmptyState } from "@/components/Shell";
import { catalog } from "@/lib/catalog";
import { CatalogBrowser } from "@/components/CatalogBrowser";
export const dynamic = "force-dynamic";
export default async function Servers() {
const { t } = await getI18n();
const maps = await catalog();
return (
<>
<Header />
<main className="section page-content">
<span className="eyebrow">THE COMMUNITY AT YOUR COORDINATES</span>
<h1>Explore the worlds.</h1>
<span className="eyebrow">
{t("THE COMMUNITY AT YOUR COORDINATES")}
</span>
<h1>{t("Explore the worlds.")}</h1>
<p className="lead">
From familiar spawn towns to places nobody’s quite sure about. Pick
your next expedition.
{t(
"From familiar spawn towns to places nobody’s quite sure about. Pick your next expedition.",
)}
</p>
{maps.length ? (
<CatalogBrowser maps={maps} />
) : (
<EmptyState title="Fresh chunks, fresh start">
No maps have been approved yet. Your server could be the first.
<EmptyState title={t("Fresh chunks, fresh start")}>
{t(
"No maps have been approved yet. Your server could be the first.",
)}
</EmptyState>
)}
</main>

View File

@@ -1,15 +1,20 @@
import { getI18n } from "@/lib/i18n";
import Link from "next/link";
import { redirect } from "next/navigation";
import { Header, Footer } from "@/components/Shell";
import { AccountSettings } from "@/components/AccountSettings";
import { user } from "@/lib/supabase";
import { accountProfile } from "@/lib/profile";
export const metadata = { title: "Account settings" };
export async function generateMetadata() {
const { t } = await getI18n();
return { title: t("Account settings") };
}
export default async function Settings({
searchParams,
}: {
searchParams: Promise<{ password?: string }>;
}) {
const { t } = await getI18n();
const account = await user();
if (!account) redirect("/log-in?next=%2Fsettings");
const [profile, query] = await Promise.all([
@@ -21,12 +26,14 @@ export default async function Settings({
<Header />
<main className="section page-content">
<Link className="text-link" href="/dashboard">
← My account
{t("← My account")}
</Link>
<div className="account-page-heading">
<span className="eyebrow">YOUR CARTOGRAPHER ACCOUNT</span>
<h1>Account settings.</h1>
<p className="lead">Your name, your sign-in, your next expedition.</p>
<span className="eyebrow">{t("YOUR CARTOGRAPHER ACCOUNT")}</span>
<h1>{t("Account settings.")}</h1>
<p className="lead">
{t("Your name, your sign-in, your next expedition.")}
</p>
</div>
<AccountSettings
email={account.email ?? ""}