Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 3m40s
760 lines
24 KiB
TypeScript
760 lines
24 KiB
TypeScript
"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,
|
||
initialMapId = null,
|
||
}: {
|
||
signedIn: boolean;
|
||
initialMapId?: string | null;
|
||
}) {
|
||
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(!!(signedIn && initialMapId));
|
||
const [selectedMap, setSelectedMap] = useState(initialMapId ?? "");
|
||
const [clock, setClock] = useState(0);
|
||
const [timerMode, setTimerMode] = useState<"shared" | "perMini">("shared");
|
||
const [roundSeconds, setRoundSeconds] = useState(450);
|
||
const [difficulty, setDifficulty] = useState("normal");
|
||
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);
|
||
setSelectedMap((current) =>
|
||
catalog.maps.some((map) => map.id === current) ? current : "",
|
||
);
|
||
}
|
||
})
|
||
.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: difficulty === "void" ? 10 : roundSeconds,
|
||
timerMode: difficulty === "void" ? "perMini" : 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
|
||
value={selectedMap}
|
||
onChange={(event) => setSelectedMap(event.target.value)}
|
||
>
|
||
<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"
|
||
value={difficulty}
|
||
onChange={(event) => setDifficulty(event.target.value)}
|
||
>
|
||
<option value="easy">{t("Easy")}</option>
|
||
<option value="normal">{t("Normal")}</option>
|
||
<option value="hard">{t("Hard")}</option>
|
||
<option value="void">{t("Void")}</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={difficulty === "void" ? "perMini" : timerMode}
|
||
disabled={difficulty === "void"}
|
||
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(
|
||
difficulty !== "void" && timerMode === "shared"
|
||
? "Seconds per tournament round"
|
||
: "Seconds per mini-round",
|
||
)}
|
||
<input
|
||
name="seconds"
|
||
type="number"
|
||
min={difficulty === "void" ? 10 : 15}
|
||
max={timerMode === "shared" ? 3000 : 600}
|
||
step={difficulty === "void" ? 5 : 15}
|
||
value={difficulty === "void" ? 10 : roundSeconds}
|
||
disabled={difficulty === "void"}
|
||
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>
|
||
</>
|
||
);
|
||
}
|