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,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>
);
}