From 19d66c2de6c8e20525b17b08f714bab24f0f7229 Mon Sep 17 00:00:00 2001 From: hurkicorgi Date: Mon, 13 Apr 2026 05:39:32 -0600 Subject: [PATCH] Pass 3 next slice: snapshot polish, analytics depth, player drawer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Snapshots now include recursive sizeBytes (lib/snapshots.ts dirSize), rendered as a badge next to mod count. - Snapshot restore/delete is now type-to-confirm: click Restore/Delete, type the literal snapshot name, press Enter or click Confirm. Esc cancels, matches the existing wizard Esc handler. - Analytics card: - Uptime ring showing % of datapoints with tps>0 (color-graded green/amber/red) + numeric % over selected range. - Peak-player marker dot on the Players sparkline + peak caption. - "Online now" player list (up to 8) with small PlayerAvatar badges, sourced from the latest analytics entry's players[] array. - Player profile drawer (new): - Slide-in right panel opened by clicking any PlayerAvatar. - Shows Online / Op / Whitelisted / Banned badges, UUID, ban reason. - Quick toggles for op/deop, whitelist add/remove, ban (with reason input) and pardon — reuses /api/players POST contract. - Global event bus (lib/events.ts) decouples avatars from drawer. - Esc / backdrop / close-button dismiss. - PlayerAvatar now renders as a + ) : ( + <> + + + + )} + -

- {timeAgo(snap.createdAt)} -

