Improve MineGeo interface and functionality

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
"use client";
import { useI18n } from "@/components/I18nProvider";
import Image from "next/image";
import Link from "next/link";
import { ArrowUpRight, MapPin } from "lucide-react";
@@ -21,13 +23,14 @@ export function MapCard({
index?: number;
minecraftPaper?: boolean;
}) {
const { t } = useI18n();
return (
<Link className="map-card" href={`/servers/${map.id}`}>
<div className={`card-image${minecraftPaper ? " minecraft-paper" : ""}`}>
<div className="card-map-content">
<Image
src={map.image || "/images/example-map.webp"}
alt={`${map.name} terrain`}
alt={t("Terrain of {name}", { name: map.name })}
fill
loading={index < 3 ? "eager" : "lazy"}
unoptimized
@@ -35,26 +38,30 @@ export function MapCard({
/>
</div>
<span className="card-type">
{map.source === "plugin" ? "SERVER EXPORT" : "PLAYER CAPTURE"}
{map.source === "plugin" ? t("SERVER EXPORT") : t("PLAYER CAPTURE")}
</span>
<span className="card-number">0{index + 1}</span>
</div>
<div className="card-body">
<span className="eyebrow">{map.server?.name || "Overworld"}</span>
<span className="eyebrow">{map.server?.name || t("Overworld")}</span>
<h3>
{map.name}
<ArrowUpRight size={20} />
</h3>
<p>{map.description}</p>
{map.credit && <p className="map-credit">By {map.credit}</p>}
{map.credit && (
<p className="map-credit">
{t("By")} {map.credit}
</p>
)}
<div className="card-meta">
<span>
<MapPin size={13} />
{map.panorama_count} locations
{map.panorama_count} {t("locations")}
</span>
<span>
{map.bounds.maxX - map.bounds.minX} ×{" "}
{map.bounds.maxZ - map.bounds.minZ} blocks
{map.bounds.maxZ - map.bounds.minZ} {t("blocks")}
</span>
</div>
</div>

View File

@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/I18nProvider";
import { useEffect, useState } from "react";
import { api } from "./StartGame";
@@ -11,6 +13,7 @@ export function MapRefresh({
disabled: boolean;
onQueued: () => void;
}) {
const { t, formatDate } = useI18n();
const [sources, setSources] = useState<
{ id: string; name: string; created_at: string }[]
>([]);
@@ -44,15 +47,15 @@ export function MapRefresh({
}
return (
<section className="editor-panel">
<span className="eyebrow">THE MAP PLAYERS GUESS ON</span>
<h2>Refresh guessing map</h2>
<span className="eyebrow">{t("THE MAP PLAYERS GUESS ON")}</span>
<h2>{t("Refresh guessing map")}</h2>
<p className="muted">
Rebuild the overhead map from a processed capture. To show new
buildings, export the same area again and upload it first. Select that
capture here; its boundaries must match this map exactly.
{t(
"Rebuild the overhead map from a processed capture. To show new buildings, export the same area again and upload it first. Select that capture here; its boundaries must match this map exactly.",
)}
</p>
<label className="field">
Terrain capture
{t("Terrain capture")}
<select
value={source}
onChange={(e) => setSource(e.target.value)}
@@ -60,8 +63,8 @@ export function MapRefresh({
>
{sources.map((s) => (
<option key={s.id} value={s.id}>
{s.id === id ? "Original capture" : s.name} ·{" "}
{new Date(s.created_at).toLocaleDateString()} · {s.id.slice(0, 8)}
{s.id === id ? t("Original capture") : s.name} ·{" "}
{formatDate(s.created_at)} · {s.id.slice(0, 8)}
</option>
))}
</select>
@@ -71,15 +74,16 @@ export function MapRefresh({
disabled={disabled || busy || !sources.some((s) => s.id === source)}
onClick={refresh}
>
{busy ? "Queueing…" : "Refresh guessing map"}
{busy ? t("Queueing…") : t("Refresh guessing map")}
</button>
<p className="muted">
Panoramas stay unchanged. Refreshing uses no panorama slots, even at
24/24. Submit the finished map update for review to make it playable.
{t(
"Panoramas stay unchanged. Refreshing uses no panorama slots, even at 24/24. Submit the finished map update for review to make it playable.",
)}
</p>
{error && (
<p role="alert" className="error-message">
{error}
{t(error)}
</p>
)}
</section>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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