mc-dashboard/components/PlayerManager.tsx
hurkicorgi 3a69dc9243 Rich per-player stats + pick-your-metric leaderboard
- lib/player-stats.ts: single computePlayerStats() reads every
  world/stats/<uuid>.json plus world/advancements/<uuid>.json and joins
  with usercache.json. Returns a flat PlayerStats record per player:
  playtime, longest life, mob/player kills, deaths, K/D, damage dealt
  and taken (HP-scaled), blocks mined, items used / crafted / picked up
  (+ unique), distance (summed across all _one_cm stats), chest opens,
  nether/end trips, villager trades, fish caught, animals bred, and
  advancements (non-recipe) / recipes unlocked.
- New GET /api/players/stats (authed, 60s memo). Existing
  /api/players/playtime now returns a thin projection of the same
  computed data (shared cache key keeps both endpoints cheap).
- New components/Leaderboard.tsx with a metric select grouped into
  Time / Combat / World / Exploration / Progression / Economy (22
  metrics). Sorts descending, top 10 with "show all" toggle, smart
  number formatting (1.2k / 3.4M / HP / km). Replaces the old
  PlaytimeLeaderboard in the Players tab.
- PlayerDrawer upgraded: uses the full stats payload, shows small
  tiles for Kills / Deaths / K/D / Advs / Mined / Crafted / Distance
  alongside Playtime + Last seen.

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

304 lines
11 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 { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { PlayerAvatar } from "@/components/PlayerAvatar";
import { Leaderboard } from "@/components/Leaderboard";
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 { 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;
},
onMutate: async (params) => {
await queryClient.cancelQueries({ queryKey: ["players"] });
const prev = queryClient.getQueryData<PlayerData>(["players"]);
if (prev) {
const next: PlayerData = {
ops: [...prev.ops],
whitelist: [...prev.whitelist],
banned: [...prev.banned],
};
const name = params.player;
switch (params.action) {
case "op":
if (!next.ops.some((p) => p.name === name))
next.ops.push({ name, uuid: "", level: 4 });
break;
case "deop":
next.ops = next.ops.filter((p) => p.name !== name);
break;
case "whitelist add":
if (!next.whitelist.some((p) => p.name === name))
next.whitelist.push({ name, uuid: "" });
break;
case "whitelist remove":
next.whitelist = next.whitelist.filter((p) => p.name !== name);
break;
case "ban":
if (!next.banned.some((p) => p.name === name))
next.banned.push({ name, uuid: "", reason: params.reason || "" });
break;
case "pardon":
next.banned = next.banned.filter((p) => p.name !== name);
break;
}
queryClient.setQueryData(["players"], next);
}
return { prev };
},
onSuccess: (data) => {
toast.success(data.response || "Done");
setPlayerName("");
setBanReason("");
},
onError: (err, _vars, ctx) => {
if (ctx?.prev) queryClient.setQueryData(["players"], ctx.prev);
toast.error("Action failed", { description: err.message });
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["players"] });
},
});
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 (
<div className="space-y-4 sm:space-y-6">
<Leaderboard />
<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)}
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>
<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>
</div>
);
}