Pass 3 first slice: mod update action, error boundaries, a11y, palette

- New POST /api/mods/update SSE route: per-file Modrinth lookup → snapshot →
  download latest → swap old jar → restart + verify (if server-side) →
  rebuild modpack, with automatic rollback on any failure.
- ModManager: "Update" button next to each mod with an available update,
  plus "Update all (N)" in the installed list header. Reuses the existing
  install timeline UI (same event shape). SSE reader extracted as
  consumeSSE helper.
- Error boundaries: app/error.tsx (scoped), app/admin/error.tsx (admin
  subtree retry), app/not-found.tsx, app/global-error.tsx (hard-fail
  fallback with inline styles, no app shell dependency).
- A11y sweep: aria-pressed + aria-label on LogViewer level chips and
  ModManager side filter; aria-label on admin TabsList; skip-to-content
  link in Navbar targeting <main id="main"> on public + admin pages;
  role/aria-live on install/update timeline; global Esc in ModManager
  clears open confirm prompts and exits search/review wizard steps.
- Command palette (cmdk): global Ctrl/⌘+K dialog mounted in Providers.
  Navigate admin tabs, toggle theme, start/stop/restart server, create
  backup, re-check mod updates, jump to any cached mod/player/snapshot/
  backup. Auth-aware — public users see only Home / Log in / Theme.
- AdminTabs listens to hashchange so palette navigation updates the
  active tab live.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hurkicorgi 2026-04-13 05:30:23 -06:00
parent f9ae1afac1
commit a011423017
16 changed files with 1124 additions and 22 deletions

View file

