Improve MineGeo UI and application flows
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 2m37s

This commit is contained in:
2026-10-18 11:44:04 +05:00
parent ad73132f15
commit a7085e4e80
50 changed files with 4790 additions and 703 deletions

View File

@@ -1,7 +1,7 @@
"use client";
import { useEffect, useId, useRef } from "react";
import { Compass, MapPin } from "lucide-react";
import { MapPin } from "lucide-react";
import { useI18n } from "./I18nProvider";
const storageKey = "minegeo:intro:v1";
@@ -37,11 +37,12 @@ export function FirstVisitIntro() {
}
}
seenThisSession = true;
// This is decorative delight, so reduced motion goes straight to the site.
if (motion.matches || !element.isConnected) return;
// A welcome must never interrupt another modal, such as account notices.
if (document.querySelector("dialog[open]")) return;
element.showModal();
end = setTimeout(finish, 4750);
end = setTimeout(finish, 4000);
}, 0);
const motionChanged = () => {
if (motion.matches) finish();
@@ -62,26 +63,40 @@ export function FirstVisitIntro() {
className="first-visit-intro"
aria-labelledby={titleId}
>
<div className="intro-landscape" aria-hidden="true">
<div className="intro-horizon" />
<div className="intro-terrain">
{Array.from({ length: 15 }, (_, index) => (
<i key={index} />
))}
<div className="intro-cartography" aria-hidden="true" />
<div className="intro-content">
<div className="intro-map-stage" aria-hidden="true">
<span className="intro-coordinate intro-coordinate-left">
X: −128
</span>
<span className="intro-coordinate intro-coordinate-right">
Z: +256
</span>
<div className="intro-map-enter">
<div className="intro-map-surface">
<span className="intro-map-water" />
<span className="intro-map-path" />
<i className="intro-map-block intro-map-block-one" />
<i className="intro-map-block intro-map-block-two" />
<i className="intro-map-block intro-map-block-three" />
<span className="intro-map-target" />
</div>
</div>
<div className="intro-pin-anchor">
<MapPin className="intro-pin" strokeWidth={1.5} />
</div>
<span className="intro-bearing">N</span>
</div>
<div className="intro-compass">
<span />
<span />
<Compass strokeWidth={1} />
<MapPin className="intro-pin" strokeWidth={1.4} />
<div className="intro-title">
<p className="eyebrow">{t("YOUR NEXT EXPEDITION")}</p>
<h2 id={titleId} aria-label={t("MineGeo")}>
<span aria-hidden="true">MINE</span>
<span aria-hidden="true">GEO</span>
</h2>
<p className="intro-subtitle">
{t("Find your place in a blocky world.")}
</p>
</div>
<span className="intro-coordinate intro-coordinate-left">X: −128</span>
<span className="intro-coordinate intro-coordinate-right">Z: +256</span>
</div>
<div className="intro-title">
<p className="eyebrow">{t("YOUR NEXT EXPEDITION")}</p>
<h2 id={titleId}>{t("MineGeo")}</h2>
<p>{t("Find your place in a blocky world.")}</p>
</div>
<button
type="button"
@@ -89,7 +104,7 @@ export function FirstVisitIntro() {
autoFocus
onClick={() => dialog.current?.close()}
>
{t("Skip intro")} <span aria-hidden="true">↵</span>
{t("Skip intro")} <span aria-hidden="true">Esc</span>
</button>
</dialog>
);

View File

@@ -198,6 +198,7 @@ export function Game({ id }: { id: string }) {
key={session.round}
urls={current.faces}
zoom={session.difficultySettings.zoom}
pan={session.difficultySettings.pan}
onReady={ready}
onError={onError}
/>
@@ -239,7 +240,7 @@ export function Game({ id }: { id: string }) {
>
<MapIcon size={20} aria-hidden="true" />
<span>{pin ? t("Edit your guess") : t("Open guess map")}</span>
{pin && (
{pin && session.difficultySettings.coordinates !== false && (
<small>
X {Math.round(pin.x)} · Z {Math.round(pin.z)}
</small>
@@ -247,7 +248,13 @@ export function Game({ id }: { id: string }) {
</button>
<div className="game-map-content" id="guess-map-panel">
<div className="map-topline">
<span>{t("↑ N · PLACE YOUR GUESS")}</span>
<span>
{t(
session.difficultySettings.compass === false
? "PLACE YOUR GUESS"
: "↑ N · PLACE YOUR GUESS",
)}
</span>
<button
aria-label={expanded ? t("Collapse map") : t("Expand map")}
onClick={() => setExpanded((v) => !v)}
@@ -265,11 +272,15 @@ export function Game({ id }: { id: string }) {
onReady={mapReady}
onError={onError}
disabled={!!result || busy}
zoom={session.difficultySettings.mapZoom}
compass={session.difficultySettings.compass}
/>
<div className="map-actions">
<small>
{pin
? `X ${Math.round(pin.x)} · Z ${Math.round(pin.z)}`
? session.difficultySettings.coordinates !== false
? `X ${Math.round(pin.x)} · Z ${Math.round(pin.z)}`
: t("Guess selected")
: t("Click the map to drop a pin")}
</small>
<button
@@ -286,13 +297,18 @@ export function Game({ id }: { id: string }) {
)}
<div className="look-help">
<span>
<Move size={12} /> {t("DRAG TO LOOK AROUND")}
<Move size={12} />{" "}
{t(
session?.difficultySettings.pan === false
? "FIXED VIEW"
: "DRAG TO LOOK AROUND",
)}
</span>
<span>
<MousePointer2 size={12} />{" "}
{session && !session.difficultySettings.zoom
? t("NO ZOOM")
: t("SCROLL TO ZOOM")}
{session && !session.difficultySettings.zoom
? t("NO ZOOM")
: t("SCROLL TO ZOOM")}
</span>
</div>
{(!session || !loaded.panorama || !loaded.map) && !error && (

View File

@@ -15,6 +15,8 @@ export function GuessMap({
onReady,
onError,
disabled = false,
zoom = true,
compass = true,
}: {
bounds: Bounds;
image: string;
@@ -24,6 +26,8 @@ export function GuessMap({
onReady?: () => void;
onError?: (message: string) => void;
disabled?: boolean;
zoom?: boolean;
compass?: boolean;
}) {
const { t } = useI18n();
const root = useRef<HTMLDivElement>(null),
@@ -60,10 +64,15 @@ export function GuessMap({
maxBounds: b.pad(0.15),
attributionControl: false,
zoomControl: false,
scrollWheelZoom: zoom,
touchZoom: zoom,
doubleClickZoom: zoom,
boxZoom: zoom,
});
L.control
.zoom({ zoomInTitle: t("Zoom in"), zoomOutTitle: t("Zoom out") })
.addTo(m);
if (zoom)
L.control
.zoom({ zoomInTitle: t("Zoom in"), zoomOutTitle: t("Zoom out") })
.addTo(m);
// Install cleanup immediately, including when later initialization fails.
let observer: ResizeObserver | undefined;
cleanup = () => {
@@ -94,6 +103,7 @@ export function GuessMap({
m.fitBounds(b, { padding: [0, 0], animate: false });
const fitZoom = m.getZoom();
m.setMinZoom(fitZoom);
if (!zoom) m.setMaxZoom(fitZoom);
markers.current = L.layerGroup().addTo(m);
setGeneration((v) => v + 1);
m.on("click", (e: Leaflet.LeafletMouseEvent) => {
@@ -136,11 +146,14 @@ export function GuessMap({
const wasFitted = Math.abs(m.getZoom() - fittedZoom) < 0.01;
m.invalidateSize({ pan: false });
m.setMinZoom(-5);
// Unlock only while refitting a resized fixed-zoom map.
if (!zoom) m.setMaxZoom(Infinity);
fittedZoom = m.getBoundsZoom(b);
m.setMaxZoom(Math.max(3, fittedZoom + 3));
if (wasFitted || m.getZoom() < fittedZoom)
m.fitBounds(b, { padding: [0, 0], animate: false });
m.setMinZoom(fittedZoom);
if (!zoom) m.setMaxZoom(fittedZoom);
});
observer.observe(root.current);
})
@@ -150,7 +163,15 @@ export function GuessMap({
clearTimeout(loadTimer);
cleanup();
};
}, [bounds.minX, bounds.maxX, bounds.minZ, bounds.maxZ, image, attempt]);
}, [
bounds.minX,
bounds.maxX,
bounds.minZ,
bounds.maxZ,
image,
attempt,
zoom,
]);
useEffect(() => {
const L = lib.current,
group = markers.current;
@@ -183,11 +204,15 @@ export function GuessMap({
style={{ height: "100%", width: "100%" }}
role="application"
aria-label={
disabled
? 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.",
!compass
? t(
"Minecraft map. Click to place a guess, or use arrow keys to pan and Enter to pin the center.",
)
: disabled
? 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 && (

View File

@@ -110,6 +110,21 @@ export function LeaderboardTable({
/>
<span className="player-name">
<Username name={entry.display_name} appearance={entry} />
{entry.tournament_champion && (
<Link
className="champion-badge"
href={`/tournaments/${entry.tournament_champion.tournament_id}`}
title={t("Champion spotlight until {date}", {
date:
new Date(entry.tournament_champion.expires_at)
.toISOString()
.slice(0, 16)
.replace("T", " ") + " UTC",
})}
>
🏆 {t("TOURNAMENT CHAMPION")}
</Link>
)}
{entry.player_id === playerId && (
<span className="you-label">{t("You")}</span>
)}
@@ -155,13 +170,18 @@ export function WinnersPodium({ leaders }: { leaders: LeaderboardEntry[] }) {
place: String(index + 1).padStart(2, "0"),
})}
</span>
<PlayerAvatar
id={entry.player_id}
avatarPath={entry.avatar_path}
/>
<PlayerAvatar id={entry.player_id} avatarPath={entry.avatar_path} />
<h2>
<Username name={entry.display_name} appearance={entry} />
</h2>
{entry.tournament_champion && (
<Link
className="champion-badge"
href={`/tournaments/${entry.tournament_champion.tournament_id}`}
>
🏆 {t("TOURNAMENT CHAMPION")}
</Link>
)}
<p>
{formatNumber(entry.best_score)} <span>/ 25,000</span>
</p>

View File

@@ -25,6 +25,9 @@ export function MultiplayerGame({
action,
remaining,
onLeave,
mediaPath,
embedded = false,
leaveLabel = "Leave room",
}: {
id: string;
room: RoomView;
@@ -32,6 +35,9 @@ export function MultiplayerGame({
action: (type: string, payload?: object) => Promise<boolean>;
remaining: (deadline: string) => number;
onLeave: () => void;
mediaPath?: string;
embedded?: boolean;
leaveLabel?: string;
}) {
const { t, formatNumber } = useI18n();
const [media, setMedia] = useState<RoomMedia | null>(null);
@@ -50,7 +56,7 @@ export function MultiplayerGame({
const deadline = turn.deadline;
useEffect(() => {
let disposed = false;
api<RoomMedia>(`rooms/${id}/media`)
api<RoomMedia>(mediaPath ?? `rooms/${id}/media`)
.then((next) => {
if (!disposed && next.round === room.round) setMedia(next);
})
@@ -60,7 +66,7 @@ export function MultiplayerGame({
return () => {
disposed = true;
};
}, [id, room.round]);
}, [id, room.round, mediaPath]);
useEffect(() => {
if (
room.status !== "loading" ||
@@ -95,13 +101,18 @@ export function MultiplayerGame({
[],
);
const mediaError = useCallback((message: string) => setError(message), []);
const Root = embedded ? "section" : "main";
const roundScores = [...turn.results].sort((a, b) => b.score - a.score);
return (
<main className="game-page multiplayer-game">
<Root
className="game-page multiplayer-game"
aria-label={embedded ? t("Tournament game") : undefined}
>
{media && (
<Panorama
urls={media.faces}
zoom={room.difficultySettings.zoom}
pan={room.difficultySettings.pan}
onReady={panoramaReady}
onError={mediaError}
/>
@@ -164,7 +175,7 @@ export function MultiplayerGame({
))}
</ul>
<button className="text-link" onClick={onLeave} disabled={busy}>
{t("Leave room")}
{t(leaveLabel)}
</button>
</details>
{media && (
@@ -180,7 +191,13 @@ export function MultiplayerGame({
</button>
<div className="game-map-content" id="multiplayer-guess-map">
<div className="map-topline">
<span>{t("↑ N · PLACE YOUR GUESS")}</span>
<span>
{t(
room.difficultySettings.compass === false
? "PLACE YOUR GUESS"
: "↑ N · PLACE YOUR GUESS",
)}
</span>
<button
aria-label={expanded ? t("Collapse map") : t("Expand map")}
onClick={() => setExpanded((value) => !value)}
@@ -196,6 +213,8 @@ export function MultiplayerGame({
onPin={setPin}
onReady={mapReady}
onError={mediaError}
zoom={room.difficultySettings.mapZoom}
compass={room.difficultySettings.compass}
disabled={
room.status !== "playing" || submitted || busy || seconds === 0
}
@@ -205,7 +224,9 @@ export function MultiplayerGame({
{submitted
? t("Guess locked. Waiting for the other players.")
: pin
? `X ${Math.round(pin.x)} · Z ${Math.round(pin.z)}`
? room.difficultySettings.coordinates !== false
? `X ${Math.round(pin.x)} · Z ${Math.round(pin.z)}`
: t("Guess selected")
: t("Click the map to drop a pin")}
</small>
<button
@@ -315,6 +336,6 @@ export function MultiplayerGame({
</p>
</div>
)}
</main>
</Root>
);
}

View File

@@ -15,11 +15,13 @@ void main(){vec3 d=normalize(vDirection);vec3 a=abs(d);vec2 uv;vec4 color;if(a.x
export function Panorama({
urls,
zoom,
pan = true,
onReady,
onError,
}: {
urls: string[];
zoom: boolean;
pan?: boolean;
onReady: () => void;
onError: (message: string) => void;
}) {
@@ -39,7 +41,7 @@ export function Panorama({
const element = mount.current!;
view.current = { yaw: 0, pitch: 0, fov: 80 };
const down = (e: PointerEvent) => {
if (e.button !== 0 || drag) return;
if (!pan || e.button !== 0 || drag) return;
element.setPointerCapture(e.pointerId);
drag = { x: e.clientX, y: e.clientY, pointerId: e.pointerId };
},
@@ -160,14 +162,18 @@ export function Panorama({
renderer?.dispose();
renderer?.domElement.remove();
};
}, [urls, zoom]);
}, [urls, zoom, pan]);
return (
<>
<div
className="panorama"
ref={mount}
data-native-cursor
aria-label={t("360 degree panorama: drag to look around")}
aria-label={t(
pan
? "360 degree panorama: drag to look around"
: "Fixed panorama view",
)}
/>
{
<div className="panorama-controls">

View File

@@ -7,6 +7,7 @@ import { usePathname } from "next/navigation";
const links = [
["/servers", "Play & maps"],
["/leaderboard", "Leaderboard"],
["/tournaments", "Tournaments"],
["/downloads", "Downloads"],
["/about", "Guide"],
["/docs", "Docs"],

View File

@@ -9,6 +9,8 @@ import {
difficulties,
customDifficultySchema,
type DifficultyPreset,
type ScoringMode,
difficultyRuleDefaults,
} from "@/shared/protocol";
import { api } from "@/lib/client-api";
import { mergeDifficultyPresets } from "@/shared/difficulty-presets";
@@ -27,6 +29,13 @@ export function StartGame({
[difficultyName, setDifficultyName] = useState(""),
[difficultySeconds, setDifficultySeconds] = useState(90),
[difficultyZoom, setDifficultyZoom] = useState(true),
[difficultyRules, setDifficultyRules] = useState<{
pan: boolean;
compass: boolean;
coordinates: boolean;
mapZoom: boolean;
scoring: ScoringMode;
}>(difficultyRuleDefaults),
[savingDifficulty, setSavingDifficulty] = useState(false),
[mode, setMode] = useState<"solo" | "multiplayer">("solo"),
[playerLimit, setPlayerLimit] = useState(4),
@@ -67,6 +76,7 @@ export function StartGame({
name: difficultyName,
seconds: difficultySeconds,
zoom: difficultyZoom,
...difficultyRules,
});
if (!parsed.success) {
setError(parsed.error.issues[0].message);
@@ -85,6 +95,7 @@ export function StartGame({
setDifficultyName("");
setDifficultySeconds(90);
setDifficultyZoom(true);
setDifficultyRules(difficultyRuleDefaults);
setShowDifficultyForm(false);
} catch (failure) {
setError((failure as Error).message);
@@ -157,6 +168,18 @@ export function StartGame({
<ZoomIn size={13} />
{option.zoom ? t("Zoom allowed") : t("No zoom")}
</span>
{!(option.id in difficulties) && (
<span>
{t(
option.scoring === "strict"
? "Strict scoring"
: option.scoring === "gentle"
? "Gentle scoring"
: "Normal scoring",
)}{" "}
· {t(option.pan === false ? "Fixed view" : "Panning allowed")}
</span>
)}
</button>
))}
</div>
@@ -219,6 +242,52 @@ export function StartGame({
/>
{t("Allow zoom")}
</label>
{(
[
["pan", "Allow panorama panning"],
["compass", "Show north indicator"],
["coordinates", "Show guess coordinates"],
["mapZoom", "Allow guess-map zoom"],
] as const
).map(([key, label]) => (
<label className="difficulty-builder-zoom" key={key}>
<input
type="checkbox"
checked={difficultyRules[key]}
disabled={savingDifficulty || loading}
onChange={(event) =>
setDifficultyRules((previous) => ({
...previous,
[key]: event.target.checked,
}))
}
/>
{t(label)}
</label>
))}
<label htmlFor="custom-difficulty-scoring">
{t("Scoring strictness")}
</label>
<select
id="custom-difficulty-scoring"
value={difficultyRules.scoring}
disabled={savingDifficulty || loading}
onChange={(event) =>
setDifficultyRules((previous) => ({
...previous,
scoring: event.target.value as ScoringMode,
}))
}
>
<option value="normal">{t("Normal scoring")}</option>
<option value="strict">{t("Strict scoring")}</option>
<option value="gentle">{t("Gentle scoring")}</option>
</select>
<p className="muted">
{t(
"Strict scoring penalizes distance more; gentle scoring gives more credit for nearby guesses. Every game still has five rounds.",
)}
</p>
<button
className="button primary"
disabled={savingDifficulty || loading}

View File

@@ -0,0 +1,736 @@
"use client";
import {
useCallback,
useEffect,
useRef,
useState,
type FormEvent,
} from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Trophy, Users, CalendarClock, Plus, Copy } from "lucide-react";
import { api } from "@/lib/client-api";
import { useI18n } from "./I18nProvider";
import dynamic from "next/dynamic";
const MultiplayerGame = dynamic(
() => import("./MultiplayerGame").then((module) => module.MultiplayerGame),
{ ssr: false },
);
import { roundClock } from "@/shared/round-clock";
import {
serverLocalToIso,
type TournamentRecord,
type TournamentView,
} from "@/shared/tournaments";
import type { DifficultyPreset } from "@/shared/protocol";
type Events = {
tournaments: TournamentRecord[];
serverNow: number;
serverTimezone: string;
};
function time(value: string, zone: string) {
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
timeZone: zone,
}).format(new Date(value));
}
export function TournamentIndex({ signedIn }: { signedIn: boolean }) {
const { t } = useI18n();
const router = useRouter();
const [events, setEvents] = useState<Events | null>(null);
const [maps, setMaps] = useState<{ id: string; name: string }[]>([]);
const [presets, setPresets] = useState<DifficultyPreset[]>([]);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [creating, setCreating] = useState(false);
const [clock, setClock] = useState(0);
const [timerMode, setTimerMode] = useState<"shared" | "perMini">("shared");
const [roundSeconds, setRoundSeconds] = useState(450);
const offset = useRef(0);
const load = useCallback(async () => {
try {
const next = await api<Events>("tournaments");
offset.current = next.serverNow - Date.now();
setEvents(next);
setClock(next.serverNow);
setError("");
} catch (e) {
setError((e as Error).message);
}
}, []);
useEffect(() => {
void load();
const timer = setInterval(() => {
setClock(Date.now() + offset.current);
}, 1000);
const poll = setInterval(() => void load(), 30000);
return () => {
clearInterval(timer);
clearInterval(poll);
};
}, [load]);
useEffect(() => {
if (!creating) return;
let active = true;
Promise.all([
api<{ maps: { id: string; name: string }[] }>("catalog"),
api<{ presets: DifficultyPreset[] }>("difficulty-presets"),
])
.then(([catalog, difficulties]) => {
if (active) {
setMaps(catalog.maps);
setPresets(difficulties.presets);
}
})
.catch((e) => {
if (active) setError(e.message);
});
return () => {
active = false;
};
}, [creating]);
async function create(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (busy || !events) return;
const form = new FormData(event.currentTarget);
setBusy(true);
setError("");
try {
const result = await api<{ id: string }>("tournaments", {
name: form.get("name"),
datasetId: form.get("dataset"),
difficulty: form.get("difficulty"),
playerLimit: Number(form.get("limit")),
roundCount: Number(form.get("rounds")),
roundTimeSeconds: Number(form.get("seconds")),
timerMode: form.get("timerMode"),
startsAt: serverLocalToIso(
String(form.get("start")),
events.serverTimezone,
),
announceDiscord: form.has("discord"),
});
router.push(`/tournaments/${result.id}`);
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<div className="section tournament-page">
<div className="section-heading">
<div>
<p className="eyebrow">
<Trophy size={16} />
{t("COMMUNITY TOURNAMENTS")}
</p>
<h1>{t("One world. Up to 32 rivals.")}</h1>
<p className="muted">
{t(
"Every tournament round contains five mini-rounds. Everyone plays the same locations.",
)}
</p>
</div>
{signedIn ? (
<button
className="button primary"
onClick={() => setCreating(!creating)}
>
<Plus size={16} />
{t(creating ? "Close" : "Create tournament")}
</button>
) : (
<Link href="/log-in" className="button primary">
{t("Log in to create or join")}
</Link>
)}
</div>
{events && (
<p className="tournament-server-clock">
<CalendarClock size={16} />
{t("Server time")}:{" "}
{time(
new Date(clock || events.serverNow).toISOString(),
events.serverTimezone,
)}{" "}
· {events.serverTimezone}
</p>
)}
{error && (
<div className="notice error" role="alert">
{t(error)}{" "}
<button className="text-link" onClick={() => void load()}>
{t("Try again")}
</button>
</div>
)}
{creating && signedIn && (
<form className="tournament-form panel" onSubmit={create}>
<h2>{t("Create tournament")}</h2>
<label>
{t("Tournament name")}
<input
name="name"
required
minLength={3}
maxLength={80}
placeholder={t("Saturday expedition")}
/>
</label>
<label>
{t("Map")}
<select name="dataset" required defaultValue="">
<option value="" disabled>
{t("Choose a map")}
</option>
{maps.map((map) => (
<option key={map.id} value={map.id}>
{map.name}
</option>
))}
</select>
</label>
<label>
{t("Difficulty")}
<select name="difficulty" defaultValue="normal">
<option value="easy">{t("Easy")}</option>
<option value="normal">{t("Normal")}</option>
<option value="hard">{t("Hard")}</option>
{presets.map((preset) => (
<option key={preset.id} value={preset.id}>
{preset.name}
</option>
))}
</select>
</label>
<label>
{t("Player limit")}
<input
name="limit"
type="number"
min={2}
max={32}
defaultValue={32}
required
/>
</label>
<label>
{t("Tournament rounds")}
<input
name="rounds"
type="number"
min={1}
max={10}
defaultValue={3}
required
/>
</label>
<label>
{t("Timer mode")}
<select
name="timerMode"
value={timerMode}
onChange={(e) => {
const mode = e.target.value as "shared" | "perMini";
setTimerMode(mode);
setRoundSeconds(mode === "shared" ? 450 : 90);
}}
>
<option value="shared">
{t("One budget for five mini-rounds")}
</option>
<option value="perMini">
{t("Separate timer for each mini-round")}
</option>
</select>
</label>
<label>
{t(
timerMode === "shared"
? "Seconds per tournament round"
: "Seconds per mini-round",
)}
<input
name="seconds"
type="number"
min={15}
max={timerMode === "shared" ? 3000 : 600}
step={15}
value={roundSeconds}
onChange={(e) => setRoundSeconds(Number(e.target.value))}
required
/>
</label>
<label>
{t("Start time")} · {events?.serverTimezone ?? t("Server time")}
<input
name="start"
type="datetime-local"
required
disabled={!events}
/>
</label>
<label className="tournament-checkbox">
<input name="discord" type="checkbox" />
{t("Announce in Discord 15 minutes before starting")}
</label>
<p className="muted">
{t(
"A shared budget starts with the first mini-round and keeps running between mini-rounds. Initial loading is excluded.",
)}
</p>
<p className="muted">
{t(
"The organizer joins as a player. Registration closes at the scheduled start. At least two players are needed.",
)}
</p>
<button
disabled={busy || !events || !maps.length}
className="button primary"
>
{t(busy ? "Creating…" : "Create tournament")}
</button>
</form>
)}
<div className="tournament-grid">
{events?.tournaments.map((event) => (
<Link
className="tournament-card panel"
href={`/tournaments/${event.id}`}
key={event.id}
>
<div className="tournament-card-top">
<Trophy size={24} />
<span className={`tournament-status ${event.status}`}>
{t(event.status)}
</span>
</div>
<h2>{event.name}</h2>
<p>
<CalendarClock size={15} />
{time(event.starts_at, event.server_timezone)} ·{" "}
{event.server_timezone}
</p>
<p>
<Users size={15} />
{t("Up to {count} players", { count: event.player_limit })} ·{" "}
{t("{count} tournament rounds", { count: event.round_count })}
</p>
<span className="text-link">{t("Open tournament")} →</span>
</Link>
))}
</div>
{events && !events.tournaments.length && (
<div className="empty-state">
<Trophy size={36} />
<h2>{t("The next champion could be you.")}</h2>
<p>
{t(
"Create the first community tournament or check back for upcoming events.",
)}
</p>
</div>
)}
</div>
);
}
export function TournamentLobby({
id,
signedIn,
}: {
id: string;
signedIn: boolean;
}) {
const { t, formatNumber } = useI18n();
const [event, setEvent] = useState<TournamentView | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [copied, setCopied] = useState(false);
const [editing, setEditing] = useState(false);
const acting = useRef(false),
version = useRef(0);
const latest = useRef<TournamentView | null>(null);
const clock = useRef<ReturnType<typeof roundClock> | null>(null);
const accept = useCallback((next: TournamentView, sent: number) => {
clock.current = roundClock(next.serverNow, sent, performance.now());
latest.current = next;
setEvent(next);
setError("");
}, []);
const load = useCallback(async () => {
if (acting.current) return;
const current = ++version.current,
sent = performance.now();
try {
const next = await api<TournamentView>(`tournaments/${id}`);
if (current === version.current) accept(next, sent);
} catch (e) {
if (current === version.current) setError((e as Error).message);
}
}, [id, accept]);
useEffect(() => {
let stopped = false;
let timer: ReturnType<typeof setTimeout>;
const poll = async () => {
await load();
if (
!stopped &&
!["finished", "cancelled"].includes(latest.current?.status ?? "")
)
timer = setTimeout(poll, document.hidden ? 5000 : 2000);
};
void poll();
const resume = () => {
if (!document.hidden) void load();
};
document.addEventListener("visibilitychange", resume);
return () => {
stopped = true;
version.current++;
clearTimeout(timer);
document.removeEventListener("visibilitychange", resume);
};
}, [load]);
const action = useCallback(
async (type: string, payload: object = {}) => {
if (acting.current) return false;
acting.current = true;
setBusy(true);
const current = ++version.current,
sent = performance.now();
try {
const next = await api<TournamentView>(
`tournaments/${id}/${type}`,
payload,
);
if (current === version.current) accept(next, sent);
return true;
} catch (e) {
if (current === version.current) setError((e as Error).message);
return false;
} finally {
acting.current = false;
setBusy(false);
}
},
[id, accept],
);
const remaining = useCallback(
(deadline: string) =>
clock.current?.remaining(deadline, performance.now()) ?? 0,
[],
);
const room = event?.currentRoom;
const owner = event?.you === event?.ownerId && !!event?.you;
const joined = !!event?.players.some((p) => p.accountId === event.you);
const gameAction = useCallback(
(type: string, payload: object = {}) =>
action(type, { ...payload, tournamentRound: event?.currentRound ?? 0 }),
[action, event?.currentRound],
);
if (!event)
return (
<div className="section tournament-page">
<Link href="/tournaments">← {t("Tournaments")}</Link>
{error ? (
<div role="alert" className="notice error">
{t(error)}{" "}
<button onClick={() => void load()} className="button">
{t("Try again")}
</button>
</div>
) : (
<p role="status">{t("Loading tournament…")}</p>
)}
</div>
);
async function edit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!event) return;
const form = new FormData(e.currentTarget);
try {
if (
await action("edit", {
startsAt: serverLocalToIso(
String(form.get("start")),
event.serverTimezone,
),
playerLimit: Number(form.get("limit")),
announceDiscord: form.has("discord"),
})
)
setEditing(false);
} catch (failure) {
setError((failure as Error).message);
}
}
return (
<>
{event.status === "running" && room?.you && (
<div className="tournament-play">
<div className="tournament-game-heading">
<Trophy size={18} />
{event.name} ·{" "}
{t("Tournament round {round} of {total}", {
round: event.currentRound + 1,
total: event.roundCount,
})}
</div>
<MultiplayerGame
key={`${room.id}:${room.round}`}
id={room.id}
mediaPath={`tournaments/${id}/media?tournamentRound=${event.currentRound}`}
room={room}
busy={busy}
action={gameAction}
embedded
leaveLabel="View tournament standings"
remaining={remaining}
onLeave={() => {
document
.getElementById("tournament-standings")
?.scrollIntoView({ behavior: "instant" });
}}
/>
</div>
)}
<div className="section tournament-page" id="tournament-standings">
<Link href="/tournaments" className="text-link">
← {t("Tournaments")}
</Link>
<div className="section-heading">
<div>
<p className="eyebrow">
<Trophy size={16} />
{t(event.status)}
</p>
<h1>{event.name}</h1>
<p>
{time(event.startsAt, event.serverTimezone)} ·{" "}
{event.serverTimezone}
</p>
</div>
<button
className="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(window.location.href);
setCopied(true);
} catch {
setError("Copy the tournament link from your address bar");
}
}}
>
<Copy size={16} />
{t(copied ? "Copied!" : "Copy invite")}
</button>
</div>
{error && (
<div className="notice error" role="alert">
{t(error)}
</div>
)}
<div className="tournament-facts">
<span>
<Users size={16} />
{event.players.length} / {event.playerLimit}
</span>
<span>
{t("{count} tournament rounds", { count: event.roundCount })} × 5{" "}
{t("mini-rounds")}
</span>
<span>
{event.roundTimeSeconds}{" "}
{t(
event.timerMode === "shared"
? "seconds per tournament round"
: "seconds per mini-round",
)}
</span>
</div>
{owner && event.announceDiscord && (
<p role="status" className="muted">
{t("Discord announcement")}:{" "}
{t(event.announcementState ?? "pending")}
</p>
)}
{event.status === "scheduled" && (
<div className="tournament-actions">
{!signedIn ? (
<Link className="button primary" href="/log-in">
{t("Log in to create or join")}
</Link>
) : joined ? (
<>
<span className="notice">
{t(
"You’re registered. Stay on this page when the tournament starts.",
)}
</span>
{!owner && (
<button
className="button"
disabled={busy}
onClick={() => void action("leave")}
>
{t("Leave tournament")}
</button>
)}
</>
) : (
<button
className="button primary"
disabled={busy || event.players.length >= event.playerLimit}
onClick={() => void action("join")}
>
{t(
event.players.length >= event.playerLimit
? "Tournament is full"
: "Join tournament",
)}
</button>
)}
{owner && (
<>
<button
className="button"
disabled={busy}
onClick={() => setEditing(!editing)}
>
{t("Tournament settings")}
</button>
<button
className="button danger"
disabled={busy}
onClick={() => void action("cancel")}
>
{t("Cancel tournament")}
</button>
</>
)}
</div>
)}
{editing && owner && event.status === "scheduled" && (
<form className="tournament-form panel" onSubmit={edit}>
<label>
{t("Start time")} · {event.serverTimezone}
<input
name="start"
type="datetime-local"
required
defaultValue={new Intl.DateTimeFormat("sv-SE", {
timeZone: event.serverTimezone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
})
.format(new Date(event.startsAt))
.replace(" ", "T")}
/>
</label>
<label>
{t("Player limit")}
<input
name="limit"
type="number"
min={Math.max(2, event.players.length)}
max={32}
required
defaultValue={event.playerLimit}
/>
</label>
<label className="tournament-checkbox">
<input
name="discord"
type="checkbox"
defaultChecked={event.announceDiscord}
/>
{t("Announce in Discord 15 minutes before starting")}
</label>
<p className="muted">
{t("Already-sent announcements are not posted again.")}
</p>
<button className="button primary" disabled={busy}>
{t("Save settings")}
</button>
</form>
)}
{event.status === "finished" && (
<div className="tournament-winner panel">
<Trophy size={34} />
<div>
<p className="eyebrow">{t("TOURNAMENT CHAMPION")}</p>
<h2>
{event.players.find((p) => p.accountId === event.winnerId)
?.name ?? t("Explorer")}
</h2>
<p>
{t(
"The latest tournament champion holds first place on the global leaderboard for seven days.",
)}
</p>
<Link href="/leaderboard" className="text-link">
{t("Global leaderboard")} →
</Link>
</div>
</div>
)}
{event.status === "cancelled" && (
<p role="status" className="notice">
{t(
"This tournament was cancelled. Scheduled tournaments need at least two eligible players at the start.",
)}
</p>
)}
<h2>
{t(
event.status === "finished"
? "Final standings"
: "Tournament standings",
)}
</h2>
<p className="muted">
{t(
"Points count after each mini-round closes. Ties use total distance, then registration order.",
)}
</p>
<div className="leaderboard-table-wrap">
<table className="leaderboard-table">
<thead>
<tr>
<th scope="col">{t("Rank")}</th>
<th scope="col">{t("Explorer")}</th>
<th scope="col">{t("Total points")}</th>
</tr>
</thead>
<tbody>
{event.players.map((player) => (
<tr
key={player.accountId}
className={
player.accountId === event.you ? "your-row" : undefined
}
>
<td>{player.rank}</td>
<th scope="row">
{player.name}
{player.accountId === event.winnerId ? " 🏆" : ""}
</th>
<td>{formatNumber(player.total)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
);
}