Pass 3 next slice: snapshot polish, analytics depth, player drawer

- 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 <button> by default (stopPropagation on
  click, focus-visible ring); pass interactive={false} to opt out (used
  inside the drawer itself).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hurkicorgi 2026-04-13 05:39:32 -06:00
parent a011423017
commit 19d66c2de6
7 changed files with 589 additions and 109 deletions

View file

@ -10,7 +10,7 @@ import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { timeAgo } from "@/lib/time";
import { timeAgo, formatBytes } from "@/lib/time";
import {
Card,
CardContent,
@ -67,6 +67,7 @@ type SnapshotInfo = {
createdAt: string;
modCount: number;
mods: string[];
sizeBytes?: number;
};
type WizardStep = "idle" | "searching" | "reviewing" | "installing";
@ -170,6 +171,7 @@ export function ModManager() {
const [confirmDeleteSnap, setConfirmDeleteSnap] = useState<string | null>(null);
const [installedQuery, setInstalledQuery] = useState("");
const [sideFilter, setSideFilter] = useState<"all" | ModSide>("all");
const [typedConfirm, setTypedConfirm] = useState("");
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
// Installed mods
@ -209,6 +211,7 @@ export function ModManager() {
setConfirmRemove(null);
setConfirmRestore(null);
setConfirmDeleteSnap(null);
setTypedConfirm("");
return;
}
if (step === "searching" || step === "reviewing") {
@ -1036,97 +1039,128 @@ export function ModManager() {
</CardHeader>
<CardContent>
<ul className="space-y-1">
{snapshots.map((snap) => (
<li
key={snap.dirName}
className="flex items-center justify-between px-3 py-2.5 rounded-md bg-muted/50 group"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{snap.name}</span>
<Badge
variant="secondary"
className="text-xs px-1.5 py-0"
>
{snap.modCount} mods
</Badge>
{snapshots.map((snap) => {
const pending =
confirmRestore === snap.dirName || confirmDeleteSnap === snap.dirName;
const mode = confirmRestore === snap.dirName ? "restore" : "delete";
const matched = typedConfirm === snap.name;
return (
<li
key={snap.dirName}
className="rounded-md bg-muted/50 group"
>
<div className="flex items-center justify-between gap-2 px-3 py-2.5">
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{snap.name}</span>
<Badge variant="secondary" className="text-xs px-1.5 py-0">
{snap.modCount} mods
</Badge>
{typeof snap.sizeBytes === "number" && snap.sizeBytes > 0 && (
<Badge variant="secondary" className="text-xs px-1.5 py-0">
{formatBytes(snap.sizeBytes)}
</Badge>
)}
</div>
<p
className="text-xs text-muted-foreground"
title={new Date(snap.createdAt).toLocaleString()}
>
{timeAgo(snap.createdAt)}
</p>
</div>
<div className="flex gap-1 shrink-0 flex-wrap justify-end">
{pending ? (
<Button
size="sm"
variant="ghost"
onClick={() => {
setConfirmRestore(null);
setConfirmDeleteSnap(null);
setTypedConfirm("");
}}
className="text-xs h-9"
>
Cancel
</Button>
) : (
<>
<Button
size="sm"
variant="outline"
onClick={() => {
setConfirmRestore(snap.dirName);
setConfirmDeleteSnap(null);
setTypedConfirm("");
}}
disabled={isBusy}
className="text-xs h-9 sm:opacity-0 sm:group-hover:opacity-100 transition"
>
Restore
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
setConfirmDeleteSnap(snap.dirName);
setConfirmRestore(null);
setTypedConfirm("");
}}
disabled={isBusy}
className="text-xs h-9 text-muted-foreground sm:opacity-0 sm:group-hover:opacity-100 transition"
>
Delete
</Button>
</>
)}
</div>
</div>
<p
className="text-xs text-muted-foreground"
title={new Date(snap.createdAt).toLocaleString()}
>
{timeAgo(snap.createdAt)}
</p>
</div>
<div className="flex gap-1 shrink-0 flex-wrap justify-end">
{confirmRestore === snap.dirName ? (
<>
<Button
size="sm"
variant="default"
onClick={() => restoreSnap.mutate(snap.dirName)}
disabled={isBusy}
className="text-xs h-9"
>
Confirm Restore
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setConfirmRestore(null)}
className="text-xs h-9"
>
Cancel
</Button>
</>
) : confirmDeleteSnap === snap.dirName ? (
<>
<Button
size="sm"
variant="destructive"
onClick={() => {
deleteSnap.mutate(snap.dirName);
setConfirmDeleteSnap(null);
}}
disabled={isBusy}
className="text-xs h-9"
>
Confirm Delete
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setConfirmDeleteSnap(null)}
className="text-xs h-9"
>
Cancel
</Button>
</>
) : (
<>
<Button
size="sm"
variant="outline"
onClick={() => setConfirmRestore(snap.dirName)}
disabled={isBusy}
className="text-xs h-9 sm:opacity-0 sm:group-hover:opacity-100 transition"
>
Restore
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setConfirmDeleteSnap(snap.dirName)}
disabled={isBusy}
className="text-xs h-9 text-muted-foreground sm:opacity-0 sm:group-hover:opacity-100 transition"
>
Delete
</Button>
</>
{pending && (
<div className="px-3 pb-3 pt-0 border-t border-border/60">
<p className="text-xs text-muted-foreground mt-2 mb-1.5">
Type <span className="font-mono text-foreground">{snap.name}</span> to{" "}
{mode === "restore" ? "confirm restore" : "confirm delete"}
</p>
<div className="flex gap-2">
<Input
value={typedConfirm}
autoFocus
onChange={(e) => 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"
/>
<Button
size="sm"
variant={mode === "restore" ? "default" : "destructive"}
onClick={() => {
if (mode === "restore") restoreSnap.mutate(snap.dirName);
else {
deleteSnap.mutate(snap.dirName);
setConfirmDeleteSnap(null);
setTypedConfirm("");
}
}}
disabled={!matched || isBusy}
className="text-xs h-9"
>
{mode === "restore" ? "Confirm Restore" : "Confirm Delete"}
</Button>
</div>
</div>
)}
</div>
</li>
))}
</li>
);
})}
</ul>
</CardContent>
</Card>