Add tournament and multiplayer community chat moderation
All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 12m17s
All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 12m17s
This commit is contained in:
46
components/BackNavigationTransition.tsx
Normal file
46
components/BackNavigationTransition.tsx
Normal 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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
249
components/TournamentModeration.tsx
Normal file
249
components/TournamentModeration.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user