Add tournament and multiplayer community chat moderation
All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 12m17s

This commit is contained in:
2026-10-27 12:54:15 +05:00
parent b8a430d4c2
commit 9c314ecb4e
19 changed files with 1070 additions and 16 deletions

View File

@@ -0,0 +1,46 @@
"use client";
import { useEffect, useRef } from "react";
import { usePathname, useSearchParams } from "next/navigation";
/** Animate the new content after browser history navigation, without delaying it. */
export function BackNavigationTransition() {
const pathname = usePathname();
const search = useSearchParams();
const route = `${pathname}?${search.toString()}`;
const historyNavigation = useRef(false);
const previousPath = useRef(route);
useEffect(() => {
const onPopState = () => {
historyNavigation.current =
`${window.location.pathname}?${new URLSearchParams(window.location.search).toString()}` !==
previousPath.current;
};
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
}, []);
useEffect(() => {
if (previousPath.current === route) return;
previousPath.current = route;
const fromHistory = historyNavigation.current;
historyNavigation.current = false;
if (
!fromHistory ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
)
return;
const main = document.getElementById("main-content");
if (!main || typeof main.animate !== "function") return;
const animation = main.animate(
[
{ opacity: 0.7, transform: "translateY(8px)" },
{ opacity: 1, transform: "translateY(0)" },
],
{ duration: 200, easing: "cubic-bezier(0.23, 1, 0.32, 1)" },
);
return () => animation.cancel();
}, [route]);
return null;
}

View File

@@ -7,6 +7,7 @@ import { Copy, Users, Crown, Trophy, MessageCircle } from "lucide-react";
import { Brand } from "./Brand";
import { MultiplayerGame } from "./MultiplayerGame";
import { RoomChat } from "./RoomChat";
import { TournamentModeration } from "./TournamentModeration";
import { useI18n } from "./I18nProvider";
import { api } from "@/lib/client-api";
import { roundClock } from "@/shared/round-clock";
@@ -154,6 +155,16 @@ export function MultiplayerRoom({ id }: { id: string }) {
available={room.status !== "closed"}
playing
/>
<TournamentModeration
party
playing
id={id}
players={room.players
.filter((p) => !p.left)
.map((p) => ({ accountId: p.id, name: p.name }))}
ownerId={room.hostId}
you={room.you}
/>
</>
);
const owner = !!room?.you && room.you === room.hostId;
@@ -167,7 +178,11 @@ export function MultiplayerRoom({ id }: { id: string }) {
{t("Play & maps")}
</Link>
</header>
<main className="section page-content multiplayer-lobby" id="main-content" tabIndex={-1}>
<main
className="section page-content multiplayer-lobby"
id="main-content"
tabIndex={-1}
>
{error && (
<p className="error-message" role="alert">
{t(error)}{" "}
@@ -184,16 +199,15 @@ export function MultiplayerRoom({ id }: { id: string }) {
<Users size={16} /> {t("Multiplayer")} · {room.name}
</span>
<p className="muted">
{t("Difficulty")}: <strong>
{t("Difficulty")}:{" "}
<strong>
{room.difficultySettings.id in difficulties
? t(room.difficultySettings.name)
: room.difficultySettings.name}
</strong>
{" · "}
{room.difficultySettings.seconds} {t("s per round")} ·{" "}
{room.difficultySettings.zoom
? t("Zoom allowed")
: t("No zoom")}
{room.difficultySettings.zoom ? t("Zoom allowed") : t("No zoom")}
</p>
<h1>
{finished
@@ -248,6 +262,15 @@ export function MultiplayerRoom({ id }: { id: string }) {
)}
{room.you && room.status === "lobby" && (
<>
<TournamentModeration
party
id={id}
players={room.players
.filter((p) => !p.left)
.map((p) => ({ accountId: p.id, name: p.name }))}
ownerId={room.hostId}
you={room.you}
/>
<p className="lead">
{owner
? t(

View File

@@ -15,11 +15,13 @@ export function RoomChat({
playerId,
available,
playing,
endpoint,
}: {
id: string;
playerId: string | null;
available: boolean;
playing: boolean;
endpoint?: string;
}) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
@@ -38,12 +40,7 @@ export function RoomChat({
const followLatest = useRef(true);
const enabled = !!playerId && available;
const accept = useCallback((view: ChatView) => {
setMessages((previous) =>
previous.length === view.messages.length &&
previous.at(-1)?.id === view.messages.at(-1)?.id
? previous
: view.messages,
);
setMessages(view.messages);
if (!initialized.current) {
initialized.current = true;
setLastRead(BigInt(view.messages.at(-1)?.id ?? "0"));
@@ -54,12 +51,12 @@ export function RoomChat({
if (inFlight.current) return;
const current = ++version.current;
try {
const view = await api<ChatView>(`rooms/${id}/chat`);
const view = await api<ChatView>(endpoint ?? `rooms/${id}/chat`);
if (current === version.current) accept(view);
} catch (failure) {
if (current === version.current) setError((failure as Error).message);
}
}, [id, accept]);
}, [id, endpoint, accept]);
useEffect(() => {
if (!enabled) return;
let stopped = false;
@@ -111,7 +108,7 @@ export function RoomChat({
setError("");
const current = ++version.current;
try {
const view = await api<ChatView>(`rooms/${id}/chat`, body);
const view = await api<ChatView>(endpoint ?? `rooms/${id}/chat`, body);
if (current === version.current) {
accept(view);
followLatest.current = true;
@@ -199,6 +196,7 @@ export function RoomChat({
>
<strong>
<Username name={message.name} appearance={message.appearance} />
{message.tag && <small> · {message.tag}</small>}
{message.playerId === playerId && ` ${t("(you)")}`}
</strong>
<span>{message.text}</span>

View File

@@ -12,6 +12,8 @@ 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 { RoomChat } from "./RoomChat";
import { TournamentModeration } from "./TournamentModeration";
import dynamic from "next/dynamic";
const MultiplayerGame = dynamic(
() => import("./MultiplayerGame").then((module) => module.MultiplayerGame),
@@ -576,6 +578,14 @@ export function TournamentLobby({
)}
</span>
</div>
{joined && (
<TournamentModeration
id={id}
players={event.players}
ownerId={event.ownerId}
you={event.you}
/>
)}
{owner && event.announceDiscord && (
<p role="status" className="muted">
{t("Discord announcement")}:{" "}
@@ -754,6 +764,13 @@ export function TournamentLobby({
</table>
</div>
</div>
<RoomChat
id={id}
playerId={event.you}
endpoint={`tournaments/${id}/chat`}
available={joined && ["scheduled", "running"].includes(event.status)}
playing={event.status === "running"}
/>
</>
);
}

View File

@@ -0,0 +1,249 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/client-api";
import { useI18n } from "./I18nProvider";
type Community = {
owner: boolean;
moderator: boolean;
slowSeconds: number;
moderators: string[];
tags: Record<string, string>;
bans: { accountId?: string; id?: string; name: string }[];
};
export function TournamentModeration({
id,
players,
ownerId,
you,
party = false,
playing = false,
}: {
id: string;
players: { accountId: string; name: string }[];
ownerId: string | null;
you: string | null;
party?: boolean;
playing?: boolean;
}) {
const { t } = useI18n();
const [community, setCommunity] = useState<Community | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [tags, setTags] = useState<Record<string, string>>({});
const endpoint = `${party ? "rooms" : "tournaments"}/${id}/community`;
const load = useCallback(async () => {
try {
setCommunity(await api<Community>(endpoint));
} catch (failure) {
setCommunity(null);
setError((failure as Error).message);
}
}, [endpoint]);
useEffect(() => {
void load();
const timer = window.setInterval(() => {
if (!document.hidden) void load();
}, 5000);
return () => window.clearInterval(timer);
}, [load]);
async function act(action: string, fields: object = {}) {
if (busy) return;
setBusy(true);
setError("");
try {
await api(endpoint, { action, ...fields });
await load();
} catch (failure) {
setError((failure as Error).message);
} finally {
setBusy(false);
}
}
if (!community) return null;
if (!community.moderator) {
const tagged = players.filter((player) => community.tags[player.accountId]);
return tagged.length ? (
<p className="muted">
{tagged
.map(
(player) => `${player.name} · ${community.tags[player.accountId]}`,
)
.join(" | ")}
</p>
) : null;
}
const panel = (
<section
className="panel tournament-form community-moderation"
aria-label={t(party ? "Party moderation" : "Tournament moderation")}
>
<h2>{t(party ? "Party moderation" : "Tournament moderation")}</h2>
{error && (
<p role="alert" className="error-message">
{t(error)}
</p>
)}
<form
onSubmit={(event) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
void act("slow", { seconds: Number(data.get("seconds")) });
}}
>
<label>
{t("Chat slow mode (seconds)")}
<input
key={community.slowSeconds}
name="seconds"
type="number"
min="0"
max="300"
defaultValue={community.slowSeconds}
required
/>
</label>
<button className="button" disabled={busy}>
{t("Save slow mode")}
</button>
</form>
<ul className="multiplayer-roster">
{players
.filter(
(player) =>
player.accountId !== ownerId &&
player.accountId !== you &&
(community.owner ||
!community.moderators.includes(player.accountId)),
)
.map((player) => (
<li key={player.accountId}>
<span>
{player.name}
{community.moderators.includes(player.accountId) &&
` · ${t("Moderator")}`}
{community.tags[player.accountId] &&
` · ${community.tags[player.accountId]}`}
</span>
<div className="inline-actions">
{community.owner && (
<button
className="button"
disabled={busy}
onClick={() =>
void act(
community.moderators.includes(player.accountId)
? "unadmin"
: "admin",
{
[party ? "playerId" : "accountId"]: player.accountId,
},
)
}
>
{t(
community.moderators.includes(player.accountId)
? "Remove moderator"
: "Make moderator",
)}
</button>
)}
<button
className="button"
disabled={busy}
onClick={() =>
void act("kick", {
[party ? "playerId" : "accountId"]: player.accountId,
})
}
>
{t("Kick")}
</button>
<button
className="button danger"
disabled={busy}
onClick={() => {
if (window.confirm(t("Ban this player?")))
void act("ban", {
[party ? "playerId" : "accountId"]: player.accountId,
});
}}
>
{t("Ban")}
</button>
<form
onSubmit={(event) => {
event.preventDefault();
void act("tag", {
[party ? "playerId" : "accountId"]: player.accountId,
tag:
tags[player.accountId] ??
community.tags[player.accountId] ??
"",
});
}}
>
<label>
<span className="sr-only">{t("Player tag")}</span>
<input
maxLength={32}
value={
tags[player.accountId] ??
community.tags[player.accountId] ??
""
}
onChange={(event) =>
setTags({
...tags,
[player.accountId]: event.target.value,
})
}
placeholder={t("Player tag")}
/>
</label>
<button className="button" disabled={busy}>
{t("Save tag")}
</button>
</form>
</div>
</li>
))}
</ul>
{community.bans.length > 0 && (
<>
<h3>{t("Banned players")}</h3>
<ul>
{community.bans.map((player) => (
<li key={player.id ?? player.accountId}>
{player.name}{" "}
<button
className="button"
disabled={busy}
onClick={() =>
void act(
"unban",
party
? { banId: player.id }
: { accountId: player.accountId },
)
}
>
{t("Unban")}
</button>
</li>
))}
</ul>
</>
)}
</section>
);
if (!playing) return panel;
return (
<details className="community-moderation-floating">
<summary className="button">{t("Party moderation")}</summary>
{panel}
</details>
);
}