mc-dashboard/components/CommandPalette.tsx

269 lines
9.7 KiB
TypeScript
Raw Permalink Normal View History

2026-04-13 05:30:23 -06:00
"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>
);
}