Add map-specific tournament creation link
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 3m31s

This commit is contained in:
2026-10-18 13:32:56 +05:00
parent a7085e4e80
commit da1a50a6ad
10 changed files with 74 additions and 9 deletions

View File

@@ -2,6 +2,13 @@
max-width: 1180px; max-width: 1180px;
min-height: 60vh; min-height: 60vh;
} }
.start-panel .tournament-map-action {
margin-top: 12px;
border-color: var(--green);
color: var(--green);
text-align: center;
text-decoration: none;
}
.tournament-page .eyebrow, .tournament-page .eyebrow,
.tournament-server-clock, .tournament-server-clock,
.tournament-card p, .tournament-card p,

View File

@@ -2,13 +2,20 @@ import { Header, Footer } from "@/components/Shell";
import { TournamentIndex } from "@/components/TournamentLobby"; import { TournamentIndex } from "@/components/TournamentLobby";
import { user } from "@/lib/supabase"; import { user } from "@/lib/supabase";
export default async function TournamentsPage() { export default async function TournamentsPage({
searchParams,
}: {
searchParams: Promise<{ map?: string | string[] }>;
}) {
const account = await user(); const account = await user();
const { map } = await searchParams;
const initialMapId =
typeof map === "string" && /^[0-9a-f-]{36}$/i.test(map) ? map : null;
return ( return (
<> <>
<Header /> <Header />
<main id="main-content"> <main id="main-content">
<TournamentIndex signedIn={!!account} /> <TournamentIndex signedIn={!!account} initialMapId={initialMapId} />
</main> </main>
<Footer /> <Footer />
</> </>

View File

@@ -4,7 +4,7 @@ import { useI18n } from "@/components/I18nProvider";
import { useEffect, useRef, useState, type FormEvent } from "react"; import { useEffect, useRef, useState, type FormEvent } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { ArrowRight, Timer, ZoomIn, Users } from "lucide-react"; import { ArrowRight, Timer, ZoomIn, Users, Trophy } from "lucide-react";
import { import {
difficulties, difficulties,
customDifficultySchema, customDifficultySchema,
@@ -42,6 +42,7 @@ export function StartGame({
[loading, setLoading] = useState(false), [loading, setLoading] = useState(false),
[error, setError] = useState(""); [error, setError] = useState("");
const router = useRouter(); const router = useRouter();
const tournamentPath = `/tournaments?map=${encodeURIComponent(datasetId)}`;
const starting = useRef(false); const starting = useRef(false);
const savingPreset = useRef(false); const savingPreset = useRef(false);
useEffect(() => { useEffect(() => {
@@ -147,6 +148,22 @@ export function StartGame({
<Users size={15} /> {t("Multiplayer")} <Users size={15} /> {t("Multiplayer")}
</button> </button>
</div> </div>
<Link
className="button tournament-map-action full"
href={
hostName
? tournamentPath
: `/log-in?next=${encodeURIComponent(tournamentPath)}`
}
>
<Trophy size={17} />
{t(
hostName
? "Create tournament with this map"
: "Log in to create a tournament",
)}
<ArrowRight size={17} />
</Link>
<div className="difficulty-options"> <div className="difficulty-options">
{options.map((option) => ( {options.map((option) => (
<button <button

View File

@@ -37,7 +37,13 @@ function time(value: string, zone: string) {
timeZone: zone, timeZone: zone,
}).format(new Date(value)); }).format(new Date(value));
} }
export function TournamentIndex({ signedIn }: { signedIn: boolean }) { export function TournamentIndex({
signedIn,
initialMapId = null,
}: {
signedIn: boolean;
initialMapId?: string | null;
}) {
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter(); const router = useRouter();
const [events, setEvents] = useState<Events | null>(null); const [events, setEvents] = useState<Events | null>(null);
@@ -45,7 +51,8 @@ export function TournamentIndex({ signedIn }: { signedIn: boolean }) {
const [presets, setPresets] = useState<DifficultyPreset[]>([]); const [presets, setPresets] = useState<DifficultyPreset[]>([]);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(!!(signedIn && initialMapId));
const [selectedMap, setSelectedMap] = useState(initialMapId ?? "");
const [clock, setClock] = useState(0); const [clock, setClock] = useState(0);
const [timerMode, setTimerMode] = useState<"shared" | "perMini">("shared"); const [timerMode, setTimerMode] = useState<"shared" | "perMini">("shared");
const [roundSeconds, setRoundSeconds] = useState(450); const [roundSeconds, setRoundSeconds] = useState(450);
@@ -83,6 +90,9 @@ export function TournamentIndex({ signedIn }: { signedIn: boolean }) {
if (active) { if (active) {
setMaps(catalog.maps); setMaps(catalog.maps);
setPresets(difficulties.presets); setPresets(difficulties.presets);
setSelectedMap((current) =>
catalog.maps.some((map) => map.id === current) ? current : "",
);
} }
}) })
.catch((e) => { .catch((e) => {
@@ -183,7 +193,12 @@ export function TournamentIndex({ signedIn }: { signedIn: boolean }) {
</label> </label>
<label> <label>
{t("Map")} {t("Map")}
<select name="dataset" required defaultValue=""> <select
name="dataset"
required
value={selectedMap}
onChange={(event) => setSelectedMap(event.target.value)}
>
<option value="" disabled> <option value="" disabled>
{t("Choose a map")} {t("Choose a map")}
</option> </option>

View File

@@ -79,6 +79,7 @@ export function authDestination(value: string | null | undefined): string {
return value; return value;
if (value && /^\/dashboard\/[0-9a-f-]{36}$/i.test(value)) return value; if (value && /^\/dashboard\/[0-9a-f-]{36}$/i.test(value)) return value;
if (value && /^\/support\/[0-9a-f-]{36}$/i.test(value)) return value; if (value && /^\/support\/[0-9a-f-]{36}$/i.test(value)) return value;
if (value && /^\/tournaments\?map=[0-9a-f-]{36}$/i.test(value)) return value;
if (value && /^\/pair\?code=[A-F0-9]{8}$/.test(value)) return value; if (value && /^\/pair\?code=[A-F0-9]{8}$/.test(value)) return value;
return "/dashboard"; return "/dashboard";
} }

View File

@@ -971,6 +971,8 @@
"One world. Up to 32 rivals.": "Один мир. До 32 соперников.", "One world. Up to 32 rivals.": "Один мир. До 32 соперников.",
"Every tournament round contains five mini-rounds. Everyone plays the same locations.": "Каждый раунд турнира состоит из пяти мини-раундов. Все играют в одних и тех же локациях.", "Every tournament round contains five mini-rounds. Everyone plays the same locations.": "Каждый раунд турнира состоит из пяти мини-раундов. Все играют в одних и тех же локациях.",
"Create tournament": "Создать турнир", "Create tournament": "Создать турнир",
"Create tournament with this map": "Создать турнир на этой карте",
"Log in to create a tournament": "Войдите, чтобы создать турнир",
"Log in to create or join": "Войдите, чтобы создать турнир или участвовать", "Log in to create or join": "Войдите, чтобы создать турнир или участвовать",
"Server time": "Время сервера", "Server time": "Время сервера",
"Tournament name": "Название турнира", "Tournament name": "Название турнира",

View File

@@ -75,6 +75,12 @@ test("authentication redirects accept supported local routes and reject open red
assert.equal(authDestination(next), "/dashboard"); assert.equal(authDestination(next), "/dashboard");
assert.equal(authDestination("/settings"), "/settings"); assert.equal(authDestination("/settings"), "/settings");
assert.equal(authDestination("/servers"), "/servers"); assert.equal(authDestination("/servers"), "/servers");
const tournamentMap = "/tournaments?map=123e4567-e89b-12d3-a456-426614174000";
assert.equal(authDestination(tournamentMap), tournamentMap);
assert.equal(
authDestination(`${tournamentMap}&next=//evil.example`),
"/dashboard",
);
assert.equal(authDestination("/pair?code=1234ABCD"), "/pair?code=1234ABCD"); assert.equal(authDestination("/pair?code=1234ABCD"), "/pair?code=1234ABCD");
assert.equal(authDestination("/auth/reset-password"), "/auth/reset-password"); assert.equal(authDestination("/auth/reset-password"), "/auth/reset-password");
}); });

View File

@@ -23,14 +23,24 @@ let tournamentWarningAt = 0;
async function tickTournaments() { async function tickTournaments() {
if (stopping || tournamentTickRunning) return; if (stopping || tournamentTickRunning) return;
tournamentTickRunning = true; tournamentTickRunning = true;
let phase = "scheduling";
try { try {
check(await client.rpc("tick_tournaments")); const result = await client.rpc("tick_tournaments");
if (result.error)
throw new Error(
`${result.error.code || "RPC error"}: ${result.error.message}`,
);
phase = "Discord announcements";
await dispatchTournamentAnnouncements(client); await dispatchTournamentAnnouncements(client);
} catch { if (tournamentWarningAt) {
console.info("Tournament scheduling and announcements resumed.");
tournamentWarningAt = 0;
}
} catch (error) {
if (Date.now() - tournamentWarningAt >= 300000) { if (Date.now() - tournamentWarningAt >= 300000) {
tournamentWarningAt = Date.now(); tournamentWarningAt = Date.now();
console.error( console.error(
"Tournament scheduling paused; check database migrations and connectivity. The worker will retry.", `Tournament ${phase} paused: ${summarizeWorkerError(error)}. The worker will retry in 30 seconds.`,
); );
} }
} finally { } finally {