Improve MineGeo interface and functionality
This commit is contained in:
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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 <map ID> 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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -982,7 +982,9 @@ pre {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
touch-action: none;
|
||||
cursor: grab;
|
||||
}
|
||||
.panorama:active { cursor: grabbing; }
|
||||
.panorama canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
133
app/page.tsx
133
app/page.tsx
@@ -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>
|
||||
|
||||
@@ -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
220
app/polish.css
Normal 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; }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ?? ""}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 <token></code> in your server console.
|
||||
Fabric uses browser pairing instead.
|
||||
{t("Create a contributor token, then run")}{" "}
|
||||
<code>/minegeo link <token></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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
45
components/I18nProvider.tsx
Normal file
45
components/I18nProvider.tsx
Normal 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],
|
||||
);
|
||||
}
|
||||
40
components/LanguageSwitcher.tsx
Normal file
40
components/LanguageSwitcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
83
components/ModeratorSettings.tsx
Normal file
83
components/ModeratorSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
84
components/SiteSettingsProvider.tsx
Normal file
84
components/SiteSettingsProvider.tsx
Normal 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);
|
||||
}
|
||||
@@ -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
25
docs/vercel-deployment.md
Normal 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.
|
||||
16
lib/api.ts
16
lib/api.ts
@@ -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
31
lib/i18n.ts
Normal 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
24
lib/site-settings.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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",
|
||||
|
||||
93
public/fonts/LICENSE-Monocraft.txt
Normal file
93
public/fonts/LICENSE-Monocraft.txt
Normal 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
BIN
public/fonts/Monocraft.ttf
Normal file
Binary file not shown.
306
scripts/deploy-vercel.mjs
Normal file
306
scripts/deploy-vercel.mjs
Normal 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;
|
||||
});
|
||||
73
scripts/test-site-settings-sql.mjs
Normal file
73
scripts/test-site-settings-sql.mjs
Normal 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();
|
||||
}
|
||||
@@ -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
75
shared/i18n.ts
Normal 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
591
shared/locales/ru.json
Normal 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
7
shared/site-settings.ts
Normal 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 };
|
||||
36
supabase/migrations/20261006141532_site_settings.sql
Normal file
36
supabase/migrations/20261006141532_site_settings.sql
Normal 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
190
tests/deploy-vercel.test.ts
Normal 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
82
tests/i18n.test.ts
Normal 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, []);
|
||||
});
|
||||
19
tests/site-settings.test.ts
Normal file
19
tests/site-settings.test.ts
Normal 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
Reference in New Issue
Block a user