@ -34,6 +34,15 @@ export function AdminTabs() {
if (saved && TABS.some((t) => t.value === saved)) setValue(saved);
}, []);
useEffect(() => {
const onHash = () => {
const h = window.location.hash.replace("#", "");
if (h && TABS.some((t) => t.value === h)) setValue(h);
};
window.addEventListener("hashchange", onHash);
return () => window.removeEventListener("hashchange", onHash);
}, []);
useEffect(() => {
if (!mounted) return;
localStorage.setItem("admin-tab", value);
@ -48,7 +57,10 @@ export function AdminTabs() {
onValueChange={(v) => setValue(v as string)}
className="w-full"
>
<TabsList className="flex w-full flex-wrap h-auto sm:h-9 gap-1 p-1 overflow-x-auto justify-start sm:justify-center">
<TabsList
aria-label="Admin sections"
className="flex w-full flex-wrap h-auto sm:h-9 gap-1 p-1 overflow-x-auto justify-start sm:justify-center"
>
{TABS.map((t) => (
<TabsTrigger key={t.value} value={t.value} className="text-xs sm:text-sm">
{t.label}

View file

@ -0,0 +1,268 @@
"use client";
import { Command } from "cmdk";
import { useQueryClient } from "@tanstack/react-query";
import { useSession, signIn, signOut } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
type Mod = { filename: string; displayName: string; modId: string };
type Snapshot = { dirName: string; name: string };
type Backup = { name: string };
type PlayerData = {
ops: { name: string }[];
whitelist: { name: string }[];
banned: { name: string }[];
};
const TABS: { value: string; label: string }[] = [
{ value: "server", label: "Server" },
{ value: "players", label: "Players" },
{ value: "chat", label: "Chat" },
{ value: "mods", label: "Mods" },
{ value: "backups", label: "Backups" },
{ value: "logs", label: "Logs" },
];
function toggleTheme() {
const html = document.documentElement;
const dark = html.classList.contains("dark");
const next = dark ? "light" : "dark";
html.classList.toggle("dark", next === "dark");
html.classList.toggle("light", next === "light");
html.style.colorScheme = next;
localStorage.setItem("theme", next);
}
export function CommandPalette() {
const [open, setOpen] = useState(false);
const router = useRouter();
const queryClient = useQueryClient();
const { data: session } = useSession();
const authed = !!session;
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.key === "k" || e.key === "K") && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((v) => !v);
}
if (e.key === "Escape") setOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
const close = useCallback(() => setOpen(false), []);
const run = useCallback(
(fn: () => void | Promise<void>) => () => {
close();
Promise.resolve(fn()).catch(() => {});
},
[close]
);
const goToTab = (tab: string) => {
if (typeof window === "undefined") return;
if (!window.location.pathname.startsWith("/admin")) {
router.push(`/admin#${tab}`);
} else {
window.location.hash = tab;
// force AdminTabs to sync by dispatching a hashchange
window.dispatchEvent(new HashChangeEvent("hashchange"));
}
};
const mods = (queryClient.getQueryData<Mod[]>(["mods"]) || []).slice(0, 20);
const snapshots =
(queryClient.getQueryData<Snapshot[]>(["snapshots"]) || []).slice(0, 10);
const backups = (queryClient.getQueryData<Backup[]>(["backups"]) || []).slice(0, 10);
const players = queryClient.getQueryData<PlayerData>(["players"]);
const playerList = useMemo(() => {
if (!players) return [] as { name: string; group: string }[];
const out: { name: string; group: string }[] = [];
players.ops.forEach((p) => out.push({ name: p.name, group: "Ops" }));
players.whitelist.forEach((p) => out.push({ name: p.name, group: "Whitelist" }));
return out.slice(0, 20);
}, [players]);
const serverAction = async (act: "start" | "stop" | "restart") => {
const res = await fetch(`/api/server/${act}`, { method: "POST" });
if (res.ok) {
toast.success(`${act} command sent`);
queryClient.invalidateQueries({ queryKey: ["status"] });
} else {
const data = await res.json().catch(() => ({}));
toast.error(`${act} failed`, { description: data?.error });
}
};
const createBackup = async () => {
toast.loading("Creating backup...", { id: "cmdk-backup" });
const res = await fetch("/api/backups", { method: "POST" });
const data = await res.json();
toast.dismiss("cmdk-backup");
if (res.ok) {
toast.success(data.message || "Backup created");
queryClient.invalidateQueries({ queryKey: ["backups"] });
} else {
toast.error("Backup failed", { description: data.error });
}
};
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh] bg-black/50 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-label="Command palette"
onClick={(e) => {
if (e.target === e.currentTarget) close();
}}
>
<Command
label="Command Menu"
className="w-[95%] max-w-lg rounded-xl border border-border bg-popover text-popover-foreground shadow-2xl overflow-hidden"
>
<div className="border-b border-border px-3 py-2.5 flex items-center gap-2">
<span aria-hidden className="text-muted-foreground text-sm">K</span>
<Command.Input
autoFocus
placeholder="Type a command or search..."
className="flex-1 bg-transparent outline-none text-sm placeholder:text-muted-foreground"
/>
</div>
<Command.List className="max-h-[60vh] overflow-y-auto p-1.5">
<Command.Empty className="px-3 py-6 text-center text-sm text-muted-foreground">
No matches.
</Command.Empty>
<Command.Group heading="Navigate" className="text-xs text-muted-foreground px-1.5 pt-1.5">
<Item onSelect={run(() => router.push("/"))}>Go to Home</Item>
{authed ? (
TABS.map((t) => (
<Item key={t.value} onSelect={run(() => goToTab(t.value))}>
Admin · {t.label}
</Item>
))
) : (
<Item onSelect={run(() => signIn())}>Log in</Item>
)}
</Command.Group>
<Command.Group heading="Appearance" className="text-xs text-muted-foreground px-1.5 pt-1.5">
<Item onSelect={run(() => toggleTheme())}>Toggle theme</Item>
</Command.Group>
{authed && (
<>
<Command.Group heading="Server" className="text-xs text-muted-foreground px-1.5 pt-1.5">
<Item onSelect={run(() => serverAction("start"))}>Start server</Item>
<Item onSelect={run(() => serverAction("restart"))}>Restart server</Item>
<Item onSelect={run(() => serverAction("stop"))}>Stop server</Item>
<Item onSelect={run(() => createBackup())}>Create world backup now</Item>
<Item
onSelect={run(() => {
queryClient.invalidateQueries({ queryKey: ["mod-updates"] });
toast.success("Checking Modrinth for updates...");
})}
>
Check for mod updates
</Item>
<Item onSelect={run(() => signOut({ callbackUrl: "/" }))}>
Log out
</Item>
</Command.Group>
{mods.length > 0 && (
<Command.Group heading="Installed Mods" className="text-xs text-muted-foreground px-1.5 pt-1.5">
{mods.map((m) => (
<Item
key={m.filename}
value={`mod ${m.displayName} ${m.modId} ${m.filename}`}
onSelect={run(() => goToTab("mods"))}
>
<span className="truncate">{m.displayName}</span>
</Item>
))}
</Command.Group>
)}
{playerList.length > 0 && (
<Command.Group heading="Players" className="text-xs text-muted-foreground px-1.5 pt-1.5">
{playerList.map((p, i) => (
<Item
key={`${p.group}-${p.name}-${i}`}
value={`player ${p.name} ${p.group}`}
onSelect={run(() => goToTab("players"))}
>
<span>{p.name}</span>
<span className="ml-auto text-[10px] text-muted-foreground uppercase">
{p.group}
</span>
</Item>
))}
</Command.Group>
)}
{snapshots.length > 0 && (
<Command.Group heading="Snapshots" className="text-xs text-muted-foreground px-1.5 pt-1.5">
{snapshots.map((s) => (
<Item
key={s.dirName}
value={`snapshot ${s.name} ${s.dirName}`}
onSelect={run(() => goToTab("mods"))}
>
<span className="truncate">{s.name}</span>
</Item>
))}
</Command.Group>
)}
{backups.length > 0 && (
<Command.Group heading="Backups" className="text-xs text-muted-foreground px-1.5 pt-1.5">
{backups.map((b) => (
<Item
key={b.name}
value={`backup ${b.name}`}
onSelect={run(() => goToTab("backups"))}
>
<span className="truncate">{b.name}</span>
</Item>
))}
</Command.Group>
)}
</>
)}
</Command.List>
<div className="border-t border-border px-3 py-1.5 text-[10px] text-muted-foreground flex justify-between">
<span> navigate · select · esc close</span>
<span aria-hidden>K</span>
</div>
</Command>
</div>
);
}
function Item({
children,
onSelect,
value,
}: {
children: React.ReactNode;
onSelect: () => void;
value?: string;
}) {
return (
<Command.Item
onSelect={onSelect}
value={value}
className="flex items-center gap-2 rounded-md px-2.5 py-2 text-sm cursor-pointer data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground aria-selected:bg-accent aria-selected:text-accent-foreground"
>
{children}
</Command.Item>
);
}

View file

@ -157,6 +157,8 @@ export function LogViewer() {
<button
key={lvl}
onClick={() => toggleLevel(lvl)}
aria-pressed={active}
aria-label={`${active ? "Hide" : "Show"} ${lvl} lines`}
className={`text-[10px] font-semibold uppercase rounded px-2 py-1 border transition ${
active
? `${levelClass[lvl]} border-current/40 bg-muted`

View file

@ -92,6 +92,46 @@ const INSTALL_STEPS_CLIENT: Pick<TimelineStep, "id" | "label">[] = [
{ id: "modpack", label: "Update modpack" },
];
const UPDATE_STEPS_SERVER: Pick<TimelineStep, "id" | "label">[] = [
{ id: "resolve", label: "Resolve latest versions" },
{ id: "snapshot", label: "Create snapshot" },
{ id: "download", label: "Download updates" },
{ id: "restart", label: "Restart server" },
{ id: "health", label: "Verify server" },
{ id: "modpack", label: "Update modpack" },
];
async function consumeSSE(
res: Response,
onStep: (data: { id: string; status: TimelineStep["status"]; message?: string }) => void
): Promise<{ success: boolean; message: string; installed?: string[]; rolledBack?: boolean }> {
if (!res.body) throw new Error("No response stream");
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let finalResult: { success: boolean; message: string; installed?: string[]; rolledBack?: boolean } | null = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const frames = buffer.split("\n\n");
buffer = frames.pop()!;
for (const frame of frames) {
if (!frame.trim()) continue;
const eventMatch = frame.match(/^event:\s*(\w+)/m);
const dataMatch = frame.match(/^data:\s*(.+)/m);
if (!eventMatch || !dataMatch) continue;
const ev = eventMatch[1];
const data = JSON.parse(dataMatch[1]);
if (ev === "step") onStep(data);
else if (ev === "done") finalResult = data;
}
}
if (!finalResult) throw new Error("Stream ended without result");
return finalResult;
}
// ── Side Badge ──────────────────────────────────────────────
const sideConfig = {
@ -161,6 +201,27 @@ export function ModManager() {
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
}, [searchQuery]);
// Global Esc: clear confirm prompts and exit non-installing wizard steps
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
if (confirmRemove || confirmRestore || confirmDeleteSnap) {
setConfirmRemove(null);
setConfirmRestore(null);
setConfirmDeleteSnap(null);
return;
}
if (step === "searching" || step === "reviewing") {
setStep("idle");
setSelected(new Map());
setResolved(null);
setSearchQuery("");
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [confirmRemove, confirmRestore, confirmDeleteSnap, step]);
const { data: searchResults = [], isFetching: isSearching } = useQuery<SearchResult[]>({
queryKey: ["mod-search", debouncedQuery],
queryFn: () =>
@ -293,6 +354,74 @@ export function ModManager() {
},
});
// Update mod(s)
const updateMods = useMutation({
mutationFn: async (filenames: string[]) => {
const hasServerMod = filenames.some((f) => {
const m = mods.find((x) => x.filename === f);
return m ? m.side !== "client" : true;
});
const steps = (hasServerMod ? UPDATE_STEPS_SERVER : INSTALL_STEPS_CLIENT)
.map((s) => ({ ...s, status: "pending" as const }));
setTimelineSteps(steps);
setInstallStatus("Starting update...");
const res = await fetch("/api/mods/update", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filenames }),
});
return consumeSSE(res, (data) => {
setTimelineSteps((prev) => {
const exists = prev.some((s) => s.id === data.id);
if (exists) {
return prev.map((s) =>
s.id === data.id
? { ...s, status: data.status, message: data.message }
: s
);
}
return [
...prev,
{
id: data.id,
label: data.id === "rollback" ? "Rollback" : data.id,
status: data.status,
message: data.message,
},
];
});
setInstallStatus(data.message || "");
});
},
onSuccess: (data) => {
setInstallStatus("");
setInstallResult(data);
if (data.success) {
setTimelineSteps([]);
if (data.installed?.length) {
setNewlyInstalled(new Set(data.installed));
}
toast.success(data.message || "Mods updated");
} else {
toast.error(data.message || "Update failed", {
description: data.rolledBack ? "Changes rolled back." : undefined,
});
}
queryClient.invalidateQueries({ queryKey: ["mods"] });
queryClient.invalidateQueries({ queryKey: ["mod-updates"] });
queryClient.invalidateQueries({ queryKey: ["status"] });
queryClient.invalidateQueries({ queryKey: ["snapshots"] });
},
onError: (err) => {
setInstallStatus("");
setTimelineSteps([]);
setInstallResult({ success: false, message: err.message });
toast.error("Update failed", { description: err.message });
},
});
// Remove mod
const removeMod = useMutation({
mutationFn: async (filename: string) => {
@ -363,7 +492,13 @@ export function ModManager() {
});
}, []);
const isBusy = install.isPending || removeMod.isPending || restoreSnap.isPending;
const isBusy =
install.isPending ||
removeMod.isPending ||
restoreSnap.isPending ||
updateMods.isPending;
const showTimeline =
(step === "installing" || updateMods.isPending) && timelineSteps.length > 0;
const formatDownloads = (n: number) => {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
@ -683,9 +818,9 @@ export function ModManager() {
</div>
)}
{/* ── Step 3: Installing (Timeline) ─────────── */}
{step === "installing" && timelineSteps.length > 0 && (
<div className="space-y-1.5 py-2">
{/* ── Step 3: Installing / Updating (Timeline) ── */}
{showTimeline && (
<div className="space-y-1.5 py-2" role="status" aria-live="polite">
{timelineSteps.map((s) => (
<div key={s.id} className="flex items-start gap-3 px-1">
<div className="mt-0.5 shrink-0">
@ -734,8 +869,21 @@ export function ModManager() {
<Separator />
<div>
<div className="flex items-center justify-between gap-3 mb-3 flex-wrap">
<h3 className="text-sm font-semibold">
Installed Mods ({(() => {
<h3 className="text-sm font-semibold flex items-center gap-2">
{updates.length > 0 && (
<Button
size="sm"
variant="outline"
onClick={() =>
updateMods.mutate(updates.map((u) => u.filename))
}
disabled={isBusy}
className="text-xs h-8 border-amber-500/40 text-amber-300 hover:bg-amber-500/10"
>
Update all ({updates.length})
</Button>
)}
<span>Installed Mods ({(() => {
const q = installedQuery.trim().toLowerCase();
const count = mods.filter((m) =>
(sideFilter === "all" || m.side === sideFilter) &&
@ -745,7 +893,7 @@ export function ModManager() {
m.filename.toLowerCase().includes(q))
).length;
return count === mods.length ? mods.length : `${count} / ${mods.length}`;
})()})
})()})</span>
</h3>
<div className="flex items-center gap-2 flex-wrap">
<div className="flex gap-1">
@ -753,6 +901,8 @@ export function ModManager() {
<button
key={s}
onClick={() => setSideFilter(s)}
aria-pressed={sideFilter === s}
aria-label={`Filter by ${s}`}
className={`text-[10px] font-semibold uppercase rounded px-2 py-1 border transition ${
sideFilter === s
? "border-primary/40 bg-primary/10 text-primary"
@ -843,15 +993,28 @@ export function ModManager() {
</Button>
</div>
) : (
<Button
size="sm"
variant="ghost"
onClick={() => setConfirmRemove(mod.filename)}
disabled={isBusy}
className="text-xs h-9 text-muted-foreground sm:opacity-0 sm:group-hover:opacity-100 transition shrink-0"
>
Remove
</Button>
<div className="flex gap-1 shrink-0">
{updateMap.has(mod.filename) && (
<Button
size="sm"
variant="outline"
onClick={() => updateMods.mutate([mod.filename])}
disabled={isBusy}
className="text-xs h-9 border-amber-500/40 text-amber-300 hover:bg-amber-500/10"
>
Update
</Button>
)}
<Button
size="sm"
variant="ghost"
onClick={() => setConfirmRemove(mod.filename)}
disabled={isBusy}
className="text-xs h-9 text-muted-foreground sm:opacity-0 sm:group-hover:opacity-100 transition"
>
Remove
</Button>
</div>
)}
</li>
))}

View file

@ -10,6 +10,12 @@ export function Navbar() {
return (
<header className="border-b border-border bg-card">
<a
href="#main"
className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-50 focus:rounded focus:bg-primary focus:text-primary-foreground focus:px-3 focus:py-1.5 focus:text-sm focus:font-medium"
>
Skip to content
</a>
<div className="max-w-5xl mx-auto flex items-center justify-between px-3 sm:px-6 py-2.5 sm:py-3">
<Link href="/" className="font-bold text-primary text-base sm:text-lg tracking-tight">
HurkiCorgi MC