Add map-specific tournament creation link
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 3m31s
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 3m31s
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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 />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "Название турнира",
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user