- -
- {confirmRestore === snap.dirName ? ( - <> - - - - ) : confirmDeleteSnap === snap.dirName ? ( - <> - - - - ) : ( - <> - - - + {pending && ( +
+

+ Type {snap.name} to{" "} + {mode === "restore" ? "confirm restore" : "confirm delete"} +

+
+ setTypedConfirm(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && matched) { + if (mode === "restore") restoreSnap.mutate(snap.dirName); + else { + deleteSnap.mutate(snap.dirName); + setConfirmDeleteSnap(null); + setTypedConfirm(""); + } + } + }} + placeholder={snap.name} + className="h-9 text-sm flex-1 font-mono" + /> + +
+
)} -
- - ))} + + ); + })} diff --git a/components/PlayerAvatar.tsx b/components/PlayerAvatar.tsx index 576c30e..1e4ccc1 100644 --- a/components/PlayerAvatar.tsx +++ b/components/PlayerAvatar.tsx @@ -2,41 +2,59 @@ import Image from "next/image"; import { useState } from "react"; +import { dispatchAppEvent } from "@/lib/events"; export function PlayerAvatar({ name, size = 24, className = "", + interactive = true, }: { name: string; size?: number; className?: string; + interactive?: boolean; }) { const [failed, setFailed] = useState(false); const dim = `${size}px`; const initial = name.slice(0, 1).toUpperCase(); - if (failed || !name) { - return ( + const inner = + failed || !name ? (
{initial}
+ ) : ( + setFailed(true)} + className="rounded shrink-0 bg-muted" + /> ); + + if (!interactive || !name) { + return {inner}; } return ( - setFailed(true)} - className={`rounded shrink-0 bg-muted ${className}`} - /> + ); } diff --git a/components/PlayerDrawer.tsx b/components/PlayerDrawer.tsx new file mode 100644 index 0000000..bf4c665 --- /dev/null +++ b/components/PlayerDrawer.tsx @@ -0,0 +1,243 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useSession } from "next-auth/react"; +import { useEffect, 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 { PlayerAvatar } from "@/components/PlayerAvatar"; +import { onAppEvent } from "@/lib/events"; + +type PlayerData = { + ops: { name: string; uuid: string; level: number }[]; + whitelist: { name: string; uuid: string }[]; + banned: { name: string; uuid: string; reason: string }[]; +}; + +type MetricEntry = { ts: string; players?: string[] }; + +export function PlayerDrawer() { + const { data: session } = useSession(); + const queryClient = useQueryClient(); + const [name, setName] = useState(null); + const [banReason, setBanReason] = useState(""); + + useEffect(() => onAppEvent("player:open", ({ name }) => setName(name)), []); + + useEffect(() => { + if (!name) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setName(null); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [name]); + + const authed = !!session; + + const players = useQuery({ + queryKey: ["players"], + queryFn: () => fetch("/api/players").then((r) => r.json()), + enabled: authed && !!name, + staleTime: 10_000, + }); + + // Use existing analytics cache (last entry) to determine online now + const analytics = queryClient.getQueryData(["analytics", 6]); + const onlineNow = + (analytics && analytics.length > 0 + ? analytics[analytics.length - 1].players || [] + : []) as string[]; + + const isOp = !!name && !!players.data?.ops.some((p) => p.name === name); + const isWhitelisted = + !!name && !!players.data?.whitelist.some((p) => p.name === name); + const bannedEntry = + (name && players.data?.banned.find((p) => p.name === name)) || null; + const isBanned = !!bannedEntry; + const isOnline = !!name && onlineNow.includes(name); + + const action = useMutation({ + mutationFn: async (params: { action: string; reason?: string }) => { + if (!name) throw new Error("No player selected"); + const res = await fetch("/api/players", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: params.action, player: name, reason: params.reason }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + return data; + }, + onSuccess: (data) => { + toast.success(data.response || "Done"); + setBanReason(""); + queryClient.invalidateQueries({ queryKey: ["players"] }); + }, + onError: (err) => toast.error("Action failed", { description: err.message }), + }); + + if (!name) return null; + + return ( +
{ + if (e.target === e.currentTarget) setName(null); + }} + > + +
+ ); +} diff --git a/lib/events.ts b/lib/events.ts new file mode 100644 index 0000000..1682c25 --- /dev/null +++ b/lib/events.ts @@ -0,0 +1,21 @@ +export type AppEvents = { + "player:open": { name: string }; +}; + +export function dispatchAppEvent( + name: K, + detail: AppEvents[K] +): void { + if (typeof window === "undefined") return; + window.dispatchEvent(new CustomEvent(name, { detail })); +} + +export function onAppEvent( + name: K, + handler: (detail: AppEvents[K]) => void +): () => void { + if (typeof window === "undefined") return () => {}; + const listener = (e: Event) => handler((e as CustomEvent).detail); + window.addEventListener(name, listener); + return () => window.removeEventListener(name, listener); +} diff --git a/lib/snapshots.ts b/lib/snapshots.ts index 3c82f4b..d73ee07 100644 --- a/lib/snapshots.ts +++ b/lib/snapshots.ts @@ -4,6 +4,7 @@ import { readdirSync, copyFileSync, rmSync, + statSync, writeFileSync, readFileSync, unlinkSync, @@ -11,6 +12,22 @@ import { import { join } from "path"; import { MODS_DIR, CLIENT_MODS_DIR, MOD_METADATA_FILE } from "./constants"; +function dirSize(dir: string): number { + let total = 0; + try { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, entry.name); + if (entry.isDirectory()) total += dirSize(p); + else if (entry.isFile()) { + try { + total += statSync(p).size; + } catch {} + } + } + } catch {} + return total; +} + const SNAPSHOTS_DIR = "/home/minecraft/server/snapshots"; const MAX_SNAPSHOTS = 10; @@ -19,6 +36,7 @@ export type SnapshotMeta = { createdAt: string; modCount: number; mods: string[]; + sizeBytes?: number; }; function ensureDir(dir: string) { @@ -133,7 +151,8 @@ export function listSnapshots(): (SnapshotMeta & { dirName: string })[] { const meta = JSON.parse( readFileSync(join(SNAPSHOTS_DIR, dirName, "meta.json"), "utf8") ) as SnapshotMeta; - return { ...meta, dirName }; + const sizeBytes = dirSize(join(SNAPSHOTS_DIR, dirName)); + return { ...meta, dirName, sizeBytes }; }) .sort( (a, b) =>