1
0
Fork 0
polaris-task-force/src/components/command-palette/CommandPalette.tsx

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>
);
}