Add MineGeo gameplay systems and interface

This commit is contained in:
2026-10-10 10:27:47 +05:00
parent bf01d9801d
commit 9af71c85c2
47 changed files with 9954 additions and 93 deletions

View File

@@ -0,0 +1,65 @@
import "server-only";
import { MessageCircle, Send, ArrowUpRight } from "lucide-react";
import { getI18n } from "@/lib/i18n";
function communityUrl(value: string | undefined, hosts: readonly string[]) {
if (!value?.trim()) return null;
try {
const url = new URL(value.trim());
if (
url.protocol !== "https:" ||
url.username ||
url.password ||
url.port ||
!hosts.includes(url.hostname) ||
url.pathname === "/"
)
return null;
return url.href;
} catch {
return null;
}
}
export async function CommunityLinks() {
const { t } = await getI18n();
const discord = communityUrl(process.env.MINEGEO_DISCORD_URL, [
"discord.gg",
"discord.com",
"www.discord.com",
"discordapp.com",
]);
const telegram = communityUrl(process.env.MINEGEO_TELEGRAM_URL, [
"t.me",
"telegram.me",
]);
if (!discord && !telegram) return null;
return (
<div className="community-links" role="group" aria-label={t("Community")}>
{discord && (
<a
className="community-link community-discord"
href={discord}
target="_blank"
rel="noopener noreferrer"
>
<MessageCircle size={18} aria-hidden="true" />
{t("Join us on Discord")}
<ArrowUpRight size={14} aria-hidden="true" />
</a>
)}
{telegram && (
<a
className="community-link community-telegram"
href={telegram}
target="_blank"
rel="noopener noreferrer"
>
<Send size={18} aria-hidden="true" />
{t("Follow us on Telegram")}
<ArrowUpRight size={14} aria-hidden="true" />
</a>
)}
</div>
);
}

View File

