feat(command-palette): add command palette with Cmd+K keyboard shortcuts
This commit is contained in:
parent
2f536a814b
commit
0fc61ebb47
7 changed files with 785 additions and 0 deletions
261
src/components/command-palette/CommandPalette.tsx
Normal file
261
src/components/command-palette/CommandPalette.tsx
Normal file
|
|
@ -0,0 +1,261 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
src/components/command-palette/CommandPaletteContext.tsx
Normal file
85
src/components/command-palette/CommandPaletteContext.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
export interface ContextualCommand {
|
||||||
|
id: string;
|
||||||
|
group: string;
|
||||||
|
label: string;
|
||||||
|
icon?: React.ComponentType<{ className?: string }>;
|
||||||
|
shortcut?: string;
|
||||||
|
keywords?: string[];
|
||||||
|
onSelect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommandPaletteAPI {
|
||||||
|
registerCommand: (entry: ContextualCommand) => void;
|
||||||
|
unregisterCommand: (id: string) => void;
|
||||||
|
commands: ContextualCommand[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CommandPaletteContext = React.createContext<CommandPaletteAPI | null>(null);
|
||||||
|
|
||||||
|
export { CommandPaletteContext };
|
||||||
|
|
||||||
|
export function useCommandPalette() {
|
||||||
|
const ctx = React.useContext(CommandPaletteContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useCommandPalette must be used within CommandPaletteProvider");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandPaletteProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const commandsRef = React.useRef<Map<string, ContextualCommand>>(new Map());
|
||||||
|
const [, forceUpdate] = React.useReducer((x: number) => x + 1, 0);
|
||||||
|
|
||||||
|
const registerCommand = React.useCallback((entry: ContextualCommand) => {
|
||||||
|
commandsRef.current.set(entry.id, entry);
|
||||||
|
forceUpdate();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const unregisterCommand = React.useCallback((id: string) => {
|
||||||
|
commandsRef.current.delete(id);
|
||||||
|
forceUpdate();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const commands = React.useMemo(
|
||||||
|
() => Array.from(commandsRef.current.values()),
|
||||||
|
// forceUpdate triggers re-render; recompute on each render via ref identity
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[commandsRef, forceUpdate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const value = React.useMemo<CommandPaletteAPI>(
|
||||||
|
() => ({ registerCommand, unregisterCommand, commands }),
|
||||||
|
[registerCommand, unregisterCommand, commands],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandPaletteContext.Provider value={value}>{children}</CommandPaletteContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register a set of commands for the current page. Unregisters on unmount. */
|
||||||
|
export function usePageCommands(commands: ContextualCommand[]) {
|
||||||
|
const { registerCommand, unregisterCommand } = useCommandPalette();
|
||||||
|
|
||||||
|
// Keep the latest commands in a ref so the effect doesn't re-run on every
|
||||||
|
// render when the parent recreates the array. The effect syncs the registry
|
||||||
|
// to the ref contents on each change.
|
||||||
|
const commandsRef = React.useRef(commands);
|
||||||
|
commandsRef.current = commands;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const current = commandsRef.current;
|
||||||
|
for (const cmd of current) {
|
||||||
|
registerCommand(cmd);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
for (const cmd of current) {
|
||||||
|
unregisterCommand(cmd.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [registerCommand, unregisterCommand]);
|
||||||
|
}
|
||||||
27
src/components/command-palette/KeySequenceIndicator.tsx
Normal file
27
src/components/command-palette/KeySequenceIndicator.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface KeySequenceIndicatorProps {
|
||||||
|
pendingKey: string | null;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KeySequenceIndicator({ pendingKey, className }: KeySequenceIndicatorProps) {
|
||||||
|
if (!pendingKey) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"fixed bottom-4 left-1/2 -translate-x-1/2 z-50",
|
||||||
|
"flex items-center gap-2 rounded-lg border bg-background/95 px-4 py-2 shadow-lg",
|
||||||
|
"animate-in fade-in slide-in-from-bottom-2 duration-200",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<kbd className="rounded border bg-muted px-2 py-0.5 text-sm font-medium">{pendingKey}</kbd>
|
||||||
|
<span className="text-sm text-muted-foreground">...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
src/components/command-palette/KeyboardShortcutsProvider.tsx
Normal file
64
src/components/command-palette/KeyboardShortcutsProvider.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useKeyboardShortcuts, type ShortcutMap } from "@/hooks/useKeyboardShortcuts";
|
||||||
|
import { KeySequenceIndicator } from "./KeySequenceIndicator";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
interface KeyboardShortcutsProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
userRoles?: string[];
|
||||||
|
hasIntelligence?: boolean;
|
||||||
|
hasLogistics?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_SHORTCUTS: ShortcutMap = {
|
||||||
|
"g d": "/",
|
||||||
|
"g r": "/roster",
|
||||||
|
"g h": "/helpdesk",
|
||||||
|
"g l": "/locker",
|
||||||
|
"g n": "/notifications",
|
||||||
|
};
|
||||||
|
|
||||||
|
const INTELLIGENCE_SHORTCUTS: ShortcutMap = {
|
||||||
|
"g c": "/intelligence/campaigns",
|
||||||
|
"g m": "/intelligence/missions",
|
||||||
|
};
|
||||||
|
|
||||||
|
const LOGISTICS_SHORTCUTS: ShortcutMap = {
|
||||||
|
"g s": "/logistics",
|
||||||
|
"g t": "/logistics/shipments",
|
||||||
|
"g b": "/logistics/banking",
|
||||||
|
"g k": "/logistics/market",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ADMIN_SHORTCUTS: ShortcutMap = {
|
||||||
|
"g a": "/admin",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function KeyboardShortcutsProvider({
|
||||||
|
children,
|
||||||
|
userRoles = [],
|
||||||
|
hasIntelligence = false,
|
||||||
|
hasLogistics = false,
|
||||||
|
}: KeyboardShortcutsProviderProps) {
|
||||||
|
const shortcuts = React.useMemo(() => {
|
||||||
|
const isAdmin = userRoles.includes("admin") || userRoles.includes("developer");
|
||||||
|
const isDev = userRoles.includes("developer");
|
||||||
|
return {
|
||||||
|
...BASE_SHORTCUTS,
|
||||||
|
...(hasIntelligence || isDev ? INTELLIGENCE_SHORTCUTS : {}),
|
||||||
|
...(hasLogistics || isDev ? LOGISTICS_SHORTCUTS : {}),
|
||||||
|
...(isAdmin ? ADMIN_SHORTCUTS : {}),
|
||||||
|
};
|
||||||
|
}, [userRoles, hasIntelligence, hasLogistics]);
|
||||||
|
|
||||||
|
const { pendingKey } = useKeyboardShortcuts(shortcuts);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{children}
|
||||||
|
<KeySequenceIndicator pendingKey={pendingKey} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
96
src/components/command-palette/commands/actions.ts
Normal file
96
src/components/command-palette/commands/actions.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import {
|
||||||
|
UserPlus,
|
||||||
|
Bell,
|
||||||
|
Backpack,
|
||||||
|
LogOut,
|
||||||
|
PlaneTakeoff,
|
||||||
|
User,
|
||||||
|
RefreshCw,
|
||||||
|
Shield,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export interface ActionCommand {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
keywords?: string[];
|
||||||
|
/** Action type determines how onSelect is handled */
|
||||||
|
actionType: "navigate" | "callback";
|
||||||
|
/** For navigate actions */
|
||||||
|
href?: string;
|
||||||
|
/** For callback actions - the action key that CommandPalette maps to a handler */
|
||||||
|
actionKey?: string;
|
||||||
|
/** If set, only shown for these roles */
|
||||||
|
roles?: string[];
|
||||||
|
/** If set, only shown when user has this qualification */
|
||||||
|
requires?: "intelligence" | "logistics";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const actionCommands: ActionCommand[] = [
|
||||||
|
{
|
||||||
|
id: "action:flight-simulator",
|
||||||
|
label: "Flight Simulator",
|
||||||
|
icon: PlaneTakeoff,
|
||||||
|
keywords: ["flappy", "game", "minigame", "fly"],
|
||||||
|
actionType: "navigate",
|
||||||
|
href: "/flappy",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "action:view-notifications",
|
||||||
|
label: "View Notifications",
|
||||||
|
icon: Bell,
|
||||||
|
keywords: ["alerts", "messages", "inbox"],
|
||||||
|
actionType: "navigate",
|
||||||
|
href: "/notifications",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "action:create-ticket",
|
||||||
|
label: "Create Support Ticket",
|
||||||
|
icon: UserPlus,
|
||||||
|
keywords: ["help", "support", "report"],
|
||||||
|
actionType: "navigate",
|
||||||
|
href: "/helpdesk",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "action:admin-panel",
|
||||||
|
label: "Open Admin Panel",
|
||||||
|
icon: Shield,
|
||||||
|
keywords: ["cms", "manage", "backend"],
|
||||||
|
actionType: "navigate",
|
||||||
|
href: "/admin",
|
||||||
|
roles: ["admin", "developer"],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: "action:mark-all-read",
|
||||||
|
label: "Mark All Notifications Read",
|
||||||
|
icon: Bell,
|
||||||
|
keywords: ["clear", "dismiss", "notifications"],
|
||||||
|
actionType: "callback",
|
||||||
|
actionKey: "markAllRead",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "action:auto-arrange-locker",
|
||||||
|
label: "Auto-Arrange Locker",
|
||||||
|
icon: Backpack,
|
||||||
|
keywords: ["sort", "organize", "inventory"],
|
||||||
|
actionType: "callback",
|
||||||
|
actionKey: "autoArrangeLocker",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "action:refresh-page",
|
||||||
|
label: "Refresh Page",
|
||||||
|
icon: RefreshCw,
|
||||||
|
keywords: ["reload", "update"],
|
||||||
|
actionType: "callback",
|
||||||
|
actionKey: "refreshPage",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "action:logout",
|
||||||
|
label: "Log Out",
|
||||||
|
icon: LogOut,
|
||||||
|
keywords: ["signout", "exit"],
|
||||||
|
actionType: "callback",
|
||||||
|
actionKey: "logout",
|
||||||
|
},
|
||||||
|
];
|
||||||
158
src/components/command-palette/commands/navigation.ts
Normal file
158
src/components/command-palette/commands/navigation.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
import {
|
||||||
|
Bolt,
|
||||||
|
Users,
|
||||||
|
LifeBuoy,
|
||||||
|
Crosshair,
|
||||||
|
Warehouse,
|
||||||
|
Truck,
|
||||||
|
Fuel,
|
||||||
|
Map,
|
||||||
|
Backpack,
|
||||||
|
UserCog,
|
||||||
|
Bell,
|
||||||
|
Shield,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export interface NavigationCommand {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
href: string;
|
||||||
|
keywords?: string[];
|
||||||
|
shortcut?: string;
|
||||||
|
requires?: "intelligence" | "logistics";
|
||||||
|
roles?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const navigationCommands: NavigationCommand[] = [
|
||||||
|
{ id: "nav:dashboard", label: "Dashboard", icon: Bolt, href: "/", keywords: ["home", "main"], shortcut: "G D" },
|
||||||
|
{ id: "nav:roster", label: "Roster", icon: Users, href: "/roster", keywords: ["members", "personnel"], shortcut: "G R" },
|
||||||
|
{
|
||||||
|
id: "nav:helpdesk",
|
||||||
|
label: "Helpdesk",
|
||||||
|
icon: LifeBuoy,
|
||||||
|
href: "/helpdesk",
|
||||||
|
keywords: ["support", "tickets"],
|
||||||
|
shortcut: "G H",
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: "nav:campaigns",
|
||||||
|
label: "Campaigns",
|
||||||
|
icon: Crosshair,
|
||||||
|
href: "/intelligence/campaigns",
|
||||||
|
keywords: ["operations"],
|
||||||
|
requires: "intelligence",
|
||||||
|
shortcut: "G C",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "nav:missions",
|
||||||
|
label: "Missions",
|
||||||
|
icon: Crosshair,
|
||||||
|
href: "/intelligence/missions",
|
||||||
|
keywords: ["sorties", "ops"],
|
||||||
|
requires: "intelligence",
|
||||||
|
shortcut: "G M",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "nav:factions",
|
||||||
|
label: "Factions",
|
||||||
|
icon: Crosshair,
|
||||||
|
href: "/intelligence/factions",
|
||||||
|
keywords: ["groups", "teams"],
|
||||||
|
requires: "intelligence",
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: "nav:structures",
|
||||||
|
label: "Structures",
|
||||||
|
icon: Warehouse,
|
||||||
|
href: "/logistics",
|
||||||
|
keywords: ["buildings", "storage"],
|
||||||
|
requires: "logistics",
|
||||||
|
shortcut: "G S",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "nav:resources",
|
||||||
|
label: "Resources",
|
||||||
|
icon: Fuel,
|
||||||
|
href: "/logistics/resources",
|
||||||
|
keywords: ["materials", "supplies"],
|
||||||
|
requires: "logistics",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "nav:vehicles",
|
||||||
|
label: "Vehicles",
|
||||||
|
icon: Truck,
|
||||||
|
href: "/logistics/vehicles",
|
||||||
|
keywords: ["cars", "trucks"],
|
||||||
|
requires: "logistics",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "nav:shipments",
|
||||||
|
label: "Shipments",
|
||||||
|
icon: Truck,
|
||||||
|
href: "/logistics/shipments",
|
||||||
|
keywords: ["shipping", "delivery"],
|
||||||
|
requires: "logistics",
|
||||||
|
shortcut: "G T",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "nav:deployed-vehicles",
|
||||||
|
label: "Deployed Vehicles",
|
||||||
|
icon: Map,
|
||||||
|
href: "/logistics/game-vehicles",
|
||||||
|
keywords: ["active", "field"],
|
||||||
|
requires: "logistics",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "nav:banking",
|
||||||
|
label: "Banking",
|
||||||
|
icon: UserCog,
|
||||||
|
href: "/logistics/banking",
|
||||||
|
keywords: ["finance", "accounts", "money"],
|
||||||
|
requires: "logistics",
|
||||||
|
shortcut: "G B",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "nav:market",
|
||||||
|
label: "Market",
|
||||||
|
icon: UserCog,
|
||||||
|
href: "/logistics/market",
|
||||||
|
keywords: ["trading", "flea market", "buy", "sell"],
|
||||||
|
requires: "logistics",
|
||||||
|
shortcut: "G K",
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: "nav:locker",
|
||||||
|
label: "Locker",
|
||||||
|
icon: Backpack,
|
||||||
|
href: "/locker",
|
||||||
|
keywords: ["inventory", "gear", "equipment"],
|
||||||
|
shortcut: "G L",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "nav:account",
|
||||||
|
label: "Account",
|
||||||
|
icon: UserCog,
|
||||||
|
href: "/account",
|
||||||
|
keywords: ["settings", "profile", "password"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "nav:notifications",
|
||||||
|
label: "Notifications",
|
||||||
|
icon: Bell,
|
||||||
|
href: "/notifications",
|
||||||
|
keywords: ["alerts", "messages"],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: "nav:admin",
|
||||||
|
label: "Admin Panel",
|
||||||
|
icon: Shield,
|
||||||
|
href: "/admin",
|
||||||
|
keywords: ["cms", "manage"],
|
||||||
|
roles: ["admin", "developer"],
|
||||||
|
},
|
||||||
|
];
|
||||||
94
src/hooks/useKeyboardShortcuts.ts
Normal file
94
src/hooks/useKeyboardShortcuts.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
export interface ShortcutMap {
|
||||||
|
[sequence: string]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEQUENCE_TIMEOUT_MS = 800;
|
||||||
|
|
||||||
|
export function useKeyboardShortcuts(shortcuts: ShortcutMap) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pendingKey, setPendingKey] = React.useState<string | null>(null);
|
||||||
|
const pendingKeyRef = React.useRef<string | null>(null);
|
||||||
|
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const shortcutsRef = React.useRef(shortcuts);
|
||||||
|
shortcutsRef.current = shortcuts;
|
||||||
|
|
||||||
|
const firstKeys = React.useMemo(() => {
|
||||||
|
const keys = new Set<string>();
|
||||||
|
for (const seq of Object.keys(shortcuts)) {
|
||||||
|
const first = seq.split(" ")[0];
|
||||||
|
if (first) keys.add(first);
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}, [shortcuts]);
|
||||||
|
const firstKeysRef = React.useRef(firstKeys);
|
||||||
|
firstKeysRef.current = firstKeys;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (
|
||||||
|
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
|
||||||
|
e.target instanceof HTMLInputElement ||
|
||||||
|
e.target instanceof HTMLTextAreaElement ||
|
||||||
|
e.target instanceof HTMLSelectElement
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = e.key.toLowerCase();
|
||||||
|
|
||||||
|
if (key === "escape") {
|
||||||
|
pendingKeyRef.current = null;
|
||||||
|
setPendingKey(null);
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingKeyRef.current === null) {
|
||||||
|
if (firstKeysRef.current.has(key)) {
|
||||||
|
pendingKeyRef.current = key;
|
||||||
|
setPendingKey(key);
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
pendingKeyRef.current = null;
|
||||||
|
setPendingKey(null);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}, SEQUENCE_TIMEOUT_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const sequence = `${pendingKeyRef.current} ${key}`;
|
||||||
|
const target = shortcutsRef.current[sequence];
|
||||||
|
|
||||||
|
pendingKeyRef.current = null;
|
||||||
|
setPendingKey(null);
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
e.preventDefault();
|
||||||
|
router.push(target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
return { pendingKey };
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue