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 ?? ""}

View File

@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/I18nProvider";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { UserRound, LockKeyhole, Mail } from "lucide-react";
@@ -14,6 +16,7 @@ export function AccountSettings({
displayName: string;
passwordUpdated?: boolean;
}) {
const { t } = useI18n();
const [name, setName] = useState(displayName),
[message, setMessage] = useState(""),
[error, setError] = useState(""),
@@ -68,13 +71,15 @@ export function AccountSettings({
<div className="settings-heading">
<UserRound size={22} />
<div>
<h2>Your public profile.</h2>
<p className="muted">A name for the maps and places you share.</p>
<h2>{t("Your public profile.")}</h2>
<p className="muted">
{t("A name for the maps and places you share.")}
</p>
</div>
</div>
<form onSubmit={save}>
<label className="field">
Display name
{t("Display name")}
<input
name="displayName"
autoComplete="nickname"
@@ -86,29 +91,31 @@ export function AccountSettings({
disabled={busy}
/>
<small>
2–32 characters. Display names do not have to be unique.
{t("2–32 characters. Display names do not have to be unique.")}
</small>
</label>
<div className="account-email">
<Mail size={17} />
<div>
<span>Email address</span>
<span>{t("Email address")}</span>
<strong>{email}</strong>
<small>Used for sign-in and recovery. Only you can see it.</small>
<small>
{t("Used for sign-in and recovery. Only you can see it.")}
</small>
</div>
</div>
{error && (
<p role="alert" className="error-message">
{error}
{t(error)}
</p>
)}
{message && (
<p role="status" className="success-message">
{message}
{t(message)}
</p>
)}
<button className="button primary" disabled={busy}>
{busy ? "Saving…" : "Save display name"}
{busy ? t("Saving…") : t("Save display name")}
</button>
</form>
</section>
@@ -116,24 +123,25 @@ export function AccountSettings({
<div className="settings-heading">
<LockKeyhole size={22} />
<div>
<h2>Password & security.</h2>
<p className="muted">Keep your account yours.</p>
<h2>{t("Password & security.")}</h2>
<p className="muted">{t("Keep your account yours.")}</p>
</div>
</div>
{passwordUpdated && (
<p role="status" className="success-message">
Your password has been updated.
{t("Your password has been updated.")}
</p>
)}
<PasswordForm />
<div className="settings-recovery">
<h3>Signed up with an email link?</h3>
<h3>{t("Signed up with an email link?")}</h3>
<p className="muted">
Set your first password, or recover a forgotten one, using a link
sent to your account email.
{t(
"Set your first password, or recover a forgotten one, using a link sent to your account email.",
)}
</p>
<button className="button" disabled={sending} onClick={recover}>
{sending ? "Sending…" : "Email me a password reset link"}
{sending ? t("Sending…") : t("Email me a password reset link")}
</button>
{recoveryMessage && (
<p role="status" className="success-message">

View File

@@ -1,13 +1,18 @@
"use client";
import { useI18n } from "@/components/I18nProvider";
import Link from "next/link";
import { useSiteSettings } from "./SiteSettingsProvider";
export function Brand() {
const { t } = useI18n();
const { settings } = useSiteSettings();
return (
<Link href="/" className="brand" aria-label="MineGeo home">
<Link href="/" className="brand" aria-label={t("MineGeo home")}>
<span className="brand-cube">
<span />
</span>
Mine<span>Geo</span>
<sup>BETA</sup>
{settings.betaBadgeEnabled && <sup>BETA</sup>}
</Link>
);
}

View File

@@ -1,8 +1,11 @@
"use client";
import { useI18n } from "@/components/I18nProvider";
import { useState } from "react";
import { Search } from "lucide-react";
import { MapCard, type CardMap } from "./MapCard";
export function CatalogBrowser({ maps }: { maps: CardMap[] }) {
const { t } = useI18n();
const [query, setQuery] = useState(""),
[source, setSource] = useState("all");
const shown = maps.filter(
@@ -18,17 +21,17 @@ export function CatalogBrowser({ maps }: { maps: CardMap[] }) {
<label className="search-field">
<Search size={17} />
<input
aria-label="Search worlds"
placeholder="Search worlds or servers…"
aria-label={t("Search worlds")}
placeholder={t("Search worlds or servers…")}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</label>
<div className="tabs" aria-label="Capture source">
<div className="tabs" aria-label={t("Capture source")}>
{[
["all", "All worlds"],
["plugin", "Server exports"],
["fabric", "Player captures"],
["all", t("All worlds")],
["plugin", t("Server exports")],
["fabric", t("Player captures")],
].map(([id, label]) => (
<button
key={id}
@@ -36,13 +39,13 @@ export function CatalogBrowser({ maps }: { maps: CardMap[] }) {
aria-pressed={source === id}
onClick={() => setSource(id)}
>
{label}
{t(label)}
</button>
))}
</div>
</div>
<p className="muted">
{shown.length} {shown.length === 1 ? "world" : "worlds"} to explore
{t("Worlds to explore: {count}", { count: shown.length })}
</p>
<div className="cards-grid">
{shown.map((m, i) => (
@@ -50,7 +53,7 @@ export function CatalogBrowser({ maps }: { maps: CardMap[] }) {
))}
</div>
{!shown.length && (
<p className="empty-state">No worlds match that search.</p>
<p className="empty-state">{t("No worlds match that search.")}</p>
)}
</>
);

View File

@@ -1,3 +1,5 @@
"use client";
import { useI18n } from "@/components/I18nProvider";
import Link from "next/link";
export function ContributionInstructions({
id,
@@ -10,57 +12,63 @@ export function ContributionInstructions({
serverAddress: string | null;
isOwner?: boolean;
}) {
const { t } = useI18n();
return (
<div className="token-box">
<h3>Add panoramas to this map</h3>
<h3>{t("Add panoramas to this map")}</h3>
{serverAddress ? (
<div>
<p>Contribution server:</p>
<p>{t("Contribution server:")}</p>
<code>{serverAddress}</code>
<p>
Capture on this server. Uploads from a different address are
blocked.
{t(
"Capture on this server. Uploads from a different address are blocked.",
)}
</p>
</div>
) : (
<p className="muted">
Server check is off. The owner has not set a server address; captures
from other servers can be submitted. Only submit panoramas from this
map’s world.
{t(
"Server check is off. The owner has not set a server address; captures from other servers can be submitted. Only submit panoramas from this map’s world.",
)}
</p>
)}
<p>
Capture in the same Minecraft world, within X {bounds.minX} to{" "}
{bounds.maxX} and Z {bounds.minZ} to {bounds.maxZ}.{" "}
{t("Capture in the same Minecraft world, within X")} {bounds.minX}{" "}
{t("to")} {bounds.maxX} {t("and Z")} {bounds.minZ} {t("to")}{" "}
{bounds.maxZ}.{" "}
{isOwner
? "As the owner, you can fill the map’s 24 panorama slots."
: "Each guest contributor may add up to 12 panoramas in total."}{" "}
The map holds at most 24, including pending uploads and unselected
captures.
? t("As the owner, you can fill the map’s 24 panorama slots.")
: t(
"Each guest contributor may add up to 12 panoramas in total.",
)}{" "}
{t(
"The map holds at most 24, including pending uploads and unselected captures.",
)}
</p>
<p>
In the Fabric mod, paste this map ID into “Add to map”. For the server
plugin, export the area and run:
{t(
"In the Fabric mod, paste this map ID into “Add to map”. For the server plugin, export the area and run:",
)}
</p>
<code className="command-line">/minegeo contribute {id}</code>
{isOwner && (
<p>
This command also adds your own panoramas. Open the resulting
contribution in your dashboard, submit it, then accept it in this
map’s submissions below. Finally, submit the map update for moderator
review.
{t(
"This command also adds your own panoramas. Open the resulting contribution in your dashboard, submit it, then accept it in this map’s submissions below. Finally, submit the map update for moderator review.",
)}
</p>
)}
<p>
Map ID: <code>{id}</code>
{t("Map ID:")} <code>{id}</code>
</p>
<p>
Open your dashboard after processing, inspect your panoramas, and submit
them to the owner. The owner approves them first, then moderators review
the map update.
{t(
"Open your dashboard after processing, inspect your panoramas, and submit them to the owner. The owner approves them first, then moderators review the map update.",
)}
</p>
<Link className="button" href="/downloads">
Get the capture tools →
{t("Get the capture tools →")}
</Link>
</div>
);

View File

@@ -1,4 +1,7 @@
"use client";
import { statusMessage } from "@/shared/i18n";
import { useI18n } from "@/components/I18nProvider";
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { createBrowserClient } from "@supabase/ssr";
@@ -11,6 +14,7 @@ type Draft = MapListing & {
upload_jobs: ProcessingJob | null;
};
export function Dashboard() {
const { t } = useI18n();
const [maps, setMaps] = useState<Draft[]>([]),
[error, setError] = useState(""),
[tokens, setTokens] = useState<
@@ -77,33 +81,34 @@ export function Dashboard() {
<>
<div className="dashboard-toolbar">
<p className="muted">
Your maps start privately. You decide when they’re ready for review.
Contributions are hidden here after five days. Their panoramas and
direct links stay available.
{t(
"Your maps start privately. You decide when they’re ready for review. Contributions are hidden here after five days. Their panoramas and direct links stay available.",
)}
</p>
<div className="inline-actions">
<Link className="button" href="/settings">
Account settings
{t("Account settings")}
</Link>
<button className="button" onClick={logout}>
Sign out
{t("Sign out")}
</button>
</div>
</div>
{error && (
<p role="alert" className="error-message">
{error}
{t(error)}
</p>
)}
{!maps.length && (
<div className="empty-state">
<h2>A blank map is a beginning.</h2>
<h2>{t("A blank map is a beginning.")}</h2>
<p>
Install the Fabric mod to capture while you explore, or the server
plugin to export your world.
{t(
"Install the Fabric mod to capture while you explore, or the server plugin to export your world.",
)}
</p>
<Link className="button primary" href="/downloads">
Get the capture tools →
{t("Get the capture tools →")}
</Link>
</div>
)}
@@ -111,24 +116,34 @@ export function Dashboard() {
<article className="draft-row" key={m.id}>
<div>
<span className="eyebrow">
{m.source === "plugin" ? "SERVER EXPORT" : "PLAYER CAPTURE"}
{m.source === "plugin" ? t("SERVER EXPORT") : t("PLAYER CAPTURE")}
</span>
<h3>{m.name}</h3>
<span className="badge">
{m.contribution_status
? `Contribution: ${m.contribution_status}`
? t("Contribution: {status}", {
status: t(statusMessage(m.contribution_status)),
})
: m.update_state
? `Update: ${m.update_state}`
: m.state}
? t("Update: {status}", {
status: t(statusMessage(m.update_state)),
})
: t(statusMessage(m.state))}
</span>{" "}
<span className="muted">{m.panorama_count} locations</span>
<span className="muted">
{m.panorama_count} {t("locations")}
</span>
{m.banned_at ? (
<p className="error-message">
Map banned after three confirmed reports. Open your map to
appeal.
{t(
"Map banned after three confirmed reports. Open your map to appeal.",
)}
</p>
) : m.report_strikes > 0 ? (
<p className="muted">{m.report_strikes}/3 confirmed reports</p>
<p className="muted">
{m.report_strikes}
{t("/3 confirmed reports")}
</p>
) : null}
<ProcessingProgress job={m.upload_jobs} />
{m.rejection_reason && (
@@ -139,38 +154,42 @@ export function Dashboard() {
)}
</div>
<Link href={"/dashboard/" + m.id} className="button">
{m.parent_dataset_id ? "Open contribution →" : "Open map →"}
{m.parent_dataset_id ? t("Open contribution →") : t("Open map →")}
</Link>
</article>
))}
<section className="token-box">
<span className="eyebrow">FOR SERVER ADMINISTRATORS</span>
<h2>Link your server plugin.</h2>
<span className="eyebrow">{t("FOR SERVER ADMINISTRATORS")}</span>
<h2>{t("Link your server plugin.")}</h2>
<p className="muted">
Create a contributor token, then run{" "}
<code>/minegeo link &lt;token&gt;</code> in your server console.
Fabric uses browser pairing instead.
{t("Create a contributor token, then run")}{" "}
<code>/minegeo link &lt;token&gt;</code>{" "}
{t("in your server console. Fabric uses browser pairing instead.")}
</p>
<button className="button" disabled={busy} onClick={newToken}>
Generate plugin token
{t("Generate plugin token")}
</button>
{token && (
<>
<p className="success-message">
Copy this token now. It is shown once. Keep it out of public chat.
{t(
"Copy this token now. It is shown once. Keep it out of public chat.",
)}
</p>
<code>{token}</code>
</>
)}
{tokens.map((t) => (
<div className="draft-row" key={t.id}>
{tokens.map((entry) => (
<div className="draft-row" key={entry.id}>
<span>
{t.label}{" "}
<small className="muted">{t.revoked ? "· revoked" : ""}</small>
{entry.label}{" "}
<small className="muted">
{entry.revoked ? t("· revoked") : ""}
</small>
</span>
{!t.revoked && (
<button className="button" onClick={() => revoke(t.id)}>
Revoke
{!entry.revoked && (
<button className="button" onClick={() => revoke(entry.id)}>
{t("Revoke")}
</button>
)}
</div>

View File

@@ -1,4 +1,7 @@
"use client";
import { statusMessage } from "@/shared/i18n";
import { useI18n } from "@/components/I18nProvider";
import { useEffect, useRef, useState } from "react";
import Image from "next/image";
import { api } from "./StartGame";
@@ -31,6 +34,7 @@ type Draft = {
ban_reason: string | null;
};
export function DraftEditor({ id }: { id: string }) {
const { t } = useI18n();
const dirty = useRef(false);
const [draft, setDraft] = useState<Draft | null>(null),
[name, setName] = useState(""),
@@ -123,7 +127,9 @@ export function DraftEditor({ id }: { id: string }) {
}
if (!draft)
return (
<p role={error ? "alert" : undefined}>{error || "Loading your draft…"}</p>
<p role={error ? "alert" : undefined}>
{error ? t(error) : t("Loading your draft…")}
</p>
);
const editable =
(["draft", "rejected"].includes(draft.state) ||
@@ -139,11 +145,11 @@ export function DraftEditor({ id }: { id: string }) {
return (
<>
<div className="editor-navigation">
<Link href="/dashboard">← Your maps</Link>
<a href="#locations">Choose locations</a>
{!draft.parent_dataset_id && <a href="#map-tools">Map tools</a>}
<Link href="/dashboard">{t("← Your maps")}</Link>
<a href="#locations">{t("Choose locations")}</a>
{!draft.parent_dataset_id && <a href="#map-tools">{t("Map tools")}</a>}
</div>
<span className="badge">{draft.state}</span>
<span className="badge">{t(statusMessage(draft.state))}</span>
{!draft.parent_dataset_id &&
(draft.report_strikes > 0 || draft.banned_at) && (
<MapAppeal
@@ -155,47 +161,63 @@ export function DraftEditor({ id }: { id: string }) {
/>
)}
{draft.update_state && (
<span className="badge">Update: {draft.update_state}</span>
<span className="badge">
{t("Update:")} {t(statusMessage(draft.update_state))}
</span>
)}
{draft.contribution_status && (
<p role="status">
Contribution: {draft.contribution_status}.{" "}
{t("Contribution:")} {t(statusMessage(draft.contribution_status))}.{" "}
{draft.state === "failed"
? "This contribution no longer reserves panorama slots. Upload a fresh capture to try again."
? t(
"This contribution no longer reserves panorama slots. Upload a fresh capture to try again.",
)
: draft.contribution_status === "accepted"
? "The owner included your panoramas in the map draft. Map updates become playable after moderator approval."
? t(
"The owner included your panoramas in the map draft. Map updates become playable after moderator approval.",
)
: draft.contribution_status === "submitted"
? "Waiting for the map owner's approval."
? t("Waiting for the map owner's approval.")
: draft.contribution_status === "rejected"
? "Upload a fresh capture if you want to try again."
: "Choose your locations, then submit them to the map owner."}
? t("Upload a fresh capture if you want to try again.")
: t(
"Choose your locations, then submit them to the map owner.",
)}
</p>
)}
{draft.state === "published" && (
<p className="muted">
The approved version stays playable while you edit and moderators
review your update.
{t(
"The approved version stays playable while you edit and moderators review your update.",
)}
</p>
)}
<ProcessingProgress job={draft.upload_jobs} />
{draft.upload_jobs?.kind === "map" &&
draft.upload_jobs.state === "failed" && (
<p role="alert" className="error-message">
Map refresh failed: {draft.upload_jobs.error}. Your previous map is
still available. Try refreshing again below.
{t("Map refresh failed:")} {draft.upload_jobs.error}
{t(
". Your previous map is still available. Try refreshing again below.",
)}
</p>
)}
{refreshing && (
<p role="status" className="success-message">
Refreshing your guessing map. This page updates automatically; the
published version stays playable.
{t(
"Refreshing your guessing map. This page updates automatically; the published version stays playable.",
)}
</p>
)}
{["uploading", "processing"].includes(draft.state) && (
<p role="status" className="success-message">
{draft.state === "uploading"
? "Waiting for the capture tool to finish uploading. Retry upload in the tool if the connection was interrupted."
: "Your map and panoramas are being rendered. This page updates automatically."}
? t(
"Waiting for the capture tool to finish uploading. Retry upload in the tool if the connection was interrupted.",
)
: t(
"Your map and panoramas are being rendered. This page updates automatically.",
)}
</p>
)}
{draft.rejection_reason && (
@@ -205,8 +227,12 @@ export function DraftEditor({ id }: { id: string }) {
)}
<p className="muted">
{draft.source === "plugin"
? "Server-generated views use vanilla terrain and simplified lighting. Entities, shaders, custom packs and dynamic sign text are omitted."
: "Player captures show the terrain received by the client. Unexplored map coverage remains blank."}
? t(
"Server-generated views use vanilla terrain and simplified lighting. Entities, shaders, custom packs and dynamic sign text are omitted.",
)
: t(
"Player captures show the terrain received by the client. Unexplored map coverage remains blank.",
)}
</p>
{draft.map && (
<div className="detail-map" style={{ maxWidth: 600 }}>
@@ -215,15 +241,15 @@ export function DraftEditor({ id }: { id: string }) {
width={1024}
height={1024}
loading="eager"
alt="Draft surface map"
alt={t("Draft surface map")}
unoptimized
/>
</div>
)}
<section className="editor-panel">
<h2>Map details</h2>
<h2>{t("Map details")}</h2>
<label className="field">
Map name
{t("Map name")}
<input
value={name}
onChange={(e) => {
@@ -234,7 +260,7 @@ export function DraftEditor({ id }: { id: string }) {
/>
</label>
<label className="field">
Description
{t("Description")}
<textarea
value={description}
onChange={(e) => {
@@ -245,10 +271,12 @@ export function DraftEditor({ id }: { id: string }) {
/>
</label>
</section>
<h2 id="locations">Choose your locations.</h2>
<h2 id="locations">{t("Choose your locations.")}</h2>
<p className="muted">
Keep at least {minimum} clear, useful panorama{minimum === 1 ? "" : "s"}
. Selected: {selected.length} / 24
{t("Minimum panoramas: {minimum}. Selected: {selected} / 24", {
minimum,
selected: selected.length,
})}
</p>
<div className="preview-grid">
{draft.previews.map((p) => (
@@ -258,7 +286,7 @@ export function DraftEditor({ id }: { id: string }) {
type="checkbox"
checked={selected.includes(p.id)}
disabled={!editable || busy}
aria-label={`Keep ${p.label}`}
aria-label={t("Keep {location}", { location: p.label })}
onChange={(e) => {
dirty.current = true;
setSelected((s) =>
@@ -281,7 +309,7 @@ export function DraftEditor({ id }: { id: string }) {
</label>
{p.image && (
<button className="button full" onClick={() => setPreview(p.id)}>
Look around
{t("Look around")}
</button>
)}
</div>
@@ -301,7 +329,7 @@ export function DraftEditor({ id }: { id: string }) {
disabled={busy || refreshing || selected.length < minimum}
onClick={() => save()}
>
Save draft
{t("Save draft")}
</button>
<button
className="button primary"
@@ -309,8 +337,8 @@ export function DraftEditor({ id }: { id: string }) {
onClick={() => save(true)}
>
{draft.parent_dataset_id
? "Submit to map owner →"
: "Submit for review →"}
? t("Submit to map owner →")
: t("Submit for review →")}
</button>
</div>
)}
@@ -346,18 +374,18 @@ export function DraftEditor({ id }: { id: string }) {
))) && (
<button className="button" disabled={busy} onClick={cancel}>
{draft.parent_dataset_id
? "Withdraw contribution"
: "Cancel processing"}
? t("Withdraw contribution")
: t("Cancel processing")}
</button>
)}
{error && (
<p role="alert" className="error-message">
{error}
{t(error)}
</p>
)}
{message && (
<p role="status" className="success-message">
{message}
{t(message)}
</p>
)}
</>

View File

@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/I18nProvider";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import {
@@ -38,6 +40,7 @@ export type SessionView = {
}[];
};
export function Game({ id }: { id: string }) {
const { t, formatNumber } = useI18n();
const router = useRouter(),
[session, setSession] = useState<SessionView | null>(null),
[pin, setPin] = useState<Pin | null>(null),
@@ -207,28 +210,28 @@ export function Game({ id }: { id: string }) {
)}
<div className="game-info">
<div>
<small>ROUND</small>
<small>{t("ROUND")}</small>
<strong>{session ? session.round + 1 : "—"} / 5</strong>
</div>
<div>
<small>TIME LEFT</small>
<small>{t("TIME LEFT")}</small>
<strong className={remaining <= 10 ? "urgent" : ""}>
{Math.floor(remaining / 60)}:
{String(remaining % 60).padStart(2, "0")}
</strong>
</div>
<div>
<small>POINTS</small>
<strong>{total.toLocaleString()}</strong>
<small>{t("POINTS")}</small>
<strong>{formatNumber(total)}</strong>
</div>
</div>
</div>
{session && (
<div className={"game-map " + (expanded ? "expanded" : "")}>
<div className="map-topline">
<span>↑ N · PLACE YOUR GUESS</span>
<span>{t("↑ N · PLACE YOUR GUESS")}</span>
<button
aria-label={expanded ? "Collapse map" : "Expand map"}
aria-label={expanded ? t("Collapse map") : t("Expand map")}
onClick={() => setExpanded((v) => !v)}
>
{expanded ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
@@ -249,7 +252,7 @@ export function Game({ id }: { id: string }) {
<small>
{pin
? `X ${Math.round(pin.x)} · Z ${Math.round(pin.z)}`
: "Click the map to drop a pin"}
: t("Click the map to drop a pin")}
</small>
<button
className="button primary full"
@@ -257,64 +260,69 @@ export function Game({ id }: { id: string }) {
onClick={() => guess()}
>
<MapPin size={15} />
{busy ? "Checking your guess…" : "Submit guess"}
{busy ? t("Checking your guess…") : t("Submit guess")}
</button>
</div>
</div>
)}
<div className="look-help">
<span>
<Move size={12} /> DRAG TO LOOK AROUND
<Move size={12} /> {t("DRAG TO LOOK AROUND")}
</span>
<span>
<MousePointer2 size={12} />{" "}
{session?.difficulty === "hard" ? "NO ZOOM" : "SCROLL TO ZOOM"}
{session?.difficulty === "hard" ? t("NO ZOOM") : t("SCROLL TO ZOOM")}
</span>
</div>
{(!session || !loaded.panorama || !loaded.map) && !error && (
<div className="game-loading">
<div className="loading-cube" />
<h2>Preparing your expedition.</h2>
<p>Loading panorama and surface map…</p>
<h2>{t("Preparing your expedition.")}</h2>
<p>{t("Loading panorama and surface map…")}</p>
</div>
)}
{error && (
<div className="round-result" role="alert">
<h2>Connection interrupted.</h2>
<p>{error}</p>
<h2>{t("Connection interrupted.")}</h2>
<p>{t(error)}</p>
<button
className="button primary"
onClick={() => window.location.reload()}
>
Reconnect
{t("Reconnect")}
</button>
</div>
)}
{result && !error && (
<div className="round-result">
<span className="eyebrow">
ROUND {(session?.round || 0) + 1} COMPLETE
{t("ROUND")} {(session?.round || 0) + 1} {t("COMPLETE")}
</span>
<h2>
{result.timedOut
? "Time slipped away."
? t("Time slipped away.")
: result.score === 5000
? "Right on the block!"
? t("Right on the block!")
: result.score > 3000
? "You know your way around."
: "A little off the beaten block."}
? t("You know your way around.")
: t("A little off the beaten block.")}
</h2>
<div className="score">
{result.score.toLocaleString()}
{formatNumber(result.score)}
<small> / 5,000</small>
</div>
<p>
{result.timedOut
? "The clock ran out. This round scores zero."
: `${Math.round(result.distance || 0).toLocaleString()} blocks from the actual location.`}
? t("The clock ran out. This round scores zero.")
: t("{count} blocks from the actual location.", {
count: formatNumber(Math.round(result.distance || 0)),
})}
</p>
<button className="button primary full" onClick={next}>
{session?.round === 4 ? "See expedition results" : "Next round"} →
{session?.round === 4
? t("See expedition results")
: t("Next round")}{" "}
→
</button>
</div>
)}

View File

@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/I18nProvider";
import { useEffect, useRef, useState } from "react";
import type * as Leaflet from "leaflet";
import "leaflet/dist/leaflet.css";
@@ -23,6 +25,7 @@ export function GuessMap({
onError?: (message: string) => void;
disabled?: boolean;
}) {
const { t } = useI18n();
const root = useRef<HTMLDivElement>(null),
markers = useRef<Leaflet.LayerGroup | null>(null),
lib = useRef<typeof Leaflet | null>(null),
@@ -177,18 +180,20 @@ export function GuessMap({
role="application"
aria-label={
disabled
? "Minecraft map. North is up."
: "Minecraft map. North is up. Click to place a guess, or use arrow keys to pan and Enter to pin the center."
? t("Minecraft map. North is up.")
: t(
"Minecraft map. North is up. Click to place a guess, or use arrow keys to pan and Enter to pin the center.",
)
}
/>
{error && (
<div className="map-load-error" role="alert">
<p>{error}</p>
<p>{t(error)}</p>
<button
className="button"
onClick={() => setAttempt((value) => value + 1)}
>
Retry map
{t("Retry map")}
</button>
</div>
)}

View File

@@ -0,0 +1,45 @@
"use client";
import { createContext, useContext, useMemo } from "react";
import {
localeTag,
translator,
type Locale,
type LanguagePreference,
} from "@/shared/i18n";
type LanguageContext = {
locale: Locale;
preference: LanguagePreference;
detectedLocale: Locale;
};
const I18nContext = createContext<LanguageContext>({
locale: "en",
preference: "auto",
detectedLocale: "en",
});
export function I18nProvider({
children,
...language
}: LanguageContext & { children: React.ReactNode }) {
const value = useMemo(
() => language,
[language.locale, language.preference, language.detectedLocale],
);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function useI18n() {
const language = useContext(I18nContext);
return useMemo(
() => ({
...language,
t: translator(language.locale),
formatNumber: (value: number) =>
value.toLocaleString(localeTag(language.locale)),
formatDate: (value: string) =>
new Date(value).toLocaleDateString(localeTag(language.locale)),
}),
[language],
);
}

View File

@@ -0,0 +1,40 @@
"use client";
import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { Globe2 } from "lucide-react";
import { languageCookie, type LanguagePreference } from "@/shared/i18n";
import { useI18n } from "./I18nProvider";
export function LanguageSwitcher() {
const { t, preference, detectedLocale } = useI18n();
const [pending, startTransition] = useTransition();
const router = useRouter();
function changeLanguage(value: LanguagePreference) {
document.cookie = `${languageCookie}=${value}; Path=/; Max-Age=31536000; SameSite=Lax${location.protocol === "https:" ? "; Secure" : ""}`;
startTransition(() => router.refresh());
}
return (
<label className="language-switcher" aria-busy={pending}>
<Globe2 size={16} aria-hidden="true" />
<span className="sr-only">{t("Site language")}</span>
<select
aria-label={t("Site language")}
value={preference}
disabled={pending}
onChange={(event) =>
changeLanguage(event.target.value as LanguagePreference)
}
>
<option value="auto">
{t("Automatic")} · {detectedLocale === "ru" ? "RU" : "EN"}
</option>
<option value="en" lang="en">
English
</option>
<option value="ru" lang="ru">
Русский
</option>
</select>
</label>
);
}

View File

@@ -1,8 +1,9 @@
"use client";
import { useI18n } from "@/components/I18nProvider";
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { PixelTrophy, PlayerAvatar } from "./PixelArt";
import {
formatScore,
type LeaderboardData,
type LeaderboardEntry,
} from "@/shared/leaderboard";
@@ -14,6 +15,7 @@ export function LeaderboardEmpty({
unavailable?: boolean;
search?: boolean;
}) {
const { t } = useI18n();
return (
<div
className="leaderboard-empty"
@@ -22,24 +24,24 @@ export function LeaderboardEmpty({
<PixelTrophy />
<h2>
{unavailable
? "The scoreboard is taking a break."
? t("The scoreboard is taking a break.")
: search
? "No explorers found."
: "The first place is still open."}
? t("No explorers found.")
: t("The first place is still open.")}
</h2>
<p>
{unavailable
? "We couldn’t load the scores. Try again in a moment."
? t("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."}
? t("Try another player name or clear your search.")
: t("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"}{" "}
{unavailable ? t("Reload scores") : t("Play an expedition")}{" "}
<ArrowRight size={16} />
</Link>
)}
@@ -56,31 +58,33 @@ export function LeaderboardTable({
compact?: boolean;
playerId?: string;
}) {
const { t, formatNumber } = useI18n();
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.
{t(
"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
{t("Rank")}
</th>
<th scope="col">Explorer</th>
<th scope="col">{t("Explorer")}</th>
{!compact && (
<th scope="col" className="games-column">
Games
{t("Games")}
</th>
)}
{!compact && (
<th scope="col" className="average-column">
Average
{t("Average")}
</th>
)}
<th scope="col" className="score-column">
Best score
{t("Best score")}
</th>
</tr>
</thead>
@@ -103,24 +107,24 @@ export function LeaderboardTable({
<span className="player-name">
{entry.display_name}
{entry.player_id === playerId && (
<span className="you-label">You</span>
<span className="you-label">{t("You")}</span>
)}
</span>
</div>
</th>
{!compact && (
<td className="games-column">
{formatScore(entry.games_played)}
{formatNumber(entry.games_played)}
</td>
)}
{!compact && (
<td className="average-column">
{formatScore(entry.average_score)}
{formatNumber(entry.average_score)}
</td>
)}
<td className="score-column">
<strong>{formatScore(entry.best_score)}</strong>
<span className="score-unit"> pts</span>
<strong>{formatNumber(entry.best_score)}</strong>
<span className="score-unit"> {t("pts")}</span>
</td>
</tr>
))}
@@ -131,8 +135,9 @@ export function LeaderboardTable({
}
export function WinnersPodium({ leaders }: { leaders: LeaderboardEntry[] }) {
const { t, formatNumber } = useI18n();
return (
<section className="winners-podium" aria-label="Top three explorers">
<section className="winners-podium" aria-label={t("Top three explorers")}>
{leaders.map((entry, index) => (
<article
className={`podium-place podium-${index + 1}`}
@@ -140,12 +145,16 @@ export function WinnersPodium({ leaders }: { leaders: LeaderboardEntry[] }) {
>
<div className="podium-player">
<span className="podium-label">
{index === 0 ? "TOP EXPLORER" : `PLACE 0${index + 1}`}
{index === 0
? t("TOP EXPLORER")
: t("Place {place}", {
place: String(index + 1).padStart(2, "0"),
})}
</span>
<PlayerAvatar id={entry.player_id} />
<h2>{entry.display_name}</h2>
<p>
{formatScore(entry.best_score)} <span>/ 25,000</span>
{formatNumber(entry.best_score)} <span>/ 25,000</span>
</p>
</div>
<div className="podium-block" aria-hidden="true">
@@ -159,6 +168,7 @@ export function WinnersPodium({ leaders }: { leaders: LeaderboardEntry[] }) {
}
export function LeaderboardPreview({ data }: { data: LeaderboardData | null }) {
const { t, formatNumber } = useI18n();
return (
<section
className="section home-leaderboard"
@@ -167,24 +177,24 @@ export function LeaderboardPreview({ data }: { data: LeaderboardData | null }) {
<div className="leaderboard-preview-copy">
<div className="leaderboard-kicker">
<PixelTrophy />
<span className="eyebrow">THE EXPLORERS’ BOARD</span>
<span className="eyebrow">{t("THE EXPLORERS’ BOARD")}</span>
</div>
<h2 id="home-leaderboard-heading">
Good eye.
<br /> <span>Great guess.</span>
{t("Good eye.")}
<br /> <span>{t("Great guess.")}</span>
</h2>
<p>
Five rounds to find your way.
<br /> One score to make your mark.
{t("Five rounds to find your way.")}
<br /> {t("One score to make your mark.")}
</p>
<Link className="text-link" href="/leaderboard">
See the full leaderboard <ArrowRight size={16} />
{t("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>
<span>{t("Top explorers")}</span>
<span>{t("All difficulties · All time")}</span>
</div>
{data?.entries.length ? (
<LeaderboardTable entries={data.entries} compact />
@@ -192,10 +202,12 @@ export function LeaderboardPreview({ data }: { data: LeaderboardData | null }) {
<LeaderboardEmpty unavailable={!data} />
)}
<div className="board-footnote">
Best expedition score ·{" "}
{t("Best expedition score ·")}{" "}
{data
? `${formatScore(data.total_players)} ranked ${data.total_players === 1 ? "explorer" : "explorers"}`
: "Scores temporarily unavailable"}
? t("Ranked explorers: {count}", {
count: formatNumber(data.total_players),
})
: t("Scores temporarily unavailable")}
</div>
</div>
</section>

View File

@@ -1,4 +1,7 @@
"use client";
import { statusMessage } from "@/shared/i18n";
import { useI18n } from "@/components/I18nProvider";
import { useCallback, useEffect, useState } from "react";
import { api } from "./StartGame";
@@ -24,6 +27,7 @@ export function MapAppeal({
banReason: string | null;
onChange: () => void;
}) {
const { t } = useI18n();
const [appeals, setAppeals] = useState<Appeal[]>([]);
const [reason, setReason] = useState("");
const [error, setError] = useState("");
@@ -61,30 +65,41 @@ export function MapAppeal({
<section className="tool-card">
<h2>
{bannedAt
? "Map unpublished · appeal required"
: "Community report strikes"}
? t("Map unpublished · appeal required")
: t("Community report strikes")}
</h2>
<p>
{strikes}/3 confirmed reports.{" "}
{strikes}
{t("/3 confirmed reports.")}{" "}
{bannedAt
? "This map cannot be published until a moderator approves your appeal."
: "Three confirmed reports automatically unpublish this map."}
? t(
"This map cannot be published until a moderator approves your appeal.",
)
: t("Three confirmed reports automatically unpublish this map.")}
</p>
{banReason && <p>{banReason}</p>}
{appeals.map((a) => (
<div key={a.id}>
<span className="badge">Appeal: {a.state}</span>
<span className="badge">
{t("Appeal:")} {t(statusMessage(a.state))}
</span>
<p>{a.reason}</p>
{a.review_reason && <p>Moderator feedback: {a.review_reason}</p>}
{a.review_reason && (
<p>
{t("Moderator feedback:")} {a.review_reason}
</p>
)}
</div>
))}
{bannedAt &&
(appeals.some((a) => a.state === "open") ? (
<p role="status">Your appeal is waiting for moderator review.</p>
<p role="status">
{t("Your appeal is waiting for moderator review.")}
</p>
) : (
<form onSubmit={submit}>
<label className="field">
Explain why the map ban should be lifted
{t("Explain why the map ban should be lifted")}
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
@@ -97,13 +112,13 @@ export function MapAppeal({
className="button primary"
disabled={busy || reason.trim().length < 10}
>
{busy ? "Sending…" : "Appeal map ban"}
{busy ? t("Sending…") : t("Appeal map ban")}
</button>
</form>
))}
{error && (
<p role="alert" className="error-message">
{error}
{t(error)}
</p>
)}
</section>

View File

@@ -1,3 +1,5 @@
"use client";
import { useI18n } from "@/components/I18nProvider";
import Image from "next/image";
import Link from "next/link";
import { ArrowUpRight, MapPin } from "lucide-react";
@@ -21,13 +23,14 @@ export function MapCard({
index?: number;
minecraftPaper?: boolean;
}) {
const { t } = useI18n();
return (
<Link className="map-card" href={`/servers/${map.id}`}>
<div className={`card-image${minecraftPaper ? " minecraft-paper" : ""}`}>
<div className="card-map-content">
<Image
src={map.image || "/images/example-map.webp"}
alt={`${map.name} terrain`}
alt={t("Terrain of {name}", { name: map.name })}
fill
loading={index < 3 ? "eager" : "lazy"}
unoptimized
@@ -35,26 +38,30 @@ export function MapCard({
/>
</div>
<span className="card-type">
{map.source === "plugin" ? "SERVER EXPORT" : "PLAYER CAPTURE"}
{map.source === "plugin" ? t("SERVER EXPORT") : t("PLAYER CAPTURE")}
</span>
<span className="card-number">0{index + 1}</span>
</div>
<div className="card-body">
<span className="eyebrow">{map.server?.name || "Overworld"}</span>
<span className="eyebrow">{map.server?.name || t("Overworld")}</span>
<h3>
{map.name}
<ArrowUpRight size={20} />
</h3>
<p>{map.description}</p>
{map.credit && <p className="map-credit">By {map.credit}</p>}
{map.credit && (
<p className="map-credit">
{t("By")} {map.credit}
</p>
)}
<div className="card-meta">
<span>
<MapPin size={13} />
{map.panorama_count} locations
{map.panorama_count} {t("locations")}
</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,4 +1,6 @@
"use client";
import { useI18n } from "@/components/I18nProvider";
import { useEffect, useState } from "react";
import { api } from "./StartGame";
@@ -11,6 +13,7 @@ export function MapRefresh({
disabled: boolean;
onQueued: () => void;
}) {
const { t, formatDate } = useI18n();
const [sources, setSources] = useState<
{ id: string; name: string; created_at: string }[]
>([]);
@@ -44,15 +47,15 @@ export function MapRefresh({
}
return (
<section className="editor-panel">
<span className="eyebrow">THE MAP PLAYERS GUESS ON</span>
<h2>Refresh guessing map</h2>
<span className="eyebrow">{t("THE MAP PLAYERS GUESS ON")}</span>
<h2>{t("Refresh guessing map")}</h2>
<p className="muted">
Rebuild the overhead map from a processed capture. To show new
buildings, export the same area again and upload it first. Select that
capture here; its boundaries must match this map exactly.
{t(
"Rebuild the overhead map from a processed capture. To show new buildings, export the same area again and upload it first. Select that capture here; its boundaries must match this map exactly.",
)}
</p>
<label className="field">
Terrain capture
{t("Terrain capture")}
<select
value={source}
onChange={(e) => setSource(e.target.value)}
@@ -60,8 +63,8 @@ export function MapRefresh({
>
{sources.map((s) => (
<option key={s.id} value={s.id}>
{s.id === id ? "Original capture" : s.name} ·{" "}
{new Date(s.created_at).toLocaleDateString()} · {s.id.slice(0, 8)}
{s.id === id ? t("Original capture") : s.name} ·{" "}
{formatDate(s.created_at)} · {s.id.slice(0, 8)}
</option>
))}
</select>
@@ -71,15 +74,16 @@ export function MapRefresh({
disabled={disabled || busy || !sources.some((s) => s.id === source)}
onClick={refresh}
>
{busy ? "Queueing…" : "Refresh guessing map"}
{busy ? t("Queueing…") : t("Refresh guessing map")}
</button>
<p className="muted">
Panoramas stay unchanged. Refreshing uses no panorama slots, even at
24/24. Submit the finished map update for review to make it playable.
{t(
"Panoramas stay unchanged. Refreshing uses no panorama slots, even at 24/24. Submit the finished map update for review to make it playable.",
)}
</p>
{error && (
<p role="alert" className="error-message">
{error}
{t(error)}
</p>
)}
</section>

View File

@@ -1,10 +1,14 @@
"use client";
import { statusMessage } from "@/shared/i18n";
import { useI18n } from "@/components/I18nProvider";
import { useEffect, useState } from "react";
import Image from "next/image";
import { api } from "./StartGame";
import { PanoramaPreview } from "./PanoramaPreview";
import { reportCategories } from "@/shared/moderation";
import type { Appeal } from "./MapAppeal";
import { ModeratorSettings } from "./ModeratorSettings";
type ReviewMap = {
id: string;
name: string;
@@ -19,6 +23,7 @@ type ReviewMap = {
banned_at: string | null;
};
export function Moderation() {
const { t, locale } = useI18n();
const [maps, setMaps] = useState<ReviewMap[]>([]),
[reports, setReports] = useState<
{
@@ -39,6 +44,7 @@ export function Moderation() {
[reasons, setReasons] = useState<Record<string, string>>({});
const [preview, setPreview] = useState<string | null>(null),
[loading, setLoading] = useState(true);
const [authorized, setAuthorized] = useState(false);
const [reportPreview, setReportPreview] = useState<{
id: string;
panorama: string | null;
@@ -51,10 +57,12 @@ export function Moderation() {
appeals: Appeal[];
}>("moderation");
setMaps(d.maps);
setAuthorized(true);
setReports(d.reports);
setAppeals(d.appeals);
setError("");
} catch (e) {
setAuthorized(false);
setError((e as Error).message);
} finally {
setLoading(false);
@@ -105,43 +113,45 @@ export function Moderation() {
}
return (
<>
{authorized && <ModeratorSettings />}
{error && (
<p role="alert" className="error-message">
{error}
{t(error)}
</p>
)}
{loading && <p role="status">Loading review queue…</p>}
{loading && <p role="status">{t("Loading review queue…")}</p>}
{!loading && !maps.length && !error && (
<p className="muted">The review queue is clear.</p>
<p className="muted">{t("The review queue is clear.")}</p>
)}
{maps.map((m) => (
<article className="tool-card" key={m.id}>
<span className="badge">{m.state}</span>
<span className="badge">{t(statusMessage(m.state))}</span>
{m.update_state === "review" && (
<span className="badge">Map update awaiting review</span>
<span className="badge">{t("Map update awaiting review")}</span>
)}
<h2>{m.name}</h2>
{m.report_strikes > 0 && (
<p className={m.banned_at ? "error-message" : "muted"}>
{m.report_strikes}/3 confirmed reports
{m.banned_at ? " · Banned; owner appeal required" : ""}
{m.report_strikes}
{t("/3 confirmed reports")}
{m.banned_at ? t(" · Banned; owner appeal required") : ""}
</p>
)}
<p className="muted">
{m.source} · {m.panorama_count} locations · {m.description}
{m.source} · {m.panorama_count} {t("locations ·")} {m.description}
</p>
{m.map && (
<Image
src={m.map}
width={512}
height={512}
alt="Map for moderator review"
alt={t("Map for moderator review")}
unoptimized
style={{ maxWidth: "100%", height: "auto" }}
/>
)}
<label className="field">
Review reason
{t("Review reason")}
<textarea
value={reasons[m.id] || ""}
onChange={(e) =>
@@ -151,7 +161,7 @@ export function Moderation() {
</label>
<div className="inline-actions">
<button className="button" onClick={() => setPreview(m.id)}>
Inspect 360° locations
{t("Inspect 360° locations")}
</button>
{m.state === "review" || m.update_state === "review" ? (
<>
@@ -161,15 +171,15 @@ export function Moderation() {
onClick={() => decide(m.id, "approve")}
>
{m.update_state === "review"
? "Approve update"
: "Approve map"}
? t("Approve update")
: t("Approve map")}
</button>
<button
className="button"
disabled={busy || !reasons[m.id]?.trim()}
onClick={() => decide(m.id, "reject")}
>
Return with feedback
{t("Return with feedback")}
</button>
</>
) : null}
@@ -179,7 +189,7 @@ export function Moderation() {
disabled={busy}
onClick={() => decide(m.id, "unpublish")}
>
Unpublish
{t("Unpublish")}
</button>
)}
</div>
@@ -187,10 +197,11 @@ export function Moderation() {
))}
{reports.length > 0 && (
<section className="token-box">
<h2>Community reports</h2>
<h2>{t("Community reports")}</h2>
<p className="muted">
Confirming a valid report adds one strike to the map. Three strikes
automatically unpublish it. Dismissed reports add no strikes.
{t(
"Confirming a valid report adds one strike to the map. Three strikes automatically unpublish it. Dismissed reports add no strikes.",
)}
</p>
{reports.map((r) => (
<article className="draft-row" key={r.id}>
@@ -198,9 +209,13 @@ export function Moderation() {
<h3>{r.mapName}</h3>
<code>{r.dataset_id}</code>
<p>
{r.panorama_id ? `Panorama: ${r.panorama_id}` : "Entire map"}{" "}
{r.panorama_id
? `Panorama: ${r.panorama_id}`
: t("Entire map")}{" "}
· {reportCategories[r.category]} ·{" "}
{new Date(r.created_at).toLocaleString()}
{new Date(r.created_at).toLocaleString(
locale === "ru" ? "ru-RU" : "en-GB",
)}
</p>
<p>{r.reason}</p>
{r.evidence_url && (
@@ -210,7 +225,7 @@ export function Moderation() {
target="_blank"
rel="noopener noreferrer"
>
Open evidence
{t("Open evidence")}
</a>
</p>
)}
@@ -219,7 +234,7 @@ export function Moderation() {
src={r.map}
width={320}
height={320}
alt="Reported map version"
alt={t("Reported map version")}
unoptimized
style={{ maxWidth: "100%", height: "auto" }}
/>
@@ -230,10 +245,10 @@ export function Moderation() {
setReportPreview({ id: r.id, panorama: r.panorama_id })
}
>
Inspect reported content
{t("Inspect reported content")}
</button>
<label className="field">
Decision reason
{t("Decision reason")}
<textarea
maxLength={2000}
value={reasons[r.id] ?? ""}
@@ -252,14 +267,14 @@ export function Moderation() {
}
onClick={() => resolveReport(r.id, "confirm")}
>
Confirm report · add strike
{t("Confirm report · add strike")}
</button>
<button
className="button"
disabled={busy || (reasons[r.id]?.trim().length ?? 0) < 10}
onClick={() => resolveReport(r.id, "dismiss")}
>
Dismiss report
{t("Dismiss report")}
</button>
</div>
</div>
@@ -269,7 +284,7 @@ export function Moderation() {
)}
{appeals.length > 0 && (
<section className="token-box">
<h2>Map ban appeals</h2>
<h2>{t("Map ban appeals")}</h2>
{appeals.map((a) => (
<article className="tool-card" key={a.id}>
<h3>
@@ -280,10 +295,10 @@ export function Moderation() {
className="button"
onClick={() => setPreview(a.dataset_id)}
>
Inspect map panoramas
{t("Inspect map panoramas")}
</button>
<label className="field">
Appeal decision reason
{t("Appeal decision reason")}
<textarea
maxLength={2000}
value={reasons[a.id] ?? ""}
@@ -298,14 +313,14 @@ export function Moderation() {
disabled={busy || (reasons[a.id]?.trim().length ?? 0) < 10}
onClick={() => reviewAppeal(a.id, "approve")}
>
Approve appeal · restore map
{t("Approve appeal · restore map")}
</button>
<button
className="button"
disabled={busy || (reasons[a.id]?.trim().length ?? 0) < 10}
onClick={() => reviewAppeal(a.id, "reject")}
>
Reject appeal · keep ban
{t("Reject appeal · keep ban")}
</button>
</div>
</article>

View File

@@ -0,0 +1,83 @@
"use client";
import { useState } from "react";
import { Settings2 } from "lucide-react";
import { useI18n } from "./I18nProvider";
import { useSiteSettings } from "./SiteSettingsProvider";
import { api } from "@/lib/client-api";
import type { SiteSettings } from "@/shared/site-settings";
export function ModeratorSettings() {
const { t } = useI18n();
const { settings, updateSettings } = useSiteSettings();
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [saved, setSaved] = useState(false);
async function toggle() {
setBusy(true);
setError("");
setSaved(false);
try {
const next = await api<SiteSettings>(
"moderation/settings",
{
betaBadgeEnabled: !settings.betaBadgeEnabled,
},
"PATCH",
);
updateSettings(next);
setSaved(true);
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<section
className="tool-card moderator-settings"
aria-labelledby="site-settings-heading"
>
<div className="settings-heading">
<Settings2 size={24} aria-hidden="true" />
<div>
<h2 id="site-settings-heading">{t("Site settings")}</h2>
<p>
{t(
"Changes apply to every player. Only moderators can edit these settings.",
)}
</p>
</div>
</div>
<div className="moderator-setting-row">
<div>
<h3>{t("Beta badge")}</h3>
<p className="muted">
{t("Show BETA next to the MineGeo logo throughout the site.")}
</p>
<p className="muted">
{settings.betaBadgeEnabled
? t("Visible to all players")
: t("Hidden for all players")}
</p>
</div>
<button className="button" disabled={busy} onClick={toggle}>
{busy
? t("Saving…")
: settings.betaBadgeEnabled
? t("Hide beta badge")
: t("Show beta badge")}
</button>
</div>
{error && (
<p role="alert" className="error-message">
{t(error)}
</p>
)}
{saved && (
<p role="status" className="muted">
{t("Setting saved. Open pages update within 30 seconds.")}
</p>
)}
</section>
);
}

View File

@@ -1,4 +1,7 @@
"use client";
import { statusMessage } from "@/shared/i18n";
import { useI18n } from "@/components/I18nProvider";
import { useCallback, useEffect, useState } from "react";
import { api } from "./StartGame";
import { PanoramaPreview } from "./PanoramaPreview";
@@ -31,6 +34,7 @@ export function OwnerContributions({
onChange: () => void;
count: number;
}) {
const { t } = useI18n();
const [items, setItems] = useState<Contribution[]>([]),
[address, setAddress] = useState(serverAddress ?? ""),
[saved, setSaved] = useState(""),
@@ -113,11 +117,13 @@ export function OwnerContributions({
}
return (
<section className="token-box">
<h2>Add panoramas · {count}/24</h2>
<h2>
{t("Add panoramas ·")} {count}/24
</h2>
<p className="muted">
Owners can fill all 24 slots, even with player contributions turned off.
Export a fresh capture, then use the command below to add it to this
map.
{t(
"Owners can fill all 24 slots, even with player contributions turned off. Export a fresh capture, then use the command below to add it to this map.",
)}
</p>
<label>
<input
@@ -126,15 +132,15 @@ export function OwnerContributions({
disabled={busy}
onChange={(e) => toggle(e.target.checked)}
/>{" "}
Allow players to add panoramas with you
{t("Allow players to add panoramas with you")}
</label>
<p className="muted">
Five random locations are picked for each game. Add up to 24 locations
to make repeat games more varied. You can use the map ID below to add
your own panoramas too.
{t(
"Five random locations are picked for each game. Add up to 24 locations to make repeat games more varied. You can use the map ID below to add your own panoramas too.",
)}
</p>
<label className="field">
Contribution server address
{t("Contribution server address")}
<input
value={address}
placeholder="play.example.com:25565"
@@ -146,11 +152,9 @@ export function OwnerContributions({
/>
</label>
<p className="muted">
Use the Minecraft hostname or IP players connect to. This address is
shown publicly when player contributions are enabled. The mod records
the connected server; plugin admins set server-address before exporting.
You can override the captured address here, or clear it to allow
unchecked submissions.
{t(
"Use the Minecraft hostname or IP players connect to. This address is shown publicly when player contributions are enabled. The mod records the connected server; plugin admins set server-address before exporting. You can override the captured address here, or clear it to allow unchecked submissions.",
)}
</p>
<div className="inline-actions">
<button
@@ -158,36 +162,40 @@ export function OwnerContributions({
disabled={busy}
onClick={() => saveAddress(address)}
>
Save server address
{t("Save server address")}
</button>
<button
className="button"
disabled={busy || !serverAddress}
onClick={() => saveAddress("")}
>
Clear address and turn off check
{t("Clear address and turn off check")}
</button>
</div>
{saved && <p role="status">{saved}</p>}
{saved && <p role="status">{t(saved)}</p>}
<ContributionInstructions
id={id}
bounds={bounds}
serverAddress={serverAddress}
isOwner
/>
<h3>Player submissions</h3>
<h3>{t("Player submissions")}</h3>
<p className="muted">
Submissions older than five days are hidden from this list. Accepted
panoramas stay in your map.
{t(
"Submissions older than five days are hidden from this list. Accepted panoramas stay in your map.",
)}
</p>
{locked && (
<p className="muted">
Wait for the current moderator review before accepting another
submission.
{t(
"Wait for the current moderator review before accepting another submission.",
)}
</p>
)}
{!items.length && (
<p className="muted">No contributions from the last five days.</p>
<p className="muted">
{t("No contributions from the last five days.")}
</p>
)}
{items.map((c) => (
<article className="tool-card" key={c.id}>
@@ -196,17 +204,20 @@ export function OwnerContributions({
{c.state === "failed" ? "failed" : c.contribution_status}
</span>
<p>
{c.panorama_count || c.contribution_count} panoramas · {c.state}
{c.panorama_count || c.contribution_count} {t("panoramas ·")}{" "}
{t(statusMessage(c.state))}
</p>
<p className="muted">
{t("Contributor:")} {c.contributorName}
</p>
<p className="muted">Contributor: {c.contributorName}</p>
{c.rejection_reason && <p>{c.rejection_reason}</p>}
{c.contribution_status === "submitted" && (
<>
<button className="button" onClick={() => setPreview(c.id)}>
Inspect submitted panoramas
{t("Inspect submitted panoramas")}
</button>
<label className="field">
Feedback
{t("Feedback")}
<textarea
value={reasons[c.id] ?? ""}
onChange={(e) =>
@@ -220,14 +231,14 @@ export function OwnerContributions({
disabled={busy || locked}
onClick={() => decide(c.id, "accept")}
>
Accept into map draft
{t("Accept into map draft")}
</button>
<button
className="button"
disabled={busy || locked || !reasons[c.id]?.trim()}
onClick={() => decide(c.id, "reject")}
>
Reject with feedback
{t("Reject with feedback")}
</button>
</div>
</>
@@ -242,7 +253,7 @@ export function OwnerContributions({
)}
{error && (
<p className="error-message" role="alert">
{error}
{t(error)}
</p>
)}
</section>

View File

@@ -1,7 +1,10 @@
"use client";
import { useI18n } from "@/components/I18nProvider";
import { useState } from "react";
import { api } from "./StartGame";
export function Pairing({ code }: { code: string }) {
const { t } = useI18n();
const [message, setMessage] = useState(""),
[busy, setBusy] = useState(false);
async function approve() {
@@ -17,22 +20,23 @@ export function Pairing({ code }: { code: string }) {
}
return (
<div className="auth-card">
<span className="eyebrow">LINK YOUR CAPTURE CLIENT</span>
<span className="eyebrow">{t("LINK YOUR CAPTURE CLIENT")}</span>
<h2>{code}</h2>
<p className="muted">
Approve only if this code is displayed by your MineGeo Fabric mod.
Linking lets that client upload private map drafts to your account.
{t(
"Approve only if this code is displayed by your MineGeo Fabric mod. Linking lets that client upload private map drafts to your account.",
)}
</p>
<button
className="button primary full"
disabled={busy || !!message}
onClick={approve}
>
Link my Minecraft client
{t("Link my Minecraft client")}
</button>
{message && (
<p role="status" className="success-message">
{message}
{t(message)}
</p>
)}
</div>

View File

@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/I18nProvider";
import { useEffect, useRef } from "react";
import { Plus, Minus, RotateCcw } from "lucide-react";
import * as THREE from "three";
@@ -21,6 +23,7 @@ export function Panorama({
onReady: () => void;
onError: (message: string) => void;
}) {
const { t } = useI18n();
const mount = useRef<HTMLDivElement>(null),
view = useRef({ yaw: 0, pitch: 0, fov: 80 });
const callbacks = useRef({ onReady, onError });
@@ -161,14 +164,15 @@ export function Panorama({
<div
className="panorama"
ref={mount}
aria-label="360 degree panorama: drag to look around"
data-native-cursor
aria-label={t("360 degree panorama: drag to look around")}
/>
{
<div className="panorama-controls">
{zoom && (
<>
<button
aria-label="Zoom in"
aria-label={t("Zoom in")}
onClick={() => {
view.current.fov = Math.max(35, view.current.fov - 10);
}}
@@ -176,7 +180,7 @@ export function Panorama({
<Plus size={17} />
</button>
<button
aria-label="Zoom out"
aria-label={t("Zoom out")}
onClick={() => {
view.current.fov = Math.min(100, view.current.fov + 10);
}}
@@ -186,7 +190,7 @@ export function Panorama({
</>
)}
<button
aria-label="Reset view"
aria-label={t("Reset view")}
onClick={() => {
view.current = { yaw: 0, pitch: 0, fov: 80 };
}}

View File

@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/I18nProvider";
import { useEffect, useRef, useState } from "react";
import { Panorama } from "./Panorama";
import { api } from "./StartGame";
@@ -20,6 +22,7 @@ export function PanoramaPreview({
initialId?: string;
onClose: () => void;
}) {
const { t } = useI18n();
const dialog = useRef<HTMLDialogElement>(null);
const closeButton = useRef<HTMLButtonElement>(null);
const [expanded, setExpanded] = useState(false);
@@ -105,13 +108,15 @@ export function PanoramaPreview({
>
<header className="preview-header">
<div>
<span className="eyebrow">360° PANORAMA</span>
<h2 id="panorama-preview-title">A closer look.</h2>
<span className="eyebrow">{t("360° PANORAMA")}</span>
<h2 id="panorama-preview-title">{t("A closer look.")}</h2>
</div>
<div className="preview-header-actions">
<button
className="icon-button"
aria-label={expanded ? "Restore preview size" : "Expand preview"}
aria-label={
expanded ? t("Restore preview size") : t("Expand preview")
}
onClick={() => setExpanded(!expanded)}
>
{expanded ? <Minimize size={19} /> : <Expand size={19} />}
@@ -119,7 +124,7 @@ export function PanoramaPreview({
<button
ref={closeButton}
className="icon-button"
aria-label="Close preview"
aria-label={t("Close preview")}
onClick={onClose}
>
<X size={21} />
@@ -128,9 +133,9 @@ export function PanoramaPreview({
</header>
<div className="preview-toolbar">
<label className="preview-location">
<span>Location</span>
<span>{t("Location")}</span>
<select
aria-label="Preview location"
aria-label={t("Preview location")}
value={selected}
onChange={(e) => setSelected(e.target.value)}
>
@@ -147,7 +152,7 @@ export function PanoramaPreview({
<div className="preview-navigation">
<button
className="icon-button"
aria-label="Previous panorama"
aria-label={t("Previous panorama")}
disabled={index <= 0}
onClick={() => setSelected(locations[index - 1])}
>
@@ -155,7 +160,7 @@ export function PanoramaPreview({
</button>
<button
className="icon-button"
aria-label="Next panorama"
aria-label={t("Next panorama")}
disabled={index < 0 || index >= locations.length - 1}
onClick={() => setSelected(locations[index + 1])}
>
@@ -174,12 +179,12 @@ export function PanoramaPreview({
)}
{error ? (
<p role="alert" className="preview-status">
{error}
{t(error)}
</p>
) : (
!ready && (
<p role="status" className="preview-status">
Loading panorama…
{t("Loading panorama…")}
</p>
)
)}
@@ -187,9 +192,9 @@ export function PanoramaPreview({
<footer className="preview-footer">
<span>
<Move size={16} />
Drag to look around · Scroll to zoom
{t("Drag to look around · Scroll to zoom")}
</span>
<span>Check for blocked views and missing terrain.</span>
<span>{t("Check for blocked views and missing terrain.")}</span>
</footer>
</dialog>
);

View File

@@ -1,10 +1,13 @@
"use client";
import { useI18n } from "@/components/I18nProvider";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { createBrowserClient } from "@supabase/ssr";
import { passwordUpdateSchema } from "@/shared/account";
export function PasswordForm({ recovery = false }: { recovery?: boolean }) {
const { t } = useI18n();
const [current, setCurrent] = useState(""),
[password, setPassword] = useState(""),
[confirmation, setConfirmation] = useState(""),
@@ -77,7 +80,7 @@ export function PasswordForm({ recovery = false }: { recovery?: boolean }) {
<form onSubmit={submit} aria-busy={busy} className="password-form">
{!recovery && (
<label className="field">
Current password
{t("Current password")}
<input
name="currentPassword"
type="password"
@@ -90,7 +93,7 @@ export function PasswordForm({ recovery = false }: { recovery?: boolean }) {
</label>
)}
<label className="field">
New password
{t("New password")}
<input
name="newPassword"
type="password"
@@ -102,11 +105,11 @@ export function PasswordForm({ recovery = false }: { recovery?: boolean }) {
disabled={busy}
/>
<small>
At least 12 characters. Use a unique password or passphrase.
{t("At least 12 characters. Use a unique password or passphrase.")}
</small>
</label>
<label className="field">
Confirm new password
{t("Confirm new password")}
<input
name="confirmPassword"
type="password"
@@ -119,7 +122,7 @@ export function PasswordForm({ recovery = false }: { recovery?: boolean }) {
</label>
{needsNonce && (
<label className="field">
Email verification code
{t("Email verification code")}
<input
inputMode="numeric"
autoComplete="one-time-code"
@@ -132,16 +135,20 @@ export function PasswordForm({ recovery = false }: { recovery?: boolean }) {
)}
{error && (
<p role="alert" className="error-message">
{error}
{t(error)}
</p>
)}
{message && (
<p role="status" className="success-message">
{message}
{t(message)}
</p>
)}
<button className="button primary" disabled={busy}>
{busy ? "Updating…" : recovery ? "Set new password" : "Update password"}
{busy
? t("Updating…")
: recovery
? t("Set new password")
: t("Update password")}
</button>
</form>
);

View File

@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/I18nProvider";
import { useEffect, useState } from "react";
import {
processingStage,
@@ -6,6 +8,7 @@ import {
} from "@/shared/processing-progress";
export function ProcessingProgress({ job }: { job: ProcessingJob | null }) {
const { t } = useI18n();
const [now, setNow] = useState<number | null>(null);
useEffect(() => {
setNow(Date.now());
@@ -22,20 +25,24 @@ export function ProcessingProgress({ job }: { job: ProcessingJob | null }) {
<div className="processing-progress" role="status" aria-live="polite">
<p>
{job.state === "queued"
? "Waiting for a processing worker"
: `${progress}% · ${job.kind === "map" ? "Refreshing guessing map" : processingStage(progress)}`}
? t("Waiting for a processing worker")
: `${progress}% · ${job.kind === "map" ? t("Refreshing guessing map") : t(processingStage(progress))}`}
</p>
<progress
max={100}
value={progress}
aria-label="Map processing progress"
aria-label={t("Map processing progress")}
/>
<p className="muted">
{stopped
? "The worker has stopped reporting. Check its terminal and restart it if necessary. Your uploaded export is saved."
? t(
"The worker has stopped reporting. Check its terminal and restart it if necessary. Your uploaded export is saved.",
)
: job.state === "running"
? "Worker connected. Large exports can take a while; progress measures completed work."
: "Processing will start when a worker is available."}
? t(
"Worker connected. Large exports can take a while; progress measures completed work.",
)
: t("Processing will start when a worker is available.")}
</p>
</div>
);

View File

@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/I18nProvider";
import { useEffect, useRef, useState } from "react";
import { api } from "./StartGame";
import { reportCategories } from "@/shared/moderation";
@@ -12,6 +14,7 @@ export function ReportContent({
sessionId?: string;
round?: number;
}) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const [target, setTarget] = useState("map");
const [category, setCategory] = useState("broken");
@@ -70,11 +73,11 @@ export function ReportContent({
setMessage("");
}}
>
Report content
{t("Report content")}
</button>
{message && (
<p role="status" className="success-message">
{message}
{t(message)}
</p>
)}
{open && (
@@ -85,52 +88,52 @@ export function ReportContent({
onCancel={() => setOpen(false)}
>
<form onSubmit={submit}>
<h2 id="report-title">Report content</h2>
<h2 id="report-title">{t("Report content")}</h2>
<p className="muted">
Sign in to send a report. A moderator reviews the content and your
explanation. Three confirmed reports unpublish the map until an
owner appeal is approved.
{t(
"Sign in to send a report. A moderator reviews the content and your explanation. Three confirmed reports unpublish the map until an owner appeal is approved.",
)}
</p>
<label className="field">
What are you reporting?
{t("What are you reporting?")}
<select
value={target}
onChange={(e) => setTarget(e.target.value)}
>
<option value="map">Entire map</option>
<option value="map">{t("Entire map")}</option>
{sessionId && (
<option value="panorama">
This panorama (round {(round ?? 0) + 1})
{t("This panorama (round")} {(round ?? 0) + 1})
</option>
)}
</select>
</label>
<label className="field">
Reason
{t("Reason")}
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
>
{Object.entries(reportCategories).map(([value, label]) => (
<option key={value} value={value}>
{label}
{t(label)}
</option>
))}
</select>
</label>
<label className="field">
What happened?
{t("What happened?")}
<textarea
required
minLength={10}
maxLength={2000}
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Describe the problem and where to find it."
placeholder={t("Describe the problem and where to find it.")}
/>
</label>
<label className="field">
Evidence link (optional)
{t("Evidence link (optional)")}
<input
type="url"
maxLength={1000}
@@ -141,7 +144,7 @@ export function ReportContent({
</label>
{error && (
<p role="alert" className="error-message">
{error}
{t(error)}
</p>
)}
<div className="inline-actions">
@@ -149,7 +152,7 @@ export function ReportContent({
className="button primary"
disabled={busy || reason.trim().length < 10}
>
{busy ? "Sending…" : "Send report"}
{busy ? t("Sending…") : t("Send report")}
</button>
<button
type="button"
@@ -157,7 +160,7 @@ export function ReportContent({
disabled={busy}
onClick={() => setOpen(false)}
>
Cancel
{t("Cancel")}
</button>
</div>
</form>

View File

@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/I18nProvider";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -6,6 +8,7 @@ import { api } from "./StartGame";
import { GuessMap } from "./GuessMap";
import type { SessionView } from "./Game";
export function Results({ id }: { id: string }) {
const { t, formatNumber } = useI18n();
const [session, setSession] = useState<SessionView | null>(null),
[selected, setSelected] = useState(0),
[error, setError] = useState("");
@@ -28,21 +31,23 @@ export function Results({ id }: { id: string }) {
disposed = true;
};
}, [id, router]);
if (error) return <p role="alert">{error}</p>;
if (!session) return <p className="muted">Unpacking your expedition…</p>;
if (error) return <p role="alert">{t(error)}</p>;
if (!session)
return <p className="muted">{t("Unpacking your expedition…")}</p>;
const total = session.rounds.reduce((n, r) => n + (r.result?.score || 0), 0);
const current = session.rounds[selected].result;
return (
<>
<span className="eyebrow">
EXPEDITION COMPLETE · {session.difficulty.toUpperCase()}
{t("EXPEDITION COMPLETE ·")}{" "}
{t(session.difficulty[0].toUpperCase() + session.difficulty.slice(1))}
</span>
<h1>Every guess is a story.</h1>
<h1>{t("Every guess is a story.")}</h1>
<p className="lead">
{session.name} · Here’s where your instincts took you.
{session.name} {t("· Here’s where your instincts took you.")}
</p>
<div className="results-total">
{total.toLocaleString()}
{formatNumber(total)}
<small> / 25,000</small>
</div>
<div className="results-grid">
@@ -56,7 +61,7 @@ export function Results({ id }: { id: string }) {
/>
</div>
<div className="result-summary">
<h2>Your five-round journey</h2>
<h2>{t("Your five-round journey")}</h2>
<div className="tabs">
{session.rounds.map((r, i) => (
<button
@@ -65,16 +70,16 @@ export function Results({ id }: { id: string }) {
aria-pressed={i === selected}
onClick={() => setSelected(i)}
>
Round {i + 1}
{t("Round")} {i + 1}
</button>
))}
</div>
<table className="history-table">
<thead>
<tr>
<th>ROUND</th>
<th>DISTANCE</th>
<th>POINTS</th>
<th>{t("ROUND")}</th>
<th>{t("DISTANCE")}</th>
<th>{t("POINTS")}</th>
</tr>
</thead>
<tbody>
@@ -83,23 +88,27 @@ export function Results({ id }: { id: string }) {
<td>{i + 1}</td>
<td>
{r.result?.timedOut
? "Timed out"
: `${Math.round(r.result?.distance || 0)} blocks`}
? t("Timed out")
: t("{count} blocks", {
count: formatNumber(
Math.round(r.result?.distance || 0),
),
})}
</td>
<td>{r.result?.score.toLocaleString()}</td>
<td>{r.result ? formatNumber(r.result.score) : "—"}</td>
</tr>
))}
</tbody>
</table>
<div className="inline-actions">
<Link className="button primary" href="/servers">
Another adventure →
{t("Another adventure →")}
</Link>
<Link className="button" href="/dashboard">
My maps
{t("My maps")}
</Link>
<Link className="text-link" href="/leaderboard">
View leaderboard →
{t("View leaderboard →")}
</Link>
</div>
</div>

View File

@@ -1,10 +1,13 @@
import { getI18n } from "@/lib/i18n";
import "server-only";
import Link from "next/link";
import { Compass, ArrowUpRight, Globe2 } from "lucide-react";
import { Compass, ArrowUpRight } from "lucide-react";
import { LanguageSwitcher } from "./LanguageSwitcher";
import { user } from "@/lib/supabase";
import { Brand } from "./Brand";
import { SiteNav } from "./SiteNav";
export async function Header() {
const { t } = await getI18n();
const account = await user();
return (
<header className="site-header">
@@ -15,48 +18,49 @@ export async function Header() {
href={account ? "/dashboard" : "/log-in"}
className="header-account"
>
{account ? "My account" : "Log in"} <ArrowUpRight size={15} />
{account ? t("My account") : t("Log in")} <ArrowUpRight size={15} />
</Link>
</div>
</header>
);
}
export function Footer() {
export async function Footer() {
const { t } = await getI18n();
return (
<footer className="site-footer">
<div>
<Brand />
<p>A familiar world. A whole new perspective.</p>
<p>{t("A familiar world. A whole new perspective.")}</p>
</div>
<div>
<span>
<Globe2 size={14} /> English
</span>
<Link href="/downloads">Tools & downloads</Link>
<Link href="/leaderboard">Leaderboard</Link>
<Link href="/moderation">Moderation</Link>
<LanguageSwitcher />
<Link href="/downloads">{t("Tools & downloads")}</Link>
<Link href="/leaderboard">{t("Leaderboard")}</Link>
<Link href="/moderation">{t("Moderation")}</Link>
</div>
<p className="disclaimer">
An independent community project. Not affiliated with Mojang or
Microsoft.
{t(
"An independent community project. Not affiliated with Mojang or Microsoft.",
)}
</p>
</footer>
);
}
export function EmptyState({
export async function EmptyState({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
const { t } = await getI18n();
return (
<div className="empty-state">
<Compass size={36} />
<h3>{title}</h3>
<h3>{t(title)}</h3>
<p>{children}</p>
<Link className="button primary" href="/downloads">
Create the first map <ArrowUpRight size={16} />
{t("Create the first map")} <ArrowUpRight size={16} />
</Link>
</div>
);

View File

@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/I18nProvider";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { ArrowRight, Compass, Mail, ShieldCheck } from "lucide-react";
@@ -13,6 +15,7 @@ export function SignIn({
next?: string;
initialError?: string;
}) {
const { t } = useI18n();
const [mode, setMode] = useState<Mode>("login"),
[email, setEmail] = useState(""),
[name, setName] = useState(""),
@@ -77,40 +80,41 @@ export function SignIn({
return (
<div className="auth-layout">
<aside className="auth-story">
<span className="eyebrow">YOUR NEXT EXPEDITION</span>
<span className="eyebrow">{t("YOUR NEXT EXPEDITION")}</span>
<h1>
A familiar world.
{t("A familiar world.")}
<br />
Your own perspective.
{t("Your own perspective.")}
</h1>
<p>
Keep your journeys, share the places you know, and help build the next
map.
{t(
"Keep your journeys, share the places you know, and help build the next map.",
)}
</p>
<div className="auth-story-map" aria-hidden="true">
<Compass size={52} />
<span>Every world has a story.</span>
<span>{t("Every world has a story.")}</span>
</div>
<div className="auth-benefits">
<span>
<Compass size={18} />
Save your expeditions
{t("Save your expeditions")}
</span>
<span>
<ShieldCheck size={18} />
Keep control of your maps
{t("Keep control of your maps")}
</span>
</div>
</aside>
<section className="auth-card">
<div className="auth-tabs" aria-label="Account options">
<div className="auth-tabs" aria-label={t("Account options")}>
<button
type="button"
aria-pressed={mode === "login"}
disabled={busy}
onClick={() => changeMode("login")}
>
Log in
{t("Log in")}
</button>
<button
type="button"
@@ -118,24 +122,26 @@ export function SignIn({
disabled={busy}
onClick={() => changeMode("signup")}
>
Create account
{t("Create account")}
</button>
</div>
<span className="eyebrow">YOUR CARTOGRAPHER ACCOUNT</span>
<h2>{title}</h2>
<span className="eyebrow">{t("YOUR CARTOGRAPHER ACCOUNT")}</span>
<h2>{t(title)}</h2>
<p className="muted">
{mode === "signup"
? "Choose the name people will see on your maps."
? t("Choose the name people will see on your maps.")
: mode === "recover"
? "We’ll email you a link to set a new password."
? t("We’ll email you a link to set a new password.")
: mode === "magic"
? "For existing accounts. Open the link on this device to continue."
: "Log in with your email and password."}
? t(
"For existing accounts. Open the link on this device to continue.",
)
: t("Log in with your email and password.")}
</p>
<form onSubmit={submit} aria-busy={busy}>
{mode === "signup" && (
<label className="field">
Display name
{t("Display name")}
<input
name="displayName"
autoComplete="nickname"
@@ -143,15 +149,17 @@ export function SignIn({
maxLength={32}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="How should we call you?"
placeholder={t("How should we call you?")}
required
disabled={busy}
/>
<small>2–32 characters. You can change it in settings.</small>
<small>
{t("2–32 characters. You can change it in settings.")}
</small>
</label>
)}
<label className="field">
Email address
{t("Email address")}
<input
name="email"
type="email"
@@ -165,7 +173,7 @@ export function SignIn({
</label>
{(mode === "login" || mode === "signup") && (
<label className="field">
Password
{t("Password")}
<input
name="password"
type="password"
@@ -180,14 +188,16 @@ export function SignIn({
/>
{mode === "signup" && (
<small>
Use at least 12 characters. A unique passphrase works well.
{t(
"Use at least 12 characters. A unique passphrase works well.",
)}
</small>
)}
</label>
)}
{mode === "signup" && (
<label className="field">
Confirm password
{t("Confirm password")}
<input
name="confirmPassword"
type="password"
@@ -206,17 +216,17 @@ export function SignIn({
disabled={busy}
onClick={() => changeMode("recover")}
>
Forgot password?
{t("Forgot password?")}
</button>
)}
{error && (
<p role="alert" className="error-message">
{error}
{t(error)}
</p>
)}
{message && (
<p role="status" className="success-message">
{message}
{t(message)}
</p>
)}
<button className="button primary full" disabled={busy}>
@@ -226,25 +236,25 @@ export function SignIn({
<ArrowRight size={17} />
)}{" "}
{busy
? "Please wait…"
? t("Please wait…")
: {
login: "Log in",
signup: "Create account",
recover: "Send reset link",
magic: "Send sign-in link",
login: t("Log in"),
signup: t("Create account"),
recover: t("Send reset link"),
magic: t("Send sign-in link"),
}[mode]}
</button>
</form>
{mode === "login" && (
<div className="auth-alternative">
<span>Already use email links?</span>
<span>{t("Already use email links?")}</span>
<button
type="button"
className="text-link"
disabled={busy}
onClick={() => changeMode("magic")}
>
Send me a sign-in link
{t("Send me a sign-in link")}
</button>
</div>
)}
@@ -255,11 +265,13 @@ export function SignIn({
disabled={busy}
onClick={() => changeMode("login")}
>
← Back to log in
{t("← Back to log in")}
</button>
)}
<p className="auth-footnote">
Your email stays private. Your display name appears with your maps.
{t(
"Your email stays private. Your display name appears with your maps.",
)}
</p>
</section>
</div>

View File

@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/I18nProvider";
import Link from "next/link";
import { usePathname } from "next/navigation";
@@ -10,9 +12,10 @@ const links = [
] as const;
export function SiteNav() {
const { t } = useI18n();
const pathname = usePathname();
return (
<nav aria-label="Main navigation">
<nav aria-label={t("Main navigation")}>
{links.map(([href, label]) => (
<Link
key={href}
@@ -23,7 +26,7 @@ export function SiteNav() {
: undefined
}
>
{label}
{t(label)}
</Link>
))}
</nav>

View File

@@ -0,0 +1,84 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { usePathname } from "next/navigation";
import { api } from "@/lib/client-api";
import {
defaultSiteSettings,
siteSettingsSchema,
type SiteSettings,
} from "@/shared/site-settings";
const SettingsContext = createContext({
settings: defaultSiteSettings,
updateSettings: (_settings: SiteSettings) => {},
});
export function SiteSettingsProvider({
initialSettings,
children,
}: {
initialSettings: SiteSettings;
children: React.ReactNode;
}) {
const [settings, setSettings] = useState(initialSettings);
const version = useRef(0);
const pending = useRef(false);
const pathname = usePathname();
const updateSettings = useCallback((next: SiteSettings) => {
version.current++;
setSettings(next);
}, []);
const refresh = useCallback(async () => {
if (pending.current || document.visibilityState === "hidden") return;
pending.current = true;
const requestVersion = version.current;
try {
const next = siteSettingsSchema.parse(await api("site-settings"));
// An older poll must not overwrite a moderator's just-saved setting.
if (requestVersion === version.current) setSettings(next);
} catch {
// Keep the last known setting while disconnected.
} finally {
pending.current = false;
}
}, []);
useEffect(() => {
void refresh();
}, [pathname, refresh]);
useEffect(() => {
const timer = window.setInterval(() => {
void refresh();
}, 30000);
const resume = () => {
void refresh();
};
document.addEventListener("visibilitychange", resume);
window.addEventListener("focus", resume);
return () => {
window.clearInterval(timer);
document.removeEventListener("visibilitychange", resume);
window.removeEventListener("focus", resume);
};
}, [refresh]);
const value = useMemo(
() => ({ settings, updateSettings }),
[settings, updateSettings],
);
return (
<SettingsContext.Provider value={value}>
{children}
</SettingsContext.Provider>
);
}
export function useSiteSettings() {
return useContext(SettingsContext);
}

View File

@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/I18nProvider";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { ArrowRight, Timer, ZoomIn } from "lucide-react";
@@ -6,6 +8,7 @@ import { difficulties, type Difficulty } from "@/shared/protocol";
import { api } from "@/lib/client-api";
export { api } from "@/lib/client-api";
export function StartGame({ datasetId }: { datasetId: string }) {
const { t } = useI18n();
const [difficulty, setDifficulty] = useState<Difficulty>("normal"),
[loading, setLoading] = useState(false),
[error, setError] = useState("");
@@ -26,8 +29,8 @@ export function StartGame({ datasetId }: { datasetId: string }) {
}
return (
<div className="start-panel">
<span className="eyebrow">YOUR EXPEDITION</span>
<h2>Choose your challenge.</h2>
<span className="eyebrow">{t("YOUR EXPEDITION")}</span>
<h2>{t("Choose your challenge.")}</h2>
<div className="difficulty-options">
{Object.entries(difficulties).map(([key, value]) => (
<button
@@ -36,30 +39,33 @@ export function StartGame({ datasetId }: { datasetId: string }) {
className={difficulty === key ? "selected" : ""}
onClick={() => setDifficulty(key as Difficulty)}
>
<strong>{value.label}</strong>
<strong>{t(value.label)}</strong>
<span>
<Timer size={13} />
{value.seconds}s per round
{value.seconds}
{t("s per round")}
</span>
<span>
<ZoomIn size={13} />
{value.zoom ? "Zoom allowed" : "No zoom"}
{value.zoom ? t("Zoom allowed") : t("No zoom")}
</span>
</button>
))}
</div>
<p className="muted">5 rounds · 25,000 possible points · No movement</p>
<p className="muted">
{t("5 rounds · 25,000 possible points · No movement")}
</p>
<button
className="button primary full"
onClick={start}
disabled={loading}
>
{loading ? "Preparing your world…" : "Start exploring"}
{loading ? t("Preparing your world…") : t("Start exploring")}
<ArrowRight size={17} />
</button>
{error && (
<p role="alert" className="error-message">
{error}
{t(error)}
</p>
)}
</div>

25
docs/vercel-deployment.md Normal file
View File

@@ -0,0 +1,25 @@
# Deploy MineGeo
Run `npm run deploy` from the project directory. The script collects all web
source files and public assets, uploads them in one request, waits for the build,
and prints the deployment URL. It uses the [Vercel deployments API](https://vercel.com/docs/rest-api/deployments/create-a-new-deployment). The database credentials already configured in
Vercel remain there. The Minecraft rendering worker stays on its current host.
The script excludes Git metadata, local environment files, caches, generated
captures, tests, Java build sources, and development tools. It checks uploaded
content, including the downloadable JARs, for references to the Git server.
Nothing is pushed to Gitea or connected to a Git provider.
Authentication is needed once: run `vercel login`, or put a Vercel token in the
ignored `.env.deploy.local` file as `VERCEL_TOKEN=...`. The Codex Vercel plugin's
login does not sign the local script in. Tokens are never included in the upload
or command output.
Useful options:
- `npm run deploy -- --dry-run`: inspect file count and the privacy check without uploading.
- `npm run deploy -- --preview`: create a preview deployment.
- `npm run deploy -- --help`: show usage.
The script targets the existing MineGeo project. Set `VERCEL_PROJECT_ID` and
`VERCEL_ORG_ID` to deploy a copy to another Vercel project.

View File

@@ -16,6 +16,8 @@ import { ServiceError, checkServiceError } from "./service-error";
import { requestOrigin, verifyOrigin } from "./request-origin";
import { accountApi } from "./account-api";
import { contributionCutoff, reportSchema } from "../shared/moderation";
import { siteSettingsSchema } from "../shared/site-settings";
import { getSiteSettings } from "./site-settings";
const hash = (value: string) =>
createHash("sha256").update(value).digest("hex");
const idSchema = z.string().uuid();
@@ -109,6 +111,11 @@ export async function handle(request: NextRequest, path: string[]) {
return await accountApi(request, path, { jsonBody, limit });
const client = admin(),
method = request.method;
if (path[0] === "site-settings" && path.length === 1 && method === "GET") {
return NextResponse.json(await getSiteSettings(), {
headers: { "Cache-Control": "no-store" },
});
}
if (path[0] === "health" && path.length === 1 && method === "GET") {
const { error } = await client
.from("catalog_servers")
@@ -782,6 +789,15 @@ export async function handle(request: NextRequest, path: string[]) {
}
if (path[0] === "moderation") {
const reviewer = await moderator(request);
if (path[1] === "settings" && path.length === 2 && method === "PATCH") {
const body = siteSettingsSchema.parse(await jsonBody(request));
const enabled = required(await client.rpc("set_beta_badge", {
p_reviewer: reviewer, p_enabled: body.betaBadgeEnabled,
}));
return NextResponse.json({ betaBadgeEnabled: enabled }, {
headers: { "Cache-Control": "no-store" },
});
}
if (path[1] === "appeals" && path.length === 3 && method === "POST") {
const body = z
.object({

31
lib/i18n.ts Normal file
View File

@@ -0,0 +1,31 @@
import "server-only";
import { cache } from "react";
import { cookies, headers } from "next/headers";
import {
detectLocale,
languageCookie,
localeTag,
resolveLocale,
translator,
type LanguagePreference,
} from "@/shared/i18n";
export const getI18n = cache(async () => {
const [jar, requestHeaders] = await Promise.all([cookies(), headers()]);
const preference = jar.get(languageCookie)?.value;
const locale = resolveLocale(
preference,
requestHeaders.get("accept-language"),
);
const languagePreference: LanguagePreference =
preference === "en" || preference === "ru" ? preference : "auto";
return {
locale,
preference: languagePreference,
detectedLocale: detectLocale(requestHeaders.get("accept-language")),
t: translator(locale),
formatNumber: (value: number) => value.toLocaleString(localeTag(locale)),
formatDate: (value: string) =>
new Date(value).toLocaleDateString(localeTag(locale)),
};
});

24
lib/site-settings.ts Normal file
View File

@@ -0,0 +1,24 @@
import "server-only";
import { cache } from "react";
import { admin, required } from "./supabase";
import { defaultSiteSettings, type SiteSettings } from "@/shared/site-settings";
export const getSiteSettings = cache(async (): Promise<SiteSettings> => {
const row = required(
await admin()
.from("site_settings")
.select("beta_badge_enabled")
.eq("id", true)
.single(),
);
return { betaBadgeEnabled: row.beta_badge_enabled };
});
export async function initialSiteSettings(): Promise<SiteSettings> {
try {
return await getSiteSettings();
} catch {
// A settings outage must not block the site from rendering.
return defaultSiteSettings;
}
}

View File

@@ -1,6 +1,7 @@
import type { NextConfig } from "next";
const config: NextConfig = {
poweredByHeader: false,
outputFileTracingIncludes: { "/downloads": ["./public/tools/**/*.jar"] },
serverExternalPackages: ["pg"],
turbopack: { root: process.cwd() },
async headers() {

View File

@@ -4,6 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"deploy": "node scripts/deploy-vercel.mjs",
"dev": "node scripts/supervise.mjs dev",
"dev:web": "next dev --hostname 127.0.0.1",
"prebuild": "node scripts/clean-build.mjs",

View File

@@ -0,0 +1,93 @@
Copyright (c) 2022, Idrees Hassan (https://github.com/IdreesInc/Monocraft)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

BIN
public/fonts/Monocraft.ttf Normal file

Binary file not shown.

306
scripts/deploy-vercel.mjs Normal file
View File

@@ -0,0 +1,306 @@
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { fileURLToPath } from "node:url";
import { execFileSync } from "node:child_process";
import { unzipSync } from "fflate";
const defaultRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
);
export const deploymentProject = {
name: "minegeo",
project: "prj_St6KMC5H8PakQxe3UCQxJa5b8gWy",
teamId: "team_03U5cmiAfXJvpuxDDh33CqOT",
productionUrl: "https://minegeo.vercel.app",
};
export function deploymentFiles(root = defaultRoot) {
const files = [];
const rootReal = fs.realpathSync(root);
let privateTerms = [];
try {
const remote = execFileSync(
"git",
["-C", root, "remote", "get-url", "origin"],
{
encoding: "utf8",
windowsHide: true,
stdio: ["ignore", "pipe", "ignore"],
},
).trim();
const hostname = remote.includes("://")
? new URL(remote).hostname
: remote.split("@").pop().split(":")[0];
privateTerms = [remote, hostname]
.filter(Boolean)
.map((value) => value.toLowerCase());
} catch {
/* A source snapshot can be deployed without Git. */
}
function add(full, relative) {
const real = fs.realpathSync(full);
if (real !== rootReal && !real.startsWith(rootReal + path.sep))
throw new Error(`Deployment file escapes the project: ${relative}`);
let data = fs.readFileSync(full);
if (relative === "next.config.ts")
data = Buffer.from(
data.toString().replace(/^\s*allowedDevOrigins:.*\r?\n/gm, ""),
);
if (relative === "package.json") {
const pkg = JSON.parse(data);
pkg.scripts = {
prebuild: "node scripts/clean-build.mjs",
build: "next build",
start: "next start",
};
delete pkg.repository;
delete pkg.bugs;
delete pkg.homepage;
data = Buffer.from(JSON.stringify(pkg, null, 2));
}
if (relative === "tsconfig.json") {
const config = JSON.parse(data);
config.include = [
"next-env.d.ts",
"next.config.ts",
"proxy.ts",
"app/**/*.ts",
"app/**/*.tsx",
"components/**/*.tsx",
"lib/**/*.ts",
"shared/**/*.ts",
".next/types/**/*.ts",
];
data = Buffer.from(JSON.stringify(config, null, 2));
}
const contents = relative.endsWith(".jar")
? Object.values(unzipSync(data))
: [data];
if (
contents.some((content) =>
privateTerms.some((term) =>
Buffer.from(content).toString("utf8").toLowerCase().includes(term),
),
)
)
throw new Error(
`Private Git server reference found in ${relative}. Remove it before deploying.`,
);
files.push({
file: relative,
data: data.toString("base64"),
encoding: "base64",
});
}
function walk(relative) {
const dir = path.join(root, relative);
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (
entry.name.startsWith(".") ||
entry.name === "node_modules" ||
(relative === "public" &&
["generated", "downloads", "artifacts", "build"].includes(entry.name))
)
continue;
const file = path.posix.join(relative, entry.name);
if (entry.isDirectory()) walk(file);
else add(path.join(root, file), file);
}
}
for (const directory of ["app", "components", "lib", "shared", "public"])
walk(directory);
for (const file of [
"package.json",
"package-lock.json",
"tsconfig.json",
"next.config.ts",
"next-env.d.ts",
"proxy.ts",
"postcss.config.mjs",
"vercel.json",
"scripts/clean-build.mjs",
]) {
if (fs.existsSync(path.join(root, file))) add(path.join(root, file), file);
}
if (
!files.some((file) => file.file === "package.json") ||
!files.some((file) => file.file === "app/layout.tsx")
)
throw new Error("Run this command from a complete MineGeo project.");
return files;
}
export function deploymentPayload(files, preview = false) {
return {
name: deploymentProject.name,
project: process.env.VERCEL_PROJECT_ID || deploymentProject.project,
...(preview ? {} : { target: "production" }),
files,
projectSettings: {
framework: "nextjs",
nodeVersion: "22.x",
buildCommand: "npm run build",
skipGitConnectDuringLink: true,
},
};
}
function accessToken() {
if (process.env.VERCEL_TOKEN) return process.env.VERCEL_TOKEN;
const authPaths =
process.platform === "win32"
? [
path.join(
process.env.APPDATA || path.join(os.homedir(), "AppData/Roaming"),
"com.vercel.cli/Data/auth.json",
),
]
: [
path.join(
process.env.XDG_DATA_HOME ||
path.join(os.homedir(), ".local/share"),
"com.vercel.cli/auth.json",
),
path.join(
os.homedir(),
"Library/Application Support/com.vercel.cli/auth.json",
),
];
for (const file of authPaths) {
try {
const token = JSON.parse(fs.readFileSync(file, "utf8")).token;
if (typeof token === "string") return token;
} catch {
/* No CLI login saved here. */
}
}
throw new Error(
"Vercel authentication is needed once: run vercel login, or put VERCEL_TOKEN in .env.deploy.local. Then run npm run deploy again. The Codex Vercel plugin login is separate from CLI/script authentication.",
);
}
/**
* @param {ReturnType<typeof deploymentPayload>} payload
* @param {{ token: string, request?: typeof fetch, wait?: (milliseconds: number) => Promise<void>, log?: (message: string) => void }} options
*/
export async function deploy(
payload,
{
token,
request = fetch,
wait = (milliseconds) =>
new Promise((resolve) => setTimeout(resolve, milliseconds)),
log = console.log,
},
) {
if (!token) throw new Error("A Vercel access token is required.");
const team = process.env.VERCEL_ORG_ID || deploymentProject.teamId;
async function api(endpoint, body) {
const response = await request(
`https://api.vercel.com${endpoint}${endpoint.includes("?") ? "&" : "?"}teamId=${encodeURIComponent(team)}`,
{
method: body ? "POST" : "GET",
headers: {
Authorization: `Bearer ${token}`,
...(body ? { "Content-Type": "application/json" } : {}),
},
...(body ? { body: JSON.stringify(body) } : {}),
signal: AbortSignal.timeout(120000),
},
);
const data = await response.json();
if (!response.ok)
throw new Error(
`Vercel ${response.status}: ${data.error?.message || "Deployment request failed"}`,
);
return data;
}
let deployment = await api("/v13/deployments", payload);
if (!deployment.id) throw new Error("Vercel did not return a deployment ID.");
log(
`Uploaded ${payload.files.length} site files in one request. Waiting for Vercel…`,
);
let previousState;
for (let attempts = 0; attempts < 180; attempts++) {
const state =
deployment.readyState || deployment.status || deployment.state;
if (state !== previousState) {
log(`Build: ${state}`);
previousState = state;
}
if (state === "READY") {
const url =
payload.target === "production" &&
payload.project === deploymentProject.project
? deploymentProject.productionUrl
: `https://${deployment.url}`;
log(`Deployed: ${url}`);
return deployment;
}
if (["ERROR", "CANCELED"].includes(state))
throw new Error(
`Deployment ${state.toLowerCase()}: ${deployment.errorMessage || deployment.error?.message || "See the build log in Vercel."}`,
);
await wait(5000);
deployment = await api(
`/v13/deployments/${encodeURIComponent(deployment.id)}`,
);
}
throw new Error(
`Build is still running. Check deployment ${deployment.id} in Vercel.`,
);
}
async function main() {
const args = process.argv.slice(2);
if (args.includes("--help")) {
console.log(
"npm run deploy [-- --preview | --dry-run]\nOne upload of all site files, with no Git metadata or local secrets.\nAuthenticate once using vercel login or VERCEL_TOKEN in .env.deploy.local.",
);
return;
}
const envPath = path.join(defaultRoot, ".env.deploy.local");
if (fs.existsSync(envPath)) process.loadEnvFile(envPath);
const files = deploymentFiles();
const manifestIndex = args.indexOf("--manifest");
if (manifestIndex !== -1) {
if (!args[manifestIndex + 1])
throw new Error("--manifest requires a file path");
fs.writeFileSync(
path.resolve(args[manifestIndex + 1]),
JSON.stringify(files),
);
}
if (args.includes("--dry-run")) {
console.log(
JSON.stringify({
files: files.length,
bytes: files.reduce(
(sum, file) => sum + Buffer.from(file.data, "base64").length,
0,
),
privacyScan: "passed",
gitMetadata: false,
localSecrets: false,
target: args.includes("--preview") ? "preview" : "production",
}),
);
return;
}
await deploy(deploymentPayload(files, args.includes("--preview")), {
token: accessToken(),
});
}
if (
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
)
main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});

View File

@@ -0,0 +1,73 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { PGlite } from "../.data/sql-verification/node_modules/@electric-sql/pglite/dist/index.js";
const db = new PGlite();
try {
await db.exec(`create role anon; create role authenticated; create role service_role bypassrls;
create table public.profiles(id uuid primary key, role text not null);
grant select on public.profiles to service_role;
insert into public.profiles values
('00000000-0000-4000-8000-000000000001', 'moderator'),
('00000000-0000-4000-8000-000000000002', 'contributor');`);
await db.exec(
await readFile(
"supabase/migrations/20261006141532_site_settings.sql",
"utf8",
),
);
const flag = async () =>
(await db.query("select beta_badge_enabled from public.site_settings"))
.rows[0].beta_badge_enabled;
assert.equal(await flag(), true);
for (const role of ["anon", "authenticated"]) {
await db.exec(`set role ${role}`);
await assert.rejects(
db.query("update public.site_settings set beta_badge_enabled = false"),
/permission denied/,
);
await assert.rejects(
db.query(
"select public.set_beta_badge('00000000-0000-4000-8000-000000000001', false)",
),
/permission denied/,
);
await db.exec("reset role");
}
await db.exec("set role service_role");
await assert.rejects(
db.query(
"select public.set_beta_badge('00000000-0000-4000-8000-000000000002', false)",
),
/Moderator permission required/,
);
await assert.rejects(
db.query("select public.set_beta_badge(null, false)"),
/Moderator permission required/,
);
assert.equal(await flag(), true);
await db.query(
"select public.set_beta_badge('00000000-0000-4000-8000-000000000001', false)",
);
assert.equal(await flag(), false);
assert.equal(
(await db.query("select updated_by from public.site_settings")).rows[0]
.updated_by,
"00000000-0000-4000-8000-000000000001",
);
await db.query(
"select public.set_beta_badge('00000000-0000-4000-8000-000000000001', true)",
);
assert.equal(await flag(), true);
await assert.rejects(
db.query(
"select public.set_beta_badge('00000000-0000-4000-8000-000000000001', null)",
),
/visibility is required/,
);
console.log(
"Site settings SQL passed: guests and players denied; moderators can hide and restore; updates persist and record the moderator.",
);
} finally {
await db.close();
}

View File

@@ -43,6 +43,26 @@ export type Database = {
};
public: {
Tables: {
site_settings: {
Row: {
id: boolean;
beta_badge_enabled: boolean;
updated_at: string;
updated_by: string | null;
};
Insert: {
id?: boolean;
beta_badge_enabled?: boolean;
updated_at?: string;
updated_by?: string | null;
};
Update: {
beta_badge_enabled?: boolean;
updated_at?: string;
updated_by?: string | null;
};
Relationships: [];
};
map_appeals: {
Row: AppealRecord;
Insert: Partial<AppealRecord> &
@@ -520,6 +540,10 @@ export type Database = {
[_ in never]: never;
};
Functions: {
set_beta_badge: {
Args: { p_reviewer: string; p_enabled: boolean };
Returns: boolean;
};
player_leaderboard: {
Args: {
p_difficulty?: string;

75
shared/i18n.ts Normal file
View File

@@ -0,0 +1,75 @@
import russian from "./locales/ru.json";
export type Locale = "en" | "ru";
export type LanguagePreference = Locale | "auto";
export const languageCookie = "minegeo-language";
export const localeTag = (locale: Locale) =>
locale === "ru" ? "ru-RU" : "en-GB";
export function detectLocale(acceptLanguage: string | null): Locale {
const languages = (acceptLanguage ?? "")
.split(",")
.map((entry, index) => {
const [tag, ...parameters] = entry.trim().toLowerCase().split(";");
const quality = parameters.find((parameter) =>
parameter.trim().startsWith("q="),
);
return {
tag: tag.split("-")[0],
weight: quality ? Number(quality.trim().slice(2)) : 1,
index,
};
})
.filter(
({ weight }) => Number.isFinite(weight) && weight > 0 && weight <= 1,
)
.sort((a, b) => b.weight - a.weight || a.index - b.index);
return (
(languages.find(({ tag }) => tag === "ru" || tag === "en")
?.tag as Locale) || "en"
);
}
export function resolveLocale(
preference: string | undefined,
acceptLanguage: string | null,
): Locale {
return preference === "en" || preference === "ru"
? preference
: detectLocale(acceptLanguage);
}
export function translator(locale: Locale) {
return (message: string, values?: Record<string, string | number>) => {
const translated =
locale === "ru"
? ((russian as Record<string, string>)[message] ?? message)
: message;
return values
? translated.replace(/\{(\w+)\}/g, (match, key) =>
String(values[key] ?? match),
)
: translated;
};
}
export function statusMessage(status: string) {
const labels: Record<string, string> = {
draft: "Draft",
uploading: "Uploading",
processing: "Processing",
review: "Awaiting review",
published: "Published",
failed: "Failed",
rejected: "Rejected",
submitted: "Submitted",
accepted: "Accepted",
queued: "Queued",
running: "In progress",
open: "Open",
approved: "Approved",
closed: "Closed",
withdrawn: "Withdrawn",
};
return labels[status] ?? status;
}

591
shared/locales/ru.json Normal file
View File

@@ -0,0 +1,591 @@
{
"Site language": "Язык сайта",
"Automatic": "Автоматически",
"MineGeo home": "Главная MineGeo",
"Main navigation": "Основная навигация",
"Play & maps": "Играть и карты",
"Leaderboard": "Рейтинг",
"Downloads": "Загрузки",
"Guide": "Как играть",
"My account": "Мой аккаунт",
"Log in": "Войти",
"Tools & downloads": "Инструменты",
"Moderation": "Модерация",
"English": "English",
"A familiar world. A whole new perspective.": "Знакомый мир. Новый взгляд.",
"An independent community project. Not affiliated with Mojang or Microsoft.": "Независимый проект сообщества. Не связан с Mojang или Microsoft.",
"A MINECRAFT GUESSING GAME": "УГАДАЙ МЕСТО В MINECRAFT",
"Guess where you are": "Угадай, где ты",
"in a": "в",
"blocky world.": "мире блоков.",
"Drop into a random corner of a Minecraft world.": "Окажись в случайном уголке мира Minecraft.",
"Look around, spot the landmarks, and place your pin": "Осмотрись, найди ориентиры и поставь метку",
"before the clock runs out.": "до того, как закончится время.",
"Start exploring": "Начать игру",
"Meet the top explorers": "Лучшие исследователи",
"Five rounds. No roads. Just your instincts.": "Пять раундов. Без дорог. Только чутьё.",
"Minecraft surface map": "Карта поверхности Minecraft",
"A Minecraft surface map on the original Minecraft parchment texture": "Карта мира на оригинальном пергаменте Minecraft",
"Overworld": "Верхний мир",
"Community statistics": "Статистика сообщества",
"COMMUNITY SERVERS": "СЕРВЕРОВ СООБЩЕСТВА",
"PUBLISHED MAPS": "ОПУБЛИКОВАННЫХ КАРТ",
"PLACES TO DISCOVER": "МЕСТ ДЛЯ ОТКРЫТИЙ",
"5 rounds": "5 раундов",
"EVERY EXPEDITION": "В КАЖДОЙ ЭКСПЕДИЦИИ",
"COMMUNITY MAPS": "КАРТЫ СООБЩЕСТВА",
"Where will you go today?": "Куда отправишься сегодня?",
"All": "Все",
"maps": "карт",
"All {count} maps": "Все карты: {count}",
"YOUR WORLD HERE": "ЗДЕСЬ МОЖЕТ БЫТЬ ТВОЙ МИР",
"THE FIRST EXPEDITION STARTS WITH YOU": "ПЕРВАЯ ЭКСПЕДИЦИЯ НАЧИНАЕТСЯ С ТЕБЯ",
"Put your server on the map.": "Добавь свой сервер на карту.",
"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.": "Пока нет опубликованных карт. Экспортируй мир плагином или запиши пять локаций модом Fabric. Проверь карту и отправь её на модерацию.",
"Get the capture tools": "Скачать инструменты",
"Get the capture tools →": "Скачать инструменты →",
"HOW TO PLAY": "КАК ИГРАТЬ",
"Know your blocks?": "Знаешь свои блоки?",
"Pick your world": "Выбери мир",
"Choose a community server, map, and difficulty.": "Выбери сервер, карту и сложность.",
"Look for the clues": "Ищи подсказки",
"Rotate the panorama. Find builds, biomes, and landmarks.": "Осмотри панораму. Найди постройки, биомы и ориентиры.",
"Make your guess": "Сделай предположение",
"Drop a pin on the map. Closer guesses earn more points.": "Поставь метку на карте. Чем ближе, тем больше очков.",
"Read the field guide": "Открыть руководство",
"CHOOSE YOUR CHALLENGE": "ВЫБЕРИ СЛОЖНОСТЬ",
"One world. Three ways.": "Один мир. Три режима.",
"Easy": "Легко",
"Normal": "Обычно",
"Hard": "Сложно",
"180 seconds": "180 секунд",
"90 seconds": "90 секунд",
"30 seconds": "30 секунд",
"Take your time. Rotate and zoom.": "Не спеши. Осматривайся и приближай.",
"Trust your eye. Rotate and zoom.": "Доверься глазам. Осматривайся и приближай.",
"Think fast. No zoom allowed.": "Думай быстро. Без приближения.",
"Up to 5,000 points per round. How close can you get?": "До 5 000 очков за раунд. Насколько близко попадёшь?",
"BUILT BY THE COMMUNITY": "СОЗДАНО СООБЩЕСТВОМ",
"Your world deserves to be explored.": "Твой мир ждёт исследователей.",
"Share your builds and your favorite corners of Minecraft.": "Поделись своими постройками и любимыми местами в Minecraft.",
"Create a map": "Создать карту",
"THE EXPLORERS’ BOARD": "РЕЙТИНГ ИССЛЕДОВАТЕЛЕЙ",
"Good eye.": "Зоркий глаз.",
"Great guess.": "Точная метка.",
"Five rounds to find your way.": "Пять раундов, чтобы найти свой путь.",
"One score to make your mark.": "Один результат, чтобы оставить след.",
"See the full leaderboard": "Открыть весь рейтинг",
"Top explorers": "Лучшие исследователи",
"All difficulties · All time": "Все сложности · За всё время",
"Best expedition score ·": "Лучший результат ·",
"Scores temporarily unavailable": "Результаты временно недоступны",
"Every block counts.": "Каждый блок на счету.",
"Back to home": "На главную",
"The sharpest eyes in the Overworld.": "Самые зоркие глаза Верхнего мира.",
"Five rounds. 25,000 possible points. Where do you stand?": "Пять раундов. До 25 000 очков. Какое место займёшь ты?",
"Filter leaderboard by difficulty": "Фильтр рейтинга по сложности",
"All difficulties": "Все сложности",
"All time": "За всё время",
"The rankings": "Таблица лидеров",
"Search players": "Поиск игроков",
"Find an explorer…": "Найти исследователя…",
"Clear search ×": "Сбросить поиск ×",
"explorer": "исследователь",
"explorers": "исследователей",
"expedition": "экспедиция",
"expeditions": "экспедиций",
"completed": "завершено",
"Rank": "Место",
"Explorer": "Игрок",
"Games": "Игр",
"Average": "Средний счёт",
"Best score": "Лучший счёт",
"pts": "очк.",
"You": "Ты",
"Players ranked by best five-round score. Ties are broken by average score, then games played.": "Игроки отсортированы по лучшему результату за пять раундов. При равенстве учитываются средний счёт и число игр.",
"Top three explorers": "Три лучших исследователя",
"TOP EXPLORER": "ЛИДЕР РЕЙТИНГА",
"Place {place}": "МЕСТО {place}",
"Showing": "Показано",
"of": "из",
"Leaderboard pages": "Страницы рейтинга",
"Previous": "Назад",
"Next": "Далее",
"Previous page": "Предыдущая страница",
"Next page": "Следующая страница",
"HOW THE BOARD WORKS": "КАК УСТРОЕН РЕЙТИНГ",
"A place earned, one guess at a time.": "Твой путь к вершине — метка за меткой.",
"Your best completed expedition sets your rank. Ties are broken by average score, then completed games. Difficulty filters rank only games in that mode.": "Место определяется лучшей завершённой экспедицией. При равенстве учитываются средний счёт и число игр. Фильтр сложности показывает результаты только выбранного режима.",
"Log in before you play. All five rounds must be finished to count; guest games stay off the board.": "Войди перед игрой. В рейтинг попадают только завершённые игры из пяти раундов. Гостевые игры не учитываются.",
"Log in to join the board": "Войти и попасть в рейтинг",
"The scoreboard is taking a break.": "Рейтинг временно недоступен.",
"No explorers found.": "Игроки не найдены.",
"The first place is still open.": "Первое место ещё свободно.",
"We couldn’t load the scores. Try again in a moment.": "Не удалось загрузить результаты. Попробуй чуть позже.",
"Try another player name or clear your search.": "Попробуй другое имя или сбрось поиск.",
"Log in and finish five rounds to put your name on the board.": "Войди и заверши пять раундов, чтобы попасть в рейтинг.",
"Reload scores": "Обновить результаты",
"Play an expedition": "Начать экспедицию",
"Loading leaderboard": "Загрузка рейтинга",
"Fetching the explorers’ scores…": "Загружаем результаты игроков…",
"YOUR NEXT EXPEDITION": "ТВОЯ СЛЕДУЮЩАЯ ЭКСПЕДИЦИЯ",
"A familiar world.": "Знакомый мир.",
"Your own perspective.": "Твой собственный взгляд.",
"Keep your journeys, share the places you know, and help build the next map.": "Сохраняй путешествия, делись знакомыми местами и создавай новые карты.",
"Save your expeditions": "Сохраняй экспедиции",
"Keep control of your maps": "Управляй своими картами",
"Account options": "Варианты входа",
"Create account": "Создать аккаунт",
"YOUR CARTOGRAPHER ACCOUNT": "ТВОЙ АККАУНТ КАРТОГРАФА",
"Welcome back.": "С возвращением.",
"Make yourself at home.": "Располагайся поудобнее.",
"Find your way back.": "Вернись к своим мирам.",
"An email, a way in.": "Письмо — и ты снова здесь.",
"Choose the name people will see on your maps.": "Выбери имя, которое будет видно на твоих картах.",
"We’ll email you a link to set a new password.": "Отправим письмо со ссылкой для нового пароля.",
"For existing accounts. Open the link on this device to continue.": "Для существующих аккаунтов. Открой ссылку на этом устройстве.",
"Log in with your email and password.": "Войди с помощью почты и пароля.",
"Display name": "Имя игрока",
"Email address": "Электронная почта",
"Password": "Пароль",
"Confirm password": "Повтори пароль",
"How should we call you?": "Как тебя называть?",
"2–32 characters. You can change it in settings.": "От 2 до 32 символов. Имя можно изменить в настройках.",
"Use at least 12 characters. A unique passphrase works well.": "Не менее 12 символов. Используй уникальный пароль или фразу.",
"Forgot password?": "Забыл пароль?",
"Already use email links?": "Входишь по ссылке из письма?",
"Send me a sign-in link": "Отправить ссылку для входа",
"Your email stays private. Your display name appears with your maps.": "Твоя почта скрыта. На картах отображается только имя игрока.",
"Sending…": "Отправляем…",
"Send reset link": "Отправить ссылку сброса",
"Send sign-in link": "Отправить ссылку входа",
"Check your email to continue.": "Проверь почту, чтобы продолжить.",
"Your passwords do not match.": "Пароли не совпадают.",
"Account settings": "Настройки аккаунта",
"Account settings.": "Настройки аккаунта.",
"Your name, your sign-in, your next expedition.": "Твоё имя, твой аккаунт, твоя следующая экспедиция.",
"← My account": "← Мой аккаунт",
"Your public profile.": "Твой публичный профиль.",
"A name for the maps and places you share.": "Имя для карт и мест, которыми ты делишься.",
"2–32 characters. Display names do not have to be unique.": "От 2 до 32 символов. Имя не обязано быть уникальным.",
"Used for sign-in and recovery. Only you can see it.": "Для входа и восстановления. Почту видишь только ты.",
"Save display name": "Сохранить имя",
"Saving…": "Сохраняем…",
"Display name saved.": "Имя сохранено.",
"Password & security.": "Пароль и безопасность.",
"Keep your account yours.": "Защити свой аккаунт.",
"Current password": "Текущий пароль",
"New password": "Новый пароль",
"Confirm new password": "Повтори новый пароль",
"Email verification code": "Код из письма",
"Update password": "Изменить пароль",
"Updating…": "Обновляем…",
"Set new password": "Установить новый пароль",
"Your password has been updated.": "Пароль изменён.",
"Could not update your password": "Не удалось изменить пароль",
"At least 12 characters. Use a unique password or passphrase.": "Не менее 12 символов. Используй уникальный пароль или фразу.",
"Check your email for a verification code, then enter it below to finish.": "Найди код подтверждения в письме и введи его ниже.",
"Signed up with an email link?": "Зарегистрировался по ссылке из письма?",
"Set your first password, or recover a forgotten one, using a link sent to your account email.": "Установи первый пароль или восстанови забытый по ссылке из письма.",
"Email me a password reset link": "Отправить ссылку для смены пароля",
"ACCOUNT RECOVERY": "ВОССТАНОВЛЕНИЕ АККАУНТА",
"Reset password": "Сбросить пароль",
"Choose a new password for your account.": "Выбери новый пароль для аккаунта.",
"This reset link has expired or has already been used. Request a fresh link to continue.": "Эта ссылка истекла или уже использована. Запроси новую ссылку.",
"Request a new reset link →": "Запросить новую ссылку →",
"← Back to log in": "← Вернуться ко входу",
"This sign-in link expired or could not be verified. Request a new link below.": "Ссылка для входа истекла или недействительна. Запроси новую ниже.",
"THE CARTOGRAPHER’S DESK": "СТОЛ КАРТОГРАФА",
"My expedition.": "Моя экспедиция.",
"Welcome back,": "С возвращением,",
"Recent journeys": "Недавние путешествия",
"Your five latest journeys from the past 30 days.": "Пять последних путешествий за 30 дней.",
"Open →": "Открыть →",
"/5 rounds": "/5 раундов",
"rounds": "раундов",
"My maps": "Мои карты",
"Sign out": "Выйти",
"Could not sign out. Please try again.": "Не удалось выйти. Попробуй ещё раз.",
"THE COMMUNITY AT YOUR COORDINATES": "СООБЩЕСТВО НА ТВОИХ КООРДИНАТАХ",
"Explore the worlds.": "Исследуй миры.",
"From familiar spawn towns to places nobody’s quite sure about. Pick your next expedition.": "От знакомых городов у спавна до загадочных мест. Выбери следующую экспедицию.",
"Search worlds": "Поиск миров",
"Search worlds or servers…": "Найти мир или сервер…",
"Capture source": "Источник карты",
"All worlds": "Все миры",
"Player captures": "Снимки игроков",
"Server exports": "Экспорты серверов",
"No worlds match that search.": "По этому запросу миры не найдены.",
"No maps have been approved yet. Your server could be the first.": "Пока нет одобренных карт. Твой сервер может стать первым.",
"Create the first map": "Создать первую карту",
"SERVER EXPORT": "ЭКСПОРТ СЕРВЕРА",
"PLAYER CAPTURE": "СНИМОК ИГРОКА",
"By": "Автор:",
"Created by": "Автор:",
"locations": "локаций",
"locations ·": "локаций ·",
"blocks": "блоков",
"Terrain of {name}": "Рельеф карты {name}",
"YOUR EXPEDITION": "ТВОЯ ЭКСПЕДИЦИЯ",
"Choose your challenge.": "Выбери сложность.",
"s per round": "с на раунд",
"Zoom allowed": "Можно приближать",
"No zoom": "Без приближения",
"5 rounds · 25,000 possible points · No movement": "5 раундов · До 25 000 очков · Без перемещения",
"Preparing your world…": "Готовим твой мир…",
"Preparing your expedition.": "Готовим экспедицию.",
"Loading panorama and surface map…": "Загружаем панораму и карту…",
"DRAG TO LOOK AROUND": "ПОТЯНИ, ЧТОБЫ ОСМОТРЕТЬСЯ",
"SCROLL TO ZOOM": "КОЛЁСИКО ДЛЯ ПРИБЛИЖЕНИЯ",
"NO ZOOM": "БЕЗ ПРИБЛИЖЕНИЯ",
"TIME LEFT": "ОСТАЛОСЬ ВРЕМЕНИ",
"ROUND": "РАУНД",
"POINTS": "ОЧКИ",
"Expand map": "Развернуть карту",
"Collapse map": "Свернуть карту",
"↑ N · PLACE YOUR GUESS": "↑ С · ПОСТАВЬ МЕТКУ",
"Click the map to drop a pin": "Нажми на карту, чтобы поставить метку",
"Submit guess": "Подтвердить метку",
"Checking your guess…": "Проверяем метку…",
"Next round": "Следующий раунд",
"See expedition results": "Результаты экспедиции",
"Right on the block!": "Точно в блок!",
"You know this place.": "Ты знаешь это место.",
"You know your way around.": "Ты хорошо ориентируешься.",
"A little off the beaten block.": "Немного в стороне.",
"Time slipped away.": "Время вышло.",
"The clock ran out. This round scores zero.": "Время вышло. За этот раунд — ноль очков.",
"{count} blocks from the actual location.": "Расстояние до верного места: {count} блоков.",
"Connection interrupted.": "Соединение прервано.",
"Reconnect": "Подключиться снова",
"360 degree panorama: drag to look around": "Панорама 360°: потяни, чтобы осмотреться",
"Loading panorama…": "Загружаем панораму…",
"Zoom in": "Приблизить",
"Zoom out": "Отдалить",
"Reset view": "Сбросить вид",
"Minecraft map. North is up.": "Карта Minecraft. Север сверху.",
"Minecraft map. North is up. Click to place a guess, or use arrow keys to pan and Enter to pin the center.": "Карта Minecraft. Север сверху. Нажми, чтобы поставить метку, или двигай карту стрелками и нажми Enter для метки в центре.",
"The map viewer could not initialize.": "Не удалось открыть карту.",
"The panorama graphics context was lost. Reconnect to restore the view.": "Панорама потеряла графический контекст. Подключись снова.",
"The surface map could not load. Reconnect to retry.": "Не удалось загрузить карту поверхности. Подключись снова.",
"The surface map took too long to load. Reconnect to retry.": "Загрузка карты заняла слишком много времени. Попробуй снова.",
"This panorama is missing one or more of its six faces.": "В этой панораме отсутствует часть изображений.",
"Panorama could not load. ": "Не удалось загрузить панораму. ",
"EXPEDITION COMPLETE ·": "ЭКСПЕДИЦИЯ ЗАВЕРШЕНА ·",
"Unpacking your expedition…": "Загружаем твою экспедицию…",
"Every guess is a story.": "Каждая метка — история.",
"· Here’s where your instincts took you.": "· Вот куда тебя привело чутьё.",
"Your five-round journey": "Твоё путешествие за пять раундов",
"Round": "Раунд",
"DISTANCE": "РАССТОЯНИЕ",
"Timed out": "Время вышло",
"Another adventure →": "Новое приключение →",
"View leaderboard →": "Открыть рейтинг →",
"Lost connection.": "Соединение потеряно.",
"We couldn’t load this part of the world. Check your connection and try again.": "Не удалось загрузить эту часть мира. Проверь соединение и попробуй снова.",
"Try again": "Попробовать снова",
"UNKNOWN COORDINATES": "НЕИЗВЕСТНЫЕ КООРДИНАТЫ",
"Uncharted territory.": "Неизведанная территория.",
"This map or page isn’t available.": "Эта карта или страница недоступна.",
"Explore worlds →": "Исследовать миры →",
"MADE FOR THE PEOPLE WHO KNOW THEIR WORLDS": "ДЛЯ ТЕХ, КТО ЗНАЕТ СВОИ МИРЫ",
"Become a cartographer.": "Стань картографом.",
"Two ways to bring your world to MineGeo. Both create private drafts for you to preview before submitting for review.": "Два способа добавить свой мир в MineGeo. Оба создают закрытый черновик, который можно проверить перед модерацией.",
"FOR PLAYERS": "ДЛЯ ИГРОКОВ",
"FOR SERVER ADMINISTRATORS": "ДЛЯ АДМИНИСТРАТОРОВ",
"Fabric capture mod.": "Мод захвата Fabric.",
"Explore normally and capture the places worth remembering. Your server doesn’t need a plugin.": "Исследуй мир и снимай интересные места. Плагин на сервере не нужен.",
"Minecraft Java 1.21.11 + Fabric Loader + Fabric API": "Minecraft Java 1.21.11 + Fabric Loader + Fabric API",
"Open the capture panel with G": "Открой панель захвата клавишей G",
"Link your account through a browser pairing code": "Привяжи аккаунт по коду в браузере",
"Capture 5–24 viewpoints for a new map, then upload": "Запиши 5–24 локации и загрузи новую карту",
"Paste a map ID into “Add to map” to contribute 1–12 panoramas": "Вставь ID в «Добавить на карту», чтобы отправить 1–12 панорам",
"Only terrain received by your client enters the map": "В карту входит только полученный клиентом рельеф",
"Contributions check the recorded server address": "При добавлении проверяется адрес сервера",
"Download Fabric mod": "Скачать мод Fabric",
"The Fabric build is not available yet.": "Сборка Fabric пока недоступна.",
"Install the JAR in your Minecraft mods folder alongside Fabric API.": "Помести JAR в папку mods вместе с Fabric API.",
"Server export plugin.": "Плагин экспорта сервера.",
"Export generated Overworld terrain. MineGeo renders panoramas without a connected player or BlueMap.": "Экспортируй рельеф Верхнего мира. MineGeo создаёт панорамы без игрока на сервере и без BlueMap.",
"Bukkit/Paper 1.21.11 · Java 21": "Bukkit/Paper 1.21.11 · Java 21",
"Select a bounded area and export existing chunks": "Выбери область и экспортируй созданные чанки",
"Choose up to 24 random, safe surface viewpoints": "До 24 случайных безопасных точек на поверхности",
"Use /minegeo contribute <map ID> to join an existing map": "Добавляй панорамы на существующую карту: /minegeo contribute <ID карты>",
"Link a contributor token and upload resumably": "Привяжи токен автора и загрузи экспорт с возможностью продолжения",
"Set the public Minecraft server address before exporting": "Перед экспортом укажи публичный адрес сервера Minecraft",
"Vanilla blocks/fluids, simplified lighting": "Ванильные блоки и жидкости, упрощённое освещение",
"Download server plugin": "Скачать плагин",
"The plugin build is not available yet.": "Сборка плагина пока недоступна.",
"Start with a small, explored area around your builds. Exports are saved in": "Начни с небольшой исследованной области вокруг построек. Экспорты сохраняются в",
". The manifest appears when export finishes. Upload sends the latest completed export; it does not capture the world.": ". Манифест появится после экспорта. Upload отправляет последний готовый экспорт, а не снимает мир заново.",
"Put the JAR in plugins/ and restart. Set website, server-name and server-address in plugins/MineGeo/config.yml, then run /minegeo reload. HTTP LAN addresses are supported for testing. Permission: minegeo.admin.": "Помести JAR в plugins/ и перезапусти сервер. Укажи website, server-name и server-address в plugins/MineGeo/config.yml, затем выполни /minegeo reload. Для тестирования поддерживаются локальные HTTP-адреса. Право: minegeo.admin.",
"Your map, your credit.": "Твоя карта. Твоё авторство.",
"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.": "Владельцы могут разрешить игрокам добавлять панорамы. Каждый игрок может добавить до 12 на карту. Сначала их принимает владелец, затем модератор проверяет обновление.",
"Open my dashboard": "Открыть мой кабинет",
"THE EXPLORER’S FIELD GUIDE": "РУКОВОДСТВО ИССЛЕДОВАТЕЛЯ",
"Look for the little things": "Замечай мелочи",
"MineGeo drops you into a panorama from a community Minecraft world. Find out where you are before the clock runs out.": "MineGeo показывает панораму мира сообщества. Найди своё место на карте до конца таймера.",
"Look around": "Осмотрись",
"Drag to look around. Mountains, shorelines, roofs, and landmarks all help. You stay in one place throughout a round.": "Потяни панораму, чтобы осмотреться. Горы, берег, крыши и постройки помогут найти место. В течение раунда ты остаёшься на одной точке.",
"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.": "Разверни карту справа внизу и поставь метку. Север сверху, X растёт на восток, Z — на юг. Перед подтверждением метку можно перемещать.",
"Closer means better": "Чем ближе, тем лучше",
"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.": "За раунд можно получить до 5 000 очков в зависимости от расстояния. Попадание с точностью до одного блока даёт максимум. В конце можно сравнить все пять меток.",
"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.": "Лёгкий режим даёт 180 секунд, обычный — 90, сложный — 30. В сложном режиме нет приближения. Если время вышло, раунд даёт ноль очков, но экспедицию можно продолжить.",
"THE COMMUNITY’S COMPASS": "КОМПАС СООБЩЕСТВА",
"Let’s find a world →": "Найти мир →",
"Five chances to find your way": "Пять попыток найти свой путь",
"Probably.": "Наверняка.",
"Every world has a story.": "У каждого мира своя история.",
"Player captures show the terrain received by the client. Unexplored map coverage remains blank.": "Снимки игроков показывают рельеф, загруженный клиентом. Неисследованные области остаются пустыми.",
"Server-generated views use vanilla terrain and simplified lighting. Entities, shaders, custom packs and dynamic sign text are omitted.": "Экспорты сервера используют ванильный рельеф и упрощённое освещение. Сущности, шейдеры, пользовательские паки и текст табличек не отображаются.",
"YOUR WORLD · YOUR LOCATIONS": "ТВОЙ МИР · ТВОИ ЛОКАЦИИ",
"Your maps start privately. You decide when they’re ready for review. Contributions are hidden here after five days. Their panoramas and direct links stay available.": "Новые карты видны только тебе. Ты решаешь, когда отправить их на модерацию. Добавления скрываются из списка через пять дней, но панорамы и прямые ссылки сохраняются.",
"A blank map is a beginning.": "Пустая карта — начало пути.",
"Install the Fabric mod to capture while you explore, or the server plugin to export your world.": "Установи мод Fabric для съёмки во время игры или плагин для экспорта мира.",
"Start your next expedition": "Начать новую экспедицию",
"Map workshop": "Мастерская карт",
"← Your maps": "← Твои карты",
"Loading your draft…": "Загружаем черновик…",
"Map name": "Название карты",
"Description": "Описание",
"Save draft": "Сохранить черновик",
"Draft saved.": "Черновик сохранён.",
"Map details": "Сведения о карте",
"Map tools": "Инструменты карты",
"Draft surface map": "Карта поверхности черновика",
"Overworld surface": "Поверхность Верхнего мира",
"Inspect 360° locations": "Посмотреть локации 360°",
"Inspect submitted panoramas": "Проверить отправленные панорамы",
"Inspect map panoramas": "Посмотреть панорамы карты",
"Choose your locations.": "Выбери свои локации.",
"Choose your locations, then submit them to the map owner.": "Выбери локации и отправь их владельцу карты.",
"Five random locations are picked for each game. Add up to 24 locations to make repeat games more varied. You can use the map ID below to add your own panoramas too.": "Каждая игра выбирает пять случайных локаций. Добавь до 24, чтобы игры были разнообразнее. Используй ID ниже, чтобы добавить свои панорамы.",
"Keep at least": "Оставь не менее",
"clear, useful panorama": "понятных и полезных панорам",
"panoramas": "панорам",
"panoramas ·": "панорам ·",
". Selected:": ". Выбрано:",
"The map holds at most 24, including pending uploads and unselected captures.": "На карте может быть до 24 панорам, включая незавершённые загрузки и невыбранные снимки.",
"The approved version stays playable while you edit and moderators review your update.": "Одобренная версия доступна для игры, пока ты редактируешь карту и модераторы проверяют обновление.",
"Submit for review →": "Отправить на модерацию →",
"Submit to map owner →": "Отправить владельцу карты →",
"Submitted for moderator review.": "Отправлено на модерацию.",
"Submitted for map owner approval.": "Отправлено на проверку владельцу.",
"Map update awaiting review": "Обновление ждёт модерации",
"Map unpublished · appeal required": "Карта скрыта · нужна апелляция",
"Map banned after three confirmed reports. Open your map to appeal.": "Карта заблокирована после трёх подтверждённых жалоб. Открой её, чтобы подать апелляцию.",
"Open map →": "Открыть карту →",
"Open contribution →": "Открыть добавление →",
"Original capture": "Исходная съёмка",
"PLAYER CONTRIBUTION": "ДОБАВЛЕНИЕ ИГРОКА",
"Contribution:": "Добавление:",
"Contributor:": "Автор:",
"Map ID:": "ID карты:",
"Withdraw contribution": "Отозвать добавление",
"Contribution withdrawn. Its pending quota is released.": "Добавление отозвано. Занятые им слоты освобождены.",
"The owner included your panoramas in the map draft. Map updates become playable after moderator approval.": "Владелец включил твои панорамы в черновик. Они станут доступны после одобрения модератора.",
"Waiting for the map owner's approval.": "Ожидаем одобрения владельца карты.",
"Upload a fresh capture if you want to try again.": "Загрузи новую съёмку, чтобы попробовать снова.",
"This contribution no longer reserves panorama slots. Upload a fresh capture to try again.": "Это добавление больше не занимает слоты. Загрузи новую съёмку, чтобы попробовать снова.",
"Link your server plugin.": "Привяжи плагин сервера.",
"Generate plugin token": "Создать токен плагина",
"Server plugin": "Плагин сервера",
"Create a contributor token, then run": "Создай токен автора и выполни",
"in your server console. Fabric uses browser pairing instead.": "в консоли сервера. Мод Fabric привязывается через браузер.",
"Copy this token now. It is shown once. Keep it out of public chat.": "Скопируй токен сейчас: он показывается один раз. Не публикуй его в чате.",
"Revoke": "Отозвать",
"· revoked": "· отозван",
"LINK YOUR CAPTURE CLIENT": "ПРИВЯЗКА КЛИЕНТА ЗАХВАТА",
"Link my Minecraft client": "Привязать клиент Minecraft",
"Open the pairing link shown by your Fabric mod.": "Открой ссылку привязки из мода Fabric.",
"Sign in first, then reopen the pairing link from Minecraft.": "Войди в аккаунт и заново открой ссылку из Minecraft.",
"Approve only if this code is displayed by your MineGeo Fabric mod. Linking lets that client upload private map drafts to your account.": "Подтверждай только код, показанный твоим модом MineGeo Fabric. Привязка разрешит этому клиенту загружать закрытые черновики карт в твой аккаунт.",
"Capture client linked. You can return to Minecraft.": "Клиент привязан. Можно вернуться в Minecraft.",
"Please wait…": "Подожди…",
"Cancel": "Отмена",
"Ranked explorers: {count}": "Игроков в рейтинге: {count}",
"A closer look.": "Рассмотри поближе.",
"360° PANORAMA": "ПАНОРАМА 360°",
"Drag to look around · Scroll to zoom": "Потяни для обзора · Колёсико для приближения",
"Close preview": "Закрыть просмотр",
"Expand preview": "Развернуть просмотр",
"Restore preview size": "Вернуть размер просмотра",
"Preview location": "Просмотреть локацию",
"Previous panorama": "Предыдущая панорама",
"Next panorama": "Следующая панорама",
"Location": "Локация",
"Choose locations": "Выбрать локации",
"Check for blocked views and missing terrain.": "Проверь, нет ли закрытых видов и отсутствующего рельефа.",
"Entire map": "Вся карта",
"This panorama (round": "Эта панорама (раунд",
"Report content": "Пожаловаться",
"What are you reporting?": "На что ты жалуешься?",
"What happened?": "Что произошло?",
"Describe the problem and where to find it.": "Опиши проблему и укажи, где её найти.",
"Evidence link (optional)": "Ссылка на доказательство (необязательно)",
"Send report": "Отправить жалобу",
"Sign in to send a report. A moderator reviews the content and your explanation. Three confirmed reports unpublish the map until an owner appeal is approved.": "Войди, чтобы отправить жалобу. Модератор проверит содержание и описание. Три подтверждённые жалобы скрывают карту до одобрения апелляции владельца.",
"Report sent for moderator review. Only confirmed reports count toward the three-report map ban.": "Жалоба отправлена модератору. Для блокировки карты учитываются только подтверждённые жалобы.",
"Inappropriate or offensive content": "Неприемлемое или оскорбительное содержание",
"Personal or private information": "Личная или закрытая информация",
"Wrong world or misleading map": "Другой мир или вводящая в заблуждение карта",
"Broken panorama or map": "Неисправная панорама или карта",
"Spam or advertising": "Спам или реклама",
"Other": "Другое",
"Map moderation.": "Модерация карт.",
"Review map quality and coverage before a world enters the public catalog.": "Проверь качество и охват карты перед публикацией в общем каталоге.",
"Loading review queue…": "Загружаем очередь проверки…",
"The review queue is clear.": "Очередь проверки пуста.",
"Map for moderator review": "Карта для проверки модератором",
"Inspect reported content": "Посмотреть содержание жалобы",
"Reported map version": "Версия карты из жалобы",
"Review reason": "Причина решения",
"Approve map": "Одобрить карту",
"Approve update": "Одобрить обновление",
"Return with feedback": "Вернуть с замечаниями",
"Unpublish": "Скрыть карту",
"Community reports": "Жалобы сообщества",
"Confirming a valid report adds one strike to the map. Three strikes automatically unpublish it. Dismissed reports add no strikes.": "Подтверждённая жалоба даёт карте одно предупреждение. Три предупреждения автоматически скрывают карту. Отклонённые жалобы не учитываются.",
"Reason": "Причина",
"Decision reason": "Причина решения",
"Open evidence": "Открыть доказательство",
"Confirm report · add strike": "Подтвердить жалобу",
"Dismiss report": "Отклонить жалобу",
"Map ban appeals": "Апелляции блокировок",
"Appeal:": "Апелляция:",
"Appeal decision reason": "Причина решения по апелляции",
"Approve appeal · restore map": "Одобрить и восстановить карту",
"Reject appeal · keep ban": "Отклонить и оставить блокировку",
"Community report strikes": "Предупреждения по жалобам",
"Three confirmed reports automatically unpublish this map.": "Три подтверждённые жалобы автоматически скрывают карту.",
"This map cannot be published until a moderator approves your appeal.": "Эта карта не может быть опубликована до одобрения апелляции.",
"Your appeal is waiting for moderator review.": "Твоя апелляция ждёт проверки.",
"Appeal map ban": "Обжаловать блокировку",
"Explain why the map ban should be lifted": "Объясни, почему блокировку следует снять",
"/3 confirmed reports": "/3 подтверждённых жалоб",
"/3 confirmed reports.": "/3 подтверждённых жалоб.",
" · Banned; owner appeal required": " · Заблокирована; нужна апелляция владельца",
"Feedback": "Замечания",
"Moderator feedback:": "Замечания модератора:",
"Player submissions": "Добавления игроков",
"Allow players to add panoramas with you": "Разрешить игрокам добавлять панорамы",
"As the owner, you can fill the map’s 24 panorama slots.": "Как владелец, ты можешь заполнить все 24 слота панорам.",
"Contribution server address": "Адрес сервера для добавлений",
"Contribution server:": "Сервер для добавлений:",
"Save server address": "Сохранить адрес сервера",
"Server address saved.": "Адрес сервера сохранён.",
"Server check turned off. Submissions still need your approval.": "Проверка сервера отключена. Добавления по-прежнему требуют твоего одобрения.",
"Clear address and turn off check": "Очистить адрес и отключить проверку",
"Use the Minecraft hostname or IP players connect to. This address is shown publicly when player contributions are enabled. The mod records the connected server; plugin admins set server-address before exporting. You can override the captured address here, or clear it to allow unchecked submissions.": "Укажи адрес сервера Minecraft, к которому подключаются игроки. Он будет виден при разрешённых добавлениях. Мод записывает сервер подключения, а в плагине адрес задаётся перед экспортом. Здесь можно изменить адрес или очистить его, отключив проверку.",
"Server check is off. The owner has not set a server address; captures from other servers can be submitted. Only submit panoramas from this map’s world.": "Проверка сервера отключена: владелец не указал адрес. Можно отправить снимки с других серверов, но добавлять следует только панорамы из мира этой карты.",
"Capture on this server. Uploads from a different address are blocked.": "Снимай на этом сервере. Загрузки с другого адреса блокируются.",
"Capture in the same Minecraft world, within X": "Снимай в том же мире Minecraft, в пределах X",
"and Z": "и Z",
"to": "до",
"to explore": "для исследования",
"for “": "по запросу «",
"Add panoramas to this map": "Добавить панорамы на карту",
"Add panoramas ·": "Добавить панорамы ·",
"In the Fabric mod, paste this map ID into “Add to map”. For the server plugin, export the area and run:": "В моде Fabric вставь ID в «Добавить на карту». Для плагина экспортируй область и выполни:",
"Open your dashboard after processing, inspect your panoramas, and submit them to the owner. The owner approves them first, then moderators review the map update.": "После обработки открой кабинет, проверь панорамы и отправь владельцу. Сначала он примет добавление, затем модератор проверит обновление карты.",
"Owners can fill all 24 slots, even with player contributions turned off. Export a fresh capture, then use the command below to add it to this map.": "Владелец может заполнить все 24 слота, даже когда добавления игроков отключены. Экспортируй новую съёмку и используй команду ниже.",
"This command also adds your own panoramas. Open the resulting contribution in your dashboard, submit it, then accept it in this map’s submissions below. Finally, submit the map update for moderator review.": "Эта команда добавляет и твои собственные панорамы. Открой добавление в кабинете, отправь его и прими в списке ниже. Затем отправь обновление карты на модерацию.",
"Wait for the current moderator review before accepting another submission.": "Дождись текущей проверки модератором, прежде чем принимать новое добавление.",
"No contributions from the last five days.": "За последние пять дней добавлений нет.",
"Submissions older than five days are hidden from this list. Accepted panoramas stay in your map.": "Добавления старше пяти дней скрыты из списка. Принятые панорамы остаются на карте.",
"Accept into map draft": "Принять в черновик карты",
"Reject with feedback": "Отклонить с замечаниями",
"Could not load the submitted locations. Please try again.": "Не удалось загрузить отправленные локации. Попробуй снова.",
"Fresh chunks, fresh start": "Новые чанки — новое начало",
"A fresh start.": "Новое начало.",
"Refresh guessing map": "Обновить карту для игры",
"THE MAP PLAYERS GUESS ON": "КАРТА ДЛЯ ИГРОКОВ",
"Rebuild the overhead map from a processed capture. To show new buildings, export the same area again and upload it first. Select that capture here; its boundaries must match this map exactly.": "Пересоздай вид сверху из обработанной съёмки. Для новых построек заново экспортируй и загрузи ту же область. Выбери её здесь; границы должны точно совпадать с картой.",
"Panoramas stay unchanged. Refreshing uses no panorama slots, even at 24/24. Submit the finished map update for review to make it playable.": "Панорамы сохраняются. Обновление не занимает слоты, даже при 24/24. Отправь готовую карту на модерацию, чтобы она стала доступна для игры.",
"Terrain capture": "Съёмка рельефа",
"Queueing…": "Ставим в очередь…",
"Refreshing guessing map": "Обновляем игровую карту",
"Map refresh queued. Keep this page open to follow progress.": "Обновление карты в очереди. Оставь страницу открытой, чтобы следить за обработкой.",
"Refreshing your guessing map. This page updates automatically; the published version stays playable.": "Обновляем карту. Страница обновляется автоматически; опубликованная версия доступна для игры.",
"Map refresh failed:": "Обновление карты не удалось:",
". Your previous map is still available. Try refreshing again below.": ". Предыдущая карта доступна. Попробуй обновить её ещё раз ниже.",
"Retry map": "Повторить обновление",
"Update:": "Обновление:",
"Map processing progress": "Прогресс обработки карты",
"Waiting for a processing worker": "Ожидаем обработчик",
"Processing will start when a worker is available.": "Обработка начнётся, когда появится свободный обработчик.",
"Worker connected. Large exports can take a while; progress measures completed work.": "Обработчик подключён. Большие экспорты требуют времени; прогресс показывает завершённую работу.",
"The worker has stopped reporting. Check its terminal and restart it if necessary. Your uploaded export is saved.": "Обработчик перестал отвечать. Проверь его и при необходимости перезапусти. Загруженный экспорт сохранён.",
"Your map and panoramas are being rendered. This page updates automatically.": "Создаём карту и панорамы. Страница обновляется автоматически.",
"Waiting for the capture tool to finish uploading. Retry upload in the tool if the connection was interrupted.": "Ожидаем завершения загрузки. Если соединение прервалось, повтори загрузку в инструменте захвата.",
"Cancel processing": "Отменить обработку",
"Processing cancelled": "Обработка отменена",
"Processing cancelled.": "Обработка отменена.",
"Downloading and checking the export": "Скачиваем и проверяем экспорт",
"Reading the terrain snapshot": "Читаем снимок рельефа",
"Indexing terrain blocks": "Индексируем блоки рельефа",
"Loading block models and textures": "Загружаем модели и текстуры блоков",
"Building terrain geometry": "Создаём геометрию рельефа",
"Preparing the scene": "Подготавливаем сцену",
"Rendering panorama faces": "Создаём изображения панорамы",
"Rendering the surface map": "Создаём карту поверхности",
"Saving processed images": "Сохраняем изображения",
"Processing complete": "Обработка завершена",
"COMPLETE": "ГОТОВО",
"· MINECRAFT 1.21.11": "· MINECRAFT 1.21.11",
"Use at least 2 characters": "Не менее 2 символов",
"Use at most 32 characters": "Не более 32 символов",
"Use at least 12 characters": "Не менее 12 символов",
"Use at most 72 UTF-8 bytes for your password": "Пароль не должен превышать 72 байта UTF-8",
"Use a single line without hidden control characters": "Используй одну строку без скрытых управляющих символов",
"Invalid email address": "Некорректный адрес электронной почты",
"Invalid email": "Некорректный адрес электронной почты",
"The request timed out. Reconnect and try again.": "Время ожидания вышло. Проверь соединение и попробуй снова.",
"Could not reach MineGeo. Check your connection and try again.": "Не удалось подключиться к MineGeo. Проверь соединение и попробуй снова.",
"MineGeo returned an unreadable response. Please try again.": "MineGeo вернул непонятный ответ. Попробуй снова.",
"Request failed. Please try again.": "Запрос не выполнен. Попробуй снова.",
"Confirm your email address before logging in.": "Подтверди почту перед входом.",
"Email or password is incorrect. Try again or reset your password.": "Неверная почта или пароль. Попробуй снова или сбрось пароль.",
"Check your email to confirm your account. If you already have an account, log in or reset your password.": "Проверь почту, чтобы подтвердить аккаунт. Если он уже существует, войди или сбрось пароль.",
"If an account exists for this email, a password reset link is on its way. Check your inbox.": "Если аккаунт с этой почтой существует, мы отправили ссылку для сброса пароля. Проверь почту.",
"If an account exists for this email, a sign-in link is on its way. Check your inbox.": "Если аккаунт с этой почтой существует, мы отправили ссылку для входа. Проверь почту.",
"MineGeo is not configured yet. Please try again later.": "MineGeo ещё не настроен. Попробуй позже.",
"MineGeo could not reach its data service. Please try again.": "MineGeo не смог подключиться к базе данных. Попробуй снова.",
"MineGeo — Get lost in a familiar world": "MineGeo — Затеряйся в знакомом мире",
"Guess where you are in Minecraft. Explore community servers through 360° panoramas and place your pin on the map.": "Угадай, где ты находишься в Minecraft. Исследуй серверы сообщества через панорамы 360° и отмечай своё местоположение на карте.",
"Meet MineGeo’s top explorers. Compare the best five-round scores across easy, normal, and hard Minecraft expeditions.": "Лучшие исследователи MineGeo. Сравнивай результаты экспедиций из пяти раундов на разных уровнях сложности.",
"Players: {players} · Completed expeditions: {games}": "Игроков: {players} · Завершённых экспедиций: {games}",
"Results for “{search}”: {count}": "Результаты по запросу «{search}»: {count}",
"Minimum panoramas: {minimum}. Selected: {selected} / 24": "Минимум панорам: {minimum}. Выбрано: {selected} / 24",
"Keep {location}": "Оставить: {location}",
"{count} blocks": "{count} блоков",
"Each guest contributor may add up to 12 panoramas in total.": "Каждый гость может добавить не более 12 панорам.",
"Draft": "Черновик",
"Uploading": "Загрузка",
"Processing": "Обработка",
"Awaiting review": "На проверке",
"Published": "Опубликовано",
"Failed": "Ошибка",
"Rejected": "Отклонено",
"Submitted": "Отправлено",
"Accepted": "Принято",
"Queued": "В очереди",
"In progress": "В работе",
"Open": "Открыто",
"Approved": "Одобрено",
"Closed": "Закрыто",
"Withdrawn": "Отозвано",
"Contribution: {status}": "Вклад: {status}",
"Update: {status}": "Обновление: {status}",
"Worlds to explore: {count}": "Миров для исследования: {count}",
"All maps ({count})": "Все карты ({count})",
"Site settings": "Настройки сайта",
"Changes apply to every player. Only moderators can edit these settings.": "Изменения применяются ко всем игрокам. Менять эти настройки могут только модераторы.",
"Beta badge": "Значок BETA",
"Show BETA next to the MineGeo logo throughout the site.": "Показывать BETA рядом с логотипом MineGeo на всех страницах сайта.",
"Visible to all players": "Виден всем игрокам",
"Hidden for all players": "Скрыт для всех игроков",
"Hide beta badge": "Скрыть значок BETA",
"Show beta badge": "Показать значок BETA",
"Setting saved. Open pages update within 30 seconds.": "Настройка сохранена. Открытые страницы обновятся в течение 30 секунд."
}

7
shared/site-settings.ts Normal file
View File

@@ -0,0 +1,7 @@
import { z } from "zod";
export const siteSettingsSchema = z
.object({ betaBadgeEnabled: z.boolean() })
.strict();
export type SiteSettings = z.infer<typeof siteSettingsSchema>;
export const defaultSiteSettings: SiteSettings = { betaBadgeEnabled: true };

View File

@@ -0,0 +1,36 @@
create table public.site_settings (
id boolean primary key default true check (id),
beta_badge_enabled boolean not null default true,
updated_at timestamptz not null default now(),
updated_by uuid references public.profiles(id) on delete set null
);
insert into public.site_settings (id) values (true);
alter table public.site_settings enable row level security;
revoke all on public.site_settings from public, anon, authenticated;
grant select, update on public.site_settings to service_role;
create policy "Server manages site settings" on public.site_settings
for all to service_role using (true) with check (true);
-- Invoked only by the server after authenticating the moderator. The profile
-- check also protects this operation if another server caller is added later.
create function public.set_beta_badge(p_reviewer uuid, p_enabled boolean)
returns boolean language plpgsql security invoker set search_path = '' as $$
begin
if not exists (
select 1 from public.profiles where id = p_reviewer and role = 'moderator'
) then
raise exception using errcode = '42501', message = 'Moderator permission required';
end if;
if p_enabled is null then
raise exception 'Beta badge visibility is required';
end if;
update public.site_settings
set beta_badge_enabled = p_enabled, updated_at = now(), updated_by = p_reviewer
where id = true;
if not found then raise exception 'Site settings not found'; end if;
return p_enabled;
end;
$$;
revoke all on function public.set_beta_badge(uuid, boolean) from public, anon, authenticated;
grant execute on function public.set_beta_badge(uuid, boolean) to service_role;

190
tests/deploy-vercel.test.ts Normal file
View File

@@ -0,0 +1,190 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { zipSync, strToU8 } from "fflate";
// The command is also an importable module, so tests never trigger a real upload.
import {
deploymentFiles,
deploymentPayload,
deploy,
} from "../scripts/deploy-vercel.mjs";
function fixture(
run: (
root: string,
write: (file: string, data: string | Uint8Array) => void,
) => void,
) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "minegeo-deploy-test-"));
function write(file: string, data: string | Uint8Array) {
fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true });
fs.writeFileSync(path.join(root, file), data);
}
try {
write(
"package.json",
JSON.stringify({
name: "minegeo",
scripts: { worker: "private worker" },
repository: "https://private.invalid/repository",
}),
);
write("app/layout.tsx", "export default function Layout() {}");
run(root, write);
} finally {
assert.ok(root.startsWith(path.join(os.tmpdir(), "minegeo-deploy-test-")));
fs.rmSync(root, { recursive: true, force: true });
}
}
test("deployment collects web assets and excludes local secrets, Git and build outputs", () => {
fixture((root, write) => {
write(".env.local", "SUPABASE_SERVICE_ROLE_KEY=secret");
write(".env.deploy.local", "VERCEL_TOKEN=secret");
write(".git/config", "private Git configuration");
write("public/.hidden", "secret");
write("public/generated/capture.png", "private capture");
write("public/downloads/old.jar", "generated artifact");
write("app/downloads/page.tsx", "export default function Downloads() {}");
write("worker/main.ts", "private worker");
write("public/images/map.svg", "<svg/>");
write("components/Example.tsx", "export const Example = 1;");
const files = deploymentFiles(root);
assert.deepEqual(
files.map((entry: { file: string }) => entry.file).sort(),
[
"app/downloads/page.tsx",
"app/layout.tsx",
"components/Example.tsx",
"package.json",
"public/images/map.svg",
],
);
const pkg = JSON.parse(
Buffer.from(
files.find((entry: { file: string }) => entry.file === "package.json")
.data,
"base64",
).toString(),
);
assert.equal(pkg.repository, undefined);
assert.equal(pkg.scripts.worker, undefined);
const payload = deploymentPayload(files);
assert.equal(payload.target, "production");
assert.equal(deploymentPayload(files, true).target, undefined);
assert.equal(Object.hasOwn(payload, "gitSource"), false);
assert.equal(Object.hasOwn(payload, "gitMetadata"), false);
assert.equal(
JSON.stringify(payload).includes("SUPABASE_SERVICE_ROLE_KEY"),
false,
);
});
});
test("privacy scan rejects the private Git hostname in public files and bundled JARs", () => {
fixture((root, write) => {
execFileSync("git", ["init", root], { windowsHide: true, stdio: "ignore" });
execFileSync(
"git",
[
"-C",
root,
"remote",
"add",
"origin",
"https://private.example.invalid/user/minegeo.git",
],
{ windowsHide: true },
);
write("public/leak.txt", "https://private.example.invalid");
assert.throws(
() => deploymentFiles(root),
/Private Git server reference found in public\/leak.txt/,
);
fs.unlinkSync(path.join(root, "public/leak.txt"));
write(
"public/tools/mod.jar",
zipSync({ "metadata.json": strToU8('"private.example.invalid"') }),
);
assert.throws(
() => deploymentFiles(root),
/Private Git server reference found in public\/tools\/mod.jar/,
);
});
});
test("deploy sends every file in one POST, then polls until READY", async () => {
const requests: { url: string; init: RequestInit }[] = [];
const states = ["QUEUED", "BUILDING", "READY"];
const logs: string[] = [];
const payload = deploymentPayload([
{ file: "app/page.tsx", data: "YWJj", encoding: "base64" },
]);
const result = await deploy(payload, {
token: "test-token",
wait: async () => {},
log: (message: string) => logs.push(message),
request: async (url, init = {}) => {
requests.push({ url: String(url), init });
return new Response(
JSON.stringify({
id: "test-deployment",
readyState: states.shift(),
url: "preview.example.invalid",
}),
{ status: 200 },
);
},
});
assert.equal(result.readyState, "READY");
assert.equal(
requests.filter((item) => item.init.method === "POST").length,
1,
);
assert.deepEqual(
JSON.parse(requests[0].init.body as string).files,
payload.files,
);
assert.equal(
requests.slice(1).every((item) => item.init.method === "GET"),
true,
);
assert.equal(
logs.some((message) => message.includes("test-token")),
false,
);
});
test("failed builds and rejected authentication never report success", async () => {
const payload = deploymentPayload([]);
await assert.rejects(
deploy(payload, {
token: "test",
log: () => {},
request: async () =>
new Response(
JSON.stringify({
id: "failed",
readyState: "ERROR",
errorMessage: "Build failed",
}),
{ status: 200 },
),
}),
/Build failed/,
);
await assert.rejects(
deploy(payload, {
token: "test",
log: () => {},
request: async () =>
new Response(JSON.stringify({ error: { message: "Unauthorized" } }), {
status: 401,
}),
}),
/Vercel 401/,
);
});

82
tests/i18n.test.ts Normal file
View File

@@ -0,0 +1,82 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import fs from "node:fs";
import path from "node:path";
import ts from "typescript";
import russian from "../shared/locales/ru.json";
import { detectLocale, resolveLocale, translator } from "../shared/i18n";
test("browser language preferences support regional tags and weighted fallbacks", () => {
assert.equal(detectLocale("ru-RU,ru;q=0.9,en;q=0.8"), "ru");
assert.equal(detectLocale("en-US;q=0.3,ru;q=0.9"), "ru");
assert.equal(detectLocale("de-DE,ru;q=0.7,en;q=0.5"), "ru");
assert.equal(detectLocale("ru;q=0,en-GB;q=0.8"), "en");
assert.equal(detectLocale("ru;q=invalid,en"), "en");
assert.equal(detectLocale(null), "en");
assert.equal(detectLocale("fr-FR"), "en");
});
test("an explicit language choice overrides automatic detection", () => {
assert.equal(resolveLocale("en", "ru-RU"), "en");
assert.equal(resolveLocale("ru", "en-US"), "ru");
assert.equal(resolveLocale("auto", "ru"), "ru");
assert.equal(resolveLocale("unexpected", "ru"), "ru");
});
test("translations interpolate values without changing player content", () => {
const ru = translator("ru"),
en = translator("en");
assert.equal(ru("Log in"), "Войти");
assert.equal(en("Log in"), "Log in");
assert.equal(
ru("Keep {location}", { location: "Steve's Castle" }),
"Оставить: Steve's Castle",
);
assert.equal(ru("Map authored by Steve"), "Map authored by Steve");
assert.equal(ru("Keep {location}"), "Оставить: {location}");
});
test("all literal interface messages have Russian translations with matching placeholders", () => {
const missing: string[] = [],
mismatches: string[] = [];
const dictionary = russian as Record<string, string>;
const placeholders = (s: string) =>
[...s.matchAll(/\{(\w+)\}/g)].map((match) => match[1]).sort();
function walk(directory: string) {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const file = path.join(directory, entry.name);
if (entry.isDirectory()) walk(file);
else if (file.endsWith(".tsx")) {
const source = ts.createSourceFile(
file,
fs.readFileSync(file, "utf8"),
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX,
);
function visit(node: ts.Node) {
if (
ts.isCallExpression(node) &&
node.expression.getText(source) === "t" &&
node.arguments[0] &&
ts.isStringLiteral(node.arguments[0])
) {
const message = node.arguments[0].text;
if (!dictionary[message]) missing.push(`${file}: ${message}`);
else if (
JSON.stringify(placeholders(message)) !==
JSON.stringify(placeholders(dictionary[message]))
)
mismatches.push(message);
}
ts.forEachChild(node, visit);
}
visit(source);
}
}
}
walk("app");
walk("components");
assert.deepEqual(missing, []);
assert.deepEqual(mismatches, []);
});

View File

@@ -0,0 +1,19 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { siteSettingsSchema } from "../shared/site-settings";
test("site settings accept only an explicit boolean and reject privilege fields", () => {
for (const enabled of [false, true])
assert.equal(
siteSettingsSchema.parse({ betaBadgeEnabled: enabled }).betaBadgeEnabled,
enabled,
);
for (const payload of [
{},
{ betaBadgeEnabled: "false" },
{ betaBadgeEnabled: null },
{ betaBadgeEnabled: false, role: "moderator" },
{ betaBadgeEnabled: false, updatedBy: "another-player" },
])
assert.equal(siteSettingsSchema.safeParse(payload).success, false);
});

File diff suppressed because one or more lines are too long