mc-dashboard/components/PlayerManager.tsx
hurkicorgi b6b10159ad Polish remaining UX items: avatar fallback, chat truncation, delete confirmations
- PlayerAvatar component with onError fallback to initial badge; used in
  PlayerManager and ChatBridge.
- ChatBridge: truncate long usernames to 16 chars with tooltip, add avatar
  column, bump tinted text colors to -300 for better dark-mode contrast.
- BackupManager: confirm step for Delete (was one-click).
- ModManager: confirm step for snapshot Delete, clearer "Confirm Restore" /
  "Confirm Delete" labels, unify outer spacing with the rest of admin.

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

270 lines
9.5 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";
import { PlayerAvatar } from "@/components/PlayerAvatar";
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">
<PlayerAvatar name={p.name} size={24} />
<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">
<PlayerAvatar name={p.name} size={24} />
<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">
<PlayerAvatar name={p.name} size={24} />
<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>
);
}