261 lines
8.4 KiB
TypeScript
261 lines
8.4 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { toast } from "sonner";
|
|
import { User } from "lucide-react";
|
|
|
|
import {
|
|
CommandDialog,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
CommandSeparator,
|
|
CommandShortcut,
|
|
} from "@/components/ui/command";
|
|
|
|
import { navigationCommands, type NavigationCommand } from "./commands/navigation";
|
|
import { actionCommands, type ActionCommand } from "./commands/actions";
|
|
import {
|
|
CommandPaletteContext,
|
|
type ContextualCommand,
|
|
} from "./CommandPaletteContext";
|
|
import { markAllNotificationsRead } from "@/app/(frontend)/notifications/actions";
|
|
import { autoArrangeLocker } from "@/app/(frontend)/locker/actions";
|
|
|
|
interface CommandPaletteProps {
|
|
userRoles: string[];
|
|
hasIntelligence: boolean;
|
|
hasLogistics: boolean;
|
|
username?: string;
|
|
}
|
|
|
|
export function CommandPalette({
|
|
userRoles,
|
|
hasIntelligence,
|
|
hasLogistics,
|
|
username,
|
|
}: CommandPaletteProps) {
|
|
const router = useRouter();
|
|
const [open, setOpen] = React.useState(false);
|
|
const [loading, setLoading] = React.useState<string | null>(null);
|
|
const contextualCommands = React.useContext(CommandPaletteContext)?.commands ?? [];
|
|
|
|
React.useEffect(() => {
|
|
const down = (e: KeyboardEvent) => {
|
|
if ((e.key === "k" && (e.metaKey || e.ctrlKey)) || e.key === "/") {
|
|
if (
|
|
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
|
|
e.target instanceof HTMLInputElement ||
|
|
e.target instanceof HTMLTextAreaElement ||
|
|
e.target instanceof HTMLSelectElement
|
|
) {
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
setOpen((prev) => !prev);
|
|
}
|
|
};
|
|
document.addEventListener("keydown", down);
|
|
return () => document.removeEventListener("keydown", down);
|
|
}, []);
|
|
|
|
const runCommand = React.useCallback((command: () => unknown) => {
|
|
setOpen(false);
|
|
command();
|
|
}, []);
|
|
|
|
const handleCallback = React.useCallback(
|
|
async (actionKey: string) => {
|
|
setLoading(actionKey);
|
|
try {
|
|
switch (actionKey) {
|
|
case "markAllRead": {
|
|
const result = await markAllNotificationsRead();
|
|
if (result.success) {
|
|
toast.success("All notifications marked as read");
|
|
} else {
|
|
toast.error(result.error ?? "Failed to mark notifications");
|
|
}
|
|
break;
|
|
}
|
|
case "autoArrangeLocker": {
|
|
const result = await autoArrangeLocker();
|
|
if (result.success) {
|
|
toast.success("Locker arranged");
|
|
} else {
|
|
toast.error(result.error ?? "Failed to arrange locker");
|
|
}
|
|
break;
|
|
}
|
|
case "refreshPage": {
|
|
router.refresh();
|
|
toast.success("Page refreshed");
|
|
break;
|
|
}
|
|
case "logout": {
|
|
await fetch("/api/users/logout", { method: "POST" });
|
|
window.location.href = "/login";
|
|
break;
|
|
}
|
|
}
|
|
} finally {
|
|
setLoading(null);
|
|
}
|
|
},
|
|
[router],
|
|
);
|
|
|
|
const filteredNavCommands = React.useMemo<NavigationCommand[]>(() => {
|
|
const base = navigationCommands.filter((cmd) => {
|
|
if (
|
|
cmd.roles &&
|
|
!cmd.roles.some((r) => userRoles.includes(r) || userRoles.includes("developer"))
|
|
) {
|
|
return false;
|
|
}
|
|
if (cmd.requires === "intelligence" && !hasIntelligence && !userRoles.includes("developer")) {
|
|
return false;
|
|
}
|
|
if (cmd.requires === "logistics" && !hasLogistics && !userRoles.includes("developer")) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
if (username) {
|
|
return [
|
|
{
|
|
id: "nav:my-profile",
|
|
label: "My Profile",
|
|
icon: User,
|
|
href: `/profile/${username}`,
|
|
keywords: ["awards", "rank", "progression", "service record"],
|
|
},
|
|
...base,
|
|
];
|
|
}
|
|
return base;
|
|
}, [userRoles, hasIntelligence, hasLogistics, username]);
|
|
|
|
const filteredActionCommands = React.useMemo<ActionCommand[]>(() => {
|
|
return actionCommands.filter((cmd) => {
|
|
if (
|
|
cmd.roles &&
|
|
!cmd.roles.some((r) => userRoles.includes(r) || userRoles.includes("developer"))
|
|
) {
|
|
return false;
|
|
}
|
|
if (cmd.requires === "intelligence" && !hasIntelligence && !userRoles.includes("developer")) {
|
|
return false;
|
|
}
|
|
if (cmd.requires === "logistics" && !hasLogistics && !userRoles.includes("developer")) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
}, [userRoles, hasIntelligence, hasLogistics]);
|
|
|
|
const groupedContextual = React.useMemo(() => {
|
|
const groups = new Map<string, ContextualCommand[]>();
|
|
for (const cmd of contextualCommands) {
|
|
const existing = groups.get(cmd.group) ?? [];
|
|
existing.push(cmd);
|
|
groups.set(cmd.group, existing);
|
|
}
|
|
return groups;
|
|
}, [contextualCommands]);
|
|
|
|
return (
|
|
<CommandDialog open={open} onOpenChange={setOpen} title="Command Palette">
|
|
<CommandInput placeholder="Type a command or search..." />
|
|
<CommandList>
|
|
<CommandEmpty>
|
|
<div className="flex flex-col items-center gap-2 py-4">
|
|
<p className="text-sm text-muted-foreground">No results found.</p>
|
|
<p className="text-xs text-muted-foreground/70">
|
|
Try searching for "dashboard", "locker", or "notifications"
|
|
</p>
|
|
</div>
|
|
</CommandEmpty>
|
|
<CommandGroup heading="Navigation">
|
|
{filteredNavCommands.map((cmd) => (
|
|
<CommandItem
|
|
key={cmd.id}
|
|
value={cmd.label}
|
|
keywords={cmd.keywords}
|
|
onSelect={() => runCommand(() => router.push(cmd.href))}
|
|
>
|
|
<cmd.icon className="mr-2 h-4 w-4" />
|
|
{cmd.label}
|
|
{cmd.shortcut && <CommandShortcut>{cmd.shortcut}</CommandShortcut>}
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
<CommandSeparator />
|
|
<CommandGroup heading="Actions">
|
|
{filteredActionCommands.map((cmd) => (
|
|
<CommandItem
|
|
key={cmd.id}
|
|
value={cmd.label}
|
|
keywords={cmd.keywords}
|
|
disabled={loading !== null}
|
|
onSelect={() => {
|
|
if (cmd.actionType === "navigate" && cmd.href) {
|
|
const href = cmd.href;
|
|
runCommand(() => router.push(href));
|
|
} else if (cmd.actionType === "callback" && cmd.actionKey) {
|
|
const key = cmd.actionKey;
|
|
runCommand(() => handleCallback(key));
|
|
}
|
|
}}
|
|
>
|
|
<cmd.icon className={`mr-2 h-4 w-4 ${loading === cmd.actionKey ? "animate-spin" : ""}`} />
|
|
{cmd.label}
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
{groupedContextual.size > 0 && (
|
|
<>
|
|
<CommandSeparator />
|
|
{Array.from(groupedContextual.entries()).map(([group, cmds]) => (
|
|
<CommandGroup key={group} heading={group}>
|
|
{cmds.map((cmd) => (
|
|
<CommandItem
|
|
key={cmd.id}
|
|
value={cmd.label}
|
|
keywords={cmd.keywords}
|
|
onSelect={() => runCommand(cmd.onSelect)}
|
|
>
|
|
{cmd.icon && <cmd.icon className="mr-2 h-4 w-4" />}
|
|
{cmd.label}
|
|
{cmd.shortcut && <CommandShortcut>{cmd.shortcut}</CommandShortcut>}
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
))}
|
|
</>
|
|
)}
|
|
</CommandList>
|
|
<div className="flex items-center justify-between border-t px-4 py-2 text-xs text-muted-foreground">
|
|
<div className="flex items-center gap-4">
|
|
<span className="flex items-center gap-1">
|
|
<kbd className="rounded border bg-muted px-1">↑↓</kbd>
|
|
<span>navigate</span>
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<kbd className="rounded border bg-muted px-1">↵</kbd>
|
|
<span>select</span>
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<kbd className="rounded border bg-muted px-1">esc</kbd>
|
|
<span>close</span>
|
|
</span>
|
|
</div>
|
|
<span>⌘K to toggle</span>
|
|
</div>
|
|
</CommandDialog>
|
|
);
|
|
}
|