@@ -0,0 +1,118 @@
"use client";
import { useEffect, useState } from "react";
import { MessageCircle } from "lucide-react";
import { api } from "@/lib/client-api";
import { useI18n } from "./I18nProvider";
import type { DiscordStatus } from "@/shared/discord";
export function DiscordIntegration() {
const { t } = useI18n();
const [status, setStatus] = useState<DiscordStatus | null>(null),
[error, setError] = useState(""),
[message, setMessage] = useState(""),
[busy, setBusy] = useState(false);
async function load() {
try {
setStatus(await api<DiscordStatus>("moderation/discord"));
setError("");
} catch (e) {
setError((e as Error).message);
}
}
useEffect(() => {
void load();
}, []);
const configured = !!(
status?.botConfigured &&
status.publicUrlConfigured &&
(status.mapsConfigured || status.partiesConfigured)
);
async function run(action: "check" | "retry") {
setBusy(true);
setError("");
setMessage("");
try {
await api("moderation/discord", { action });
setMessage(
action === "check"
? "Discord bot and channel access checked. Send Messages and Embed Links are also required."
: "Saved Discord announcements will retry.",
);
await load();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<section className="tool-card" aria-labelledby="discord-integration-title">
<div className="settings-heading">
<MessageCircle size={24} aria-hidden="true" />
<h2 id="discord-integration-title">{t("Discord integration")}</h2>
</div>
<p>
{t(
"New approved maps are announced automatically. Hosts can publish an open party to the shared Discord channel.",
)}
</p>
{status && (
<>
<p className="muted">
{configured
? t("Discord bot configured")
: t("Discord setup required in Vercel environment variables")}
</p>
<p>
{t("Map announcements: {state}", {
state: status.mapsConfigured
? t("Configured")
: t("Not configured"),
})}{" "}
·{" "}
{t("Party announcements: {state}", {
state: status.partiesConfigured
? t("Configured")
: t("Not configured"),
})}
</p>
<p>
{t("{pending} pending · {failed} need attention · {sent} sent", {
pending: status.pending,
failed: status.failed,
sent: status.sent,
})}
</p>
</>
)}
{error && (
<p className="error-message" role="alert">
{t(error)}
</p>
)}
{message && <p role="status">{t(message)}</p>}
<div className="inline-actions">
<button
className="button"
disabled={busy || !configured}
onClick={() => void run("check")}
>
{t("Check Discord setup")}
</button>
<button
className="button"
disabled={busy || !configured}
onClick={() => void run("retry")}
>
{t("Retry saved announcements")}
</button>
<button
className="text-link"
disabled={busy}
onClick={() => void load()}
>
{t("Refresh status")}
</button>
</div>
</section>
);
}

View File

@@ -10,6 +10,8 @@ import { reportCategories } from "@/shared/moderation";
import type { Appeal } from "./MapAppeal";
import { ModeratorSettings } from "./ModeratorSettings";
import { ModeratorPrefixes } from "./ModeratorPrefixes";
import { isAdministrator, type StaffRole } from "@/shared/permissions";
import { DiscordIntegration } from "./DiscordIntegration";
type ReviewMap = {
id: string;
name: string;
@@ -45,7 +47,8 @@ export function Moderation() {
[reasons, setReasons] = useState<Record<string, string>>({});
const [preview, setPreview] = useState<string | null>(null),
[loading, setLoading] = useState(true);
const [authorized, setAuthorized] = useState(false);
const [role, setRole] = useState<StaffRole | null>(null);
const administrator = isAdministrator(role ?? undefined);
const [reportPreview, setReportPreview] = useState<{
id: string;
panorama: string | null;
@@ -53,17 +56,21 @@ export function Moderation() {
async function load() {
try {
const d = await api<{
role: StaffRole;
maps: ReviewMap[];
reports: typeof reports;
appeals: Appeal[];
}>("moderation");
setMaps(d.maps);
setAuthorized(true);
setRole(d.role);
setReports(d.reports);
setAppeals(d.appeals);
setError("");
} catch (e) {
setAuthorized(false);
setRole(null);
setMaps([]);
setReports([]);
setAppeals([]);
setError((e as Error).message);
} finally {
setLoading(false);
@@ -114,8 +121,16 @@ export function Moderation() {
}
return (
<>
{authorized && <ModeratorSettings />}
{authorized && <ModeratorPrefixes />}
{administrator && <ModeratorSettings />}
{administrator && <ModeratorPrefixes />}
{administrator && <DiscordIntegration />}
{role === "moderator" && (
<p className="muted">
{t(
"Moderators can inspect and review pending maps and updates. Other controls require Admin permission.",
)}
</p>
)}
{error && (
<p role="alert" className="error-message">
{t(error)}
@@ -185,7 +200,7 @@ export function Moderation() {
</button>
</>
) : null}
{m.state === "published" && (
{administrator && m.state === "published" && (
<button
className="button"
disabled={busy}
@@ -197,7 +212,7 @@ export function Moderation() {
</div>
</article>
))}
{reports.length > 0 && (
{administrator && reports.length > 0 && (
<section className="token-box">
<h2>{t("Community reports")}</h2>
<p className="muted">
@@ -284,7 +299,7 @@ export function Moderation() {
))}
</section>
)}
{appeals.length > 0 && (
{administrator && appeals.length > 0 && (
<section className="token-box">
<h2>{t("Map ban appeals")}</h2>
{appeals.map((a) => (

View File

@@ -43,7 +43,7 @@ export function ModeratorSettings() {
<h2 id="site-settings-heading">{t("Site settings")}</h2>
<p>
{t(
"Changes apply to every player. Only moderators can edit these settings.",
"Changes apply to every player. Only admins can edit these settings.",
)}
</p>
</div>

View File

@@ -3,7 +3,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Copy, Users, Crown, Trophy } from "lucide-react";
import { Copy, Users, Crown, Trophy, MessageCircle } from "lucide-react";
import { Brand } from "./Brand";
import { MultiplayerGame } from "./MultiplayerGame";
import { RoomChat } from "./RoomChat";
@@ -22,6 +22,8 @@ export function MultiplayerRoom({ id }: { id: string }) {
const [name, setName] = useState("");
const [invite, setInvite] = useState("");
const [copied, setCopied] = useState(false);
const [sharing, setSharing] = useState(false);
const [shareMessage, setShareMessage] = useState("");
const clock = useRef<ReturnType<typeof roundClock> | null>(null);
const version = useRef(0);
const acting = useRef(false);
@@ -105,6 +107,22 @@ export function MultiplayerRoom({ id }: { id: string }) {
setError("Copy the invite link from the field below");
}
}
async function publishParty() {
if (sharing) return;
setSharing(true);
setShareMessage("");
try {
await api(`rooms/${id}/discord`, {});
setShareMessage(
"Party announcement queued. Anyone in the Discord channel can open the invite and join.",
);
await load();
} catch (failure) {
setShareMessage((failure as Error).message);
} finally {
setSharing(false);
}
}
const activeGame =
!!room?.you && ["loading", "playing", "results"].includes(room.status);
if (activeGame && room)
@@ -235,6 +253,60 @@ export function MultiplayerRoom({ id }: { id: string }) {
<Copy size={16} /> {copied ? t("Copied!") : t("Copy link")}
</button>
</div>
{owner && (room.discord?.available || room.discord?.state) && (
<div className="discord-party-share">
<p className="muted">
{t(
"Publishing shares this invite with everyone in the community Discord channel.",
)}
</p>
{room.discord.state === null && (
<button
className="button"
disabled={
sharing ||
busy ||
room.playerCount >= room.playerLimit
}
onClick={() => void publishParty()}
>
<MessageCircle size={16} aria-hidden="true" />
{sharing
? t("Publishing…")
: t("Publish party to Discord")}
</button>
)}
{room.discord.state === "sent" && (
<p role="status">{t("Party posted to Discord.")}</p>
)}
{["pending", "sending"].includes(
room.discord.state ?? "",
) && (
<p role="status">
{t(
"Party announcement is waiting for Discord delivery.",
)}
</p>
)}
{room.discord.state === "failed" && (
<p role="status">
{t(
"Discord delivery needs an administrator to check the bot setup and retry.",
)}
</p>
)}
{room.discord.state === "skipped" && (
<p role="status">
{t(
"This party announcement expired or the lobby is no longer available.",
)}
</p>
)}
{shareMessage && !room.discord.state && (
<p role="status">{t(shareMessage)}</p>
)}
</div>
)}
<h2>
{t("{count} / {limit} players", {
count: room.playerCount,

View File

@@ -8,6 +8,7 @@ import { Brand } from "./Brand";
import { SiteNav } from "./SiteNav";
import { ReportBugButton } from "./ReportBugButton";
import { ThemeToggle } from "./ThemeToggle";
import { CommunityLinks } from "./CommunityLinks";
export async function Header() {
const { t } = await getI18n();
const account = await user();
@@ -37,6 +38,7 @@ export async function Footer() {
<div>
<Brand />
<p>{t("A familiar world. A whole new perspective.")}</p>
<CommunityLinks />
</div>
<div>
<ThemeToggle />

View File

@@ -38,6 +38,12 @@ export function Username({
Star;
return (
<span className="username">
{appearance.role === "admin" && (
<span className="username-prefix username-admin" title={t("Admin")}>
<Crown size={13} aria-hidden="true" />
{t("Admin")}
</span>
)}
{appearance.role === "moderator" && (
<span
className="username-prefix username-moderator"