1
0
Fork 0

feat(command-palette): add command palette with Cmd+K keyboard shortcuts

This commit is contained in:
Jason Fraley 2026-08-18 02:05:00 -04:00
parent 2f536a814b
commit 0fc61ebb47
7 changed files with 785 additions and 0 deletions

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

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

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

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

View 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",
},
];

View 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"],
},
];

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