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

@@ -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>
</>
);
}