Add MineGeo gameplay systems and interface
This commit is contained in:
65
components/CommunityLinks.tsx
Normal file
65
components/CommunityLinks.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
components/DiscordIntegration.tsx
Normal file
118
components/DiscordIntegration.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user