Improve MineGeo UI and application flows
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 2m37s
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 2m37s
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -7,6 +7,7 @@ import { usePathname } from "next/navigation";
|
||||
const links = [
|
||||
["/servers", "Play & maps"],
|
||||
["/leaderboard", "Leaderboard"],
|
||||
["/tournaments", "Tournaments"],
|
||||
["/downloads", "Downloads"],
|
||||
["/about", "Guide"],
|
||||
["/docs", "Docs"],
|
||||
|
||||
@@ -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}
|
||||
|
||||
736
components/TournamentLobby.tsx
Normal file
736
components/TournamentLobby.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user