- 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>
268 lines
9.7 KiB
TypeScript
268 lines
9.7 KiB
TypeScript
"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>
|
|
);
|
|
}
|