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:
parent
a011423017
commit
19d66c2de6
7 changed files with 589 additions and 109 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue