Files
minecraft/dashboard/src/app/(panel)/players/page.tsx
T
Aryan SinghandClaude Fable 5 b2ff88b255 Fix gamerule toggles for 26.x renames; add map/skins/pregen + admin favors
Minecraft 26.x renamed every gamerule to snake_case (keepInventory →
keep_inventory, doDaylightCycle → advance_time, ...), so the panel's
queries hit unknown rules: reads returned nothing, toggles always
started from 'off', and sets silently failed. Rules are now defined by
candidate names resolved against the live server (works on old and new
versions), sets are verified by re-query, and failures surface the
server's actual reply. Toggles render only for rules the server reports.

Creative additions:
- Featured mods on the Mods page: BlueMap (3D web map), SkinRestorer
  (real skins in offline mode), Chunky (pregen) — verified for 26.2
- BlueMap plumbing: MAP_PORT mapping in compose, in-network detection,
  'Open the live world map' link on the Overview
- Player modal favors: heal, teleport-to-player, give item
- Broadcast box gained a full-screen title mode

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 12:05:23 -05:00

338 lines
13 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 { useState } from "react";
import { Users, UserX, Wifi, Crown, Gavel, HeartPulse, MapPin, Gift } from "lucide-react";
import { api, useApi, useSSE, toast } from "@/lib/api";
import { Card, Badge, Button, EmptyState, Modal, Input, Select } from "@/components/ui";
import { timeAgo, fmtDateTime } from "@/lib/format";
type PlayerRow = {
name: string;
uuid: string;
playTimeHours: number;
deaths: number;
mobKills: number;
playerKills: number;
distanceKm: number;
jumps: number;
damageDealt: number;
blocksMined: number;
isOnline: boolean;
ping: number | null;
authStatus: string | null;
lastSeenAt: number | null;
};
type PlayersData = { online: string[]; max: number; players: PlayerRow[] };
type ActivityRow = { id: number; ts: number; type: string; player: string | null; detail: string | null };
export default function PlayersPage() {
const { data, refresh } = useApi<PlayersData>("/api/players", 15000);
const { data: joins, refresh: refreshJoins } = useApi<ActivityRow[]>("/api/activity?limit=30", 20000);
const [detail, setDetail] = useState<PlayerRow | null>(null);
const [kicking, setKicking] = useState<string | null>(null);
const [adminBusy, setAdminBusy] = useState<string | null>(null);
const [tpTarget, setTpTarget] = useState("");
const [giveItemId, setGiveItemId] = useState("");
const [giveCount, setGiveCount] = useState("1");
const adminAction = async (label: string, body: Record<string, unknown>, message: string) => {
setAdminBusy(label);
try {
await api("/api/admin", { body });
toast(message, "success");
} catch (err) {
toast((err as Error).message, "error");
} finally {
setAdminBusy(null);
}
};
const banPlayer = async (name: string) => {
setAdminBusy("ban");
try {
await api("/api/security", { body: { op: "ban-player", name, reason: "Banned by the admin" } });
toast(`${name} banned`, "warn");
setDetail(null);
void refresh();
} catch (err) {
toast((err as Error).message, "error");
} finally {
setAdminBusy(null);
}
};
useSSE("/api/stream/events", {
"mc-event": () => {
void refresh();
void refreshJoins();
},
});
const kick = async (name: string) => {
setKicking(name);
try {
await api("/api/console", { body: { command: `kick ${name} Kicked from the panel` } });
toast(`${name} kicked`, "warn");
setTimeout(() => void refresh(), 1000);
} catch (err) {
toast((err as Error).message, "error");
} finally {
setKicking(null);
}
};
const players = data?.players ?? [];
const online = players.filter((p) => p.isOnline);
const onlineNames = data?.online ?? [];
const joinEvents = (joins ?? []).filter((j) => ["join", "leave", "connect"].includes(j.type));
const pingBadge = (ping: number | null) => {
if (ping === null) return null;
const color = ping > 250 ? "crit" : ping > 120 ? "warn" : "good";
return (
<Badge color={color}>
<Wifi size={10} /> {ping} ms
</Badge>
);
};
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-bold">Players</h1>
<p className="mt-0.5 text-xs text-ink2">
{online.length} online{data?.max ? ` of ${data.max} max` : ""} · {players.length} have played
</p>
</div>
<div className="grid gap-4 lg:grid-cols-3">
<div className="space-y-4 lg:col-span-2">
<Card title="All players" pad={false}>
{players.length === 0 ? (
<EmptyState
icon={<Users size={28} />}
title="No player data yet"
hint="Stats appear after someone plays on the server."
/>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-[11px] uppercase tracking-wide text-muted">
<th className="px-4 py-2 font-medium">Player</th>
<th className="px-4 py-2 font-medium">Playtime</th>
<th className="px-4 py-2 font-medium">Deaths</th>
<th className="px-4 py-2 font-medium">Last seen</th>
<th className="px-4 py-2 text-right font-medium" />
</tr>
</thead>
<tbody>
{players.map((p) => (
<tr
key={p.uuid}
className="cursor-pointer border-b border-border/50 last:border-0 hover:bg-surface2"
onClick={() => setDetail(p)}
>
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<span
className={`h-2 w-2 rounded-full ${p.isOnline ? "bg-good" : "bg-surface3"}`}
title={p.isOnline ? "online" : "offline"}
/>
<span className="font-mono font-medium">{p.name}</span>
{p.isOnline && pingBadge(p.ping)}
</div>
</td>
<td className="px-4 py-2.5 text-ink2">{p.playTimeHours} h</td>
<td className="px-4 py-2.5 text-ink2">{p.deaths}</td>
<td className="px-4 py-2.5 text-ink2">
{p.isOnline ? <span className="text-good">online now</span> : timeAgo(p.lastSeenAt)}
</td>
<td className="px-4 py-2.5 text-right">
{p.isOnline && (
<Button
size="sm"
variant="danger"
busy={kicking === p.name}
onClick={(e) => {
e.stopPropagation();
void kick(p.name);
}}
>
<UserX size={12} /> Kick
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</Card>
</div>
{/* join/leave feed */}
<Card title="Joins & leaves">
{joinEvents.length === 0 ? (
<EmptyState title="No connections yet" />
) : (
<ul className="space-y-2">
{joinEvents.map((e) => (
<li key={e.id} className="flex items-center gap-2 text-sm">
<Badge color={e.type === "join" ? "good" : e.type === "leave" ? "muted" : "info"}>
{e.type === "connect" ? "connecting" : e.type}
</Badge>
<span className="font-mono text-xs font-medium">{e.player}</span>
{e.type === "connect" && e.detail && (
<span className="font-mono text-[10px] text-muted">{e.detail}</span>
)}
<span className="ml-auto text-[10px] text-muted">{timeAgo(e.ts)}</span>
</li>
))}
</ul>
)}
</Card>
</div>
{/* stats drawer */}
<Modal open={detail !== null} onClose={() => setDetail(null)} title={detail?.name ?? ""} wide>
{detail && (
<div>
<div className="mb-3 flex items-center gap-2">
{detail.isOnline ? <Badge color="good">online</Badge> : <Badge color="muted">offline</Badge>}
{detail.authStatus && <Badge color="info">{detail.authStatus}</Badge>}
{detail.isOnline && detail.ping !== null && pingBadge(detail.ping)}
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{[
["Playtime", `${detail.playTimeHours} h`],
["Deaths", detail.deaths],
["Mob kills", detail.mobKills],
["Player kills", detail.playerKills],
["Traveled", `${detail.distanceKm} km`],
["Blocks mined", detail.blocksMined.toLocaleString()],
["Jumps", detail.jumps.toLocaleString()],
["Damage dealt", detail.damageDealt.toLocaleString()],
].map(([label, value]) => (
<div key={label as string} className="rounded-lg border border-border bg-surface2 p-3">
<div className="text-[10px] uppercase tracking-wide text-muted">{label}</div>
<div className="mt-0.5 text-lg font-semibold">{value}</div>
</div>
))}
</div>
<div className="mt-3 space-y-1 text-[11px] text-muted">
<div>UUID: <code>{detail.uuid}</code></div>
<div>Last seen: {detail.isOnline ? "online now" : fmtDateTime(detail.lastSeenAt)}</div>
</div>
{/* admin actions */}
<div className="mt-4 flex flex-wrap items-center gap-1.5 border-t border-border pt-3">
{detail.isOnline && (
<>
{(["survival", "creative", "spectator"] as const).map((mode) => (
<Button
key={mode}
size="sm"
busy={adminBusy === `gm-${mode}`}
onClick={() => void adminAction(`gm-${mode}`, { op: "gamemode", name: detail.name, mode }, `${detail.name} → ${mode}`)}
>
{mode}
</Button>
))}
<span className="mx-1 h-4 w-px bg-border" />
</>
)}
<Button
size="sm"
busy={adminBusy === "op"}
onClick={() => void adminAction("op", { op: "op", name: detail.name, grant: true }, `${detail.name} is now an operator`)}
>
<Crown size={12} /> Op
</Button>
<Button
size="sm"
busy={adminBusy === "deop"}
onClick={() => void adminAction("deop", { op: "op", name: detail.name, grant: false }, `${detail.name} de-opped`)}
>
De-op
</Button>
<span className="mx-1 h-4 w-px bg-border" />
<Button size="sm" variant="danger" busy={adminBusy === "ban"} onClick={() => void banPlayer(detail.name)}>
<Gavel size={12} /> Ban
</Button>
</div>
{/* creative admin favors (online players only) */}
{detail.isOnline && (
<div className="mt-3 space-y-2 border-t border-border pt-3">
<div className="flex flex-wrap items-center gap-1.5">
<Button
size="sm"
busy={adminBusy === "heal"}
onClick={() => void adminAction("heal", { op: "heal", name: detail.name }, `${detail.name} healed & fed`)}
>
<HeartPulse size={12} /> Heal
</Button>
{onlineNames.filter((n) => n !== detail.name).length > 0 && (
<>
<span className="mx-1 h-4 w-px bg-border" />
<Select
value={tpTarget}
onChange={setTpTarget}
options={[
{ value: "", label: "teleport to…" },
...onlineNames.filter((n) => n !== detail.name).map((n) => ({ value: n, label: n })),
]}
/>
<Button
size="sm"
disabled={!tpTarget}
busy={adminBusy === "tp"}
onClick={() =>
void adminAction("tp", { op: "teleport", name: detail.name, target: tpTarget }, `${detail.name} → ${tpTarget}`)
}
>
<MapPin size={12} /> Teleport
</Button>
</>
)}
</div>
<div className="flex flex-wrap items-center gap-1.5">
<Input
value={giveItemId}
onChange={(e) => setGiveItemId(e.target.value)}
placeholder="item id, e.g. diamond"
className="w-44 font-mono text-xs"
/>
<Input
type="number"
min={1}
max={6400}
value={giveCount}
onChange={(e) => setGiveCount(e.target.value)}
className="w-20 font-mono text-xs"
/>
<Button
size="sm"
disabled={!giveItemId.trim()}
busy={adminBusy === "give"}
onClick={() =>
void adminAction(
"give",
{ op: "give", name: detail.name, item: giveItemId.trim(), count: Number(giveCount) || 1 },
`Gave ${detail.name} ${giveCount}× ${giveItemId.trim()}`
)
}
>
<Gift size={12} /> Give
</Button>
</div>
</div>
)}
</div>
)}
</Modal>
</div>
);
}