mc-dashboard/components/PlayerManager.tsx
hurkicorgi dd69c17c3b Initial commit: Minecraft dashboard
Next.js 16 + Tailwind v4 + shadcn v4 dashboard for managing a modded
Forge 1.20.1 server. Includes server controls, player management, mod
manager with Modrinth search and dependency resolution, world backups,
snapshots, analytics, logs, and chat bridge.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:46:58 -06:00

281 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Separator } from "@/components/ui/separator";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
type PlayerData = {
ops: { name: string; uuid: string; level: number }[];
whitelist: { name: string; uuid: string }[];
banned: { name: string; uuid: string; reason: string }[];
};
type Tab = "ops" | "whitelist" | "banned";
export function PlayerManager() {
const queryClient = useQueryClient();
const [tab, setTab] = useState<Tab>("ops");
const [playerName, setPlayerName] = useState("");
const [banReason, setBanReason] = useState("");
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null);
const { data = { ops: [], whitelist: [], banned: [] } } = useQuery<PlayerData>({
queryKey: ["players"],
queryFn: async () => {
const res = await fetch("/api/players");
if (!res.ok) throw new Error("Failed to fetch players");
return res.json();
},
staleTime: 10_000,
refetchInterval: 15_000,
});
const action = useMutation({
mutationFn: async (params: { action: string; player: string; reason?: string }) => {
const res = await fetch("/api/players", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
return data;
},
onSuccess: (data) => {
setResult({ ok: true, message: data.response || "Done" });
setPlayerName("");
setBanReason("");
queryClient.invalidateQueries({ queryKey: ["players"] });
},
onError: (err) => {
setResult({ ok: false, message: err.message });
},
});
const tabs: { key: Tab; label: string; count: number }[] = [
{ key: "ops", label: "Operators", count: data.ops.length },
{ key: "whitelist", label: "Whitelist", count: data.whitelist.length },
{ key: "banned", label: "Banned", count: data.banned.length },
];
const MC_NAME_RE = /^[A-Za-z0-9_]{3,16}$/;
const trimmed = playerName.trim();
const nameValid = MC_NAME_RE.test(trimmed);
const nameError =
trimmed.length === 0
? null
: !nameValid
? "316 chars, letters/numbers/underscores only"
: null;
const handleAction = (act: string, player?: string) => {
const name = player || trimmed;
if (!name) return;
if (!player && !nameValid) return;
action.mutate({ action: act, player: name, reason: banReason || undefined });
};
return (
<Card>
<CardHeader>
<CardTitle>Player Management</CardTitle>
<CardDescription>
Manage operators, whitelist, and bans via RCON
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Tabs */}
<div className="flex gap-1 bg-muted p-1 rounded-lg">
{tabs.map((t) => (
<button
key={t.key}
onClick={() => { setTab(t.key); setResult(null); }}
className={`flex-1 px-2 sm:px-3 py-2 rounded-md text-xs sm:text-sm font-medium transition min-h-[40px] ${
tab === t.key
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{t.label}
{t.count > 0 && (
<Badge variant="secondary" className="ml-1.5 text-xs px-1.5 py-0">
{t.count}
</Badge>
)}
</button>
))}
</div>
{/* Add player input */}
<div className="flex flex-col sm:flex-row gap-2">
<div className="flex-1">
<Input
placeholder="Player name"
value={playerName}
onChange={(e) => setPlayerName(e.target.value)}
aria-invalid={!!nameError}
maxLength={16}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (tab === "ops") handleAction("op");
else if (tab === "whitelist") handleAction("whitelist add");
else if (tab === "banned") handleAction("ban");
}
}}
className="w-full"
/>
{nameError && (
<p className="text-xs text-red-300 mt-1">{nameError}</p>
)}
</div>
{tab === "banned" && (
<Input
placeholder="Reason (optional)"
value={banReason}
onChange={(e) => setBanReason(e.target.value)}
className="flex-1"
/>
)}
<Button
onClick={() => {
if (tab === "ops") handleAction("op");
else if (tab === "whitelist") handleAction("whitelist add");
else if (tab === "banned") handleAction("ban");
}}
disabled={!nameValid || action.isPending}
className="w-full sm:w-auto"
>
{tab === "ops" ? "Add OP" : tab === "whitelist" ? "Add" : "Ban"}
</Button>
</div>
{/* Feedback */}
{result && (
<Alert className={result.ok ? "border-emerald-500/20 bg-emerald-500/5" : "border-red-500/20 bg-red-500/5"}>
<AlertDescription className={result.ok ? "text-emerald-300" : "text-red-300"}>
{result.message}
</AlertDescription>
</Alert>
)}
<Separator />
{/* Player lists */}
{tab === "ops" && (
<ul className="space-y-1">
{data.ops.length === 0 && (
<li className="text-sm text-muted-foreground py-2 text-center">No operators</li>
)}
{data.ops.map((p) => (
<li
key={p.uuid || p.name}
className="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/50 group"
>
<div className="flex items-center gap-2 min-w-0">
<img
src={`https://mc-heads.net/avatar/${p.name}/24`}
alt=""
className="w-6 h-6 rounded shrink-0"
/>
<span className="text-sm font-medium truncate">{p.name}</span>
<Badge variant="secondary" className="text-xs px-1.5 py-0 shrink-0">
Lv{p.level}
</Badge>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => handleAction("deop", p.name)}
disabled={action.isPending}
className="text-xs h-9 shrink-0 text-muted-foreground sm:opacity-0 sm:group-hover:opacity-100 transition"
>
Deop
</Button>
</li>
))}
</ul>
)}
{tab === "whitelist" && (
<ul className="space-y-1">
{data.whitelist.length === 0 && (
<li className="text-sm text-muted-foreground py-2 text-center">Whitelist is empty</li>
)}
{data.whitelist.map((p) => (
<li
key={p.uuid || p.name}
className="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/50 group"
>
<div className="flex items-center gap-2 min-w-0">
<img
src={`https://mc-heads.net/avatar/${p.name}/24`}
alt=""
className="w-6 h-6 rounded shrink-0"
/>
<span className="text-sm font-medium truncate">{p.name}</span>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => handleAction("whitelist remove", p.name)}
disabled={action.isPending}
className="text-xs h-9 text-muted-foreground sm:opacity-0 sm:group-hover:opacity-100 transition"
>
Remove
</Button>
</li>
))}
</ul>
)}
{tab === "banned" && (
<ul className="space-y-1">
{data.banned.length === 0 && (
<li className="text-sm text-muted-foreground py-2 text-center">No banned players</li>
)}
{data.banned.map((p) => (
<li
key={p.uuid || p.name}
className="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/50 group"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<img
src={`https://mc-heads.net/avatar/${p.name}/24`}
alt=""
className="w-6 h-6 rounded shrink-0"
/>
<span className="text-sm font-medium truncate">{p.name}</span>
</div>
{p.reason && (
<p className="text-xs text-muted-foreground truncate ml-8">{p.reason}</p>
)}
</div>
<Button
size="sm"
variant="ghost"
onClick={() => handleAction("pardon", p.name)}
disabled={action.isPending}
className="text-xs h-9 text-muted-foreground sm:opacity-0 sm:group-hover:opacity-100 transition"
>
Unban
</Button>
</li>
))}
</ul>
)}
</CardContent>
</Card>
);
}