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