From 0fc61ebb4741ec7269ba6a5b67c2f37e0651ac90 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Tue, 18 Aug 2026 02:05:00 -0400 Subject: [PATCH] feat(command-palette): add command palette with Cmd+K keyboard shortcuts --- .../command-palette/CommandPalette.tsx | 261 ++++++++++++++++++ .../command-palette/CommandPaletteContext.tsx | 85 ++++++ .../command-palette/KeySequenceIndicator.tsx | 27 ++ .../KeyboardShortcutsProvider.tsx | 64 +++++ .../command-palette/commands/actions.ts | 96 +++++++ .../command-palette/commands/navigation.ts | 158 +++++++++++ src/hooks/useKeyboardShortcuts.ts | 94 +++++++ 7 files changed, 785 insertions(+) create mode 100644 src/components/command-palette/CommandPalette.tsx create mode 100644 src/components/command-palette/CommandPaletteContext.tsx create mode 100644 src/components/command-palette/KeySequenceIndicator.tsx create mode 100644 src/components/command-palette/KeyboardShortcutsProvider.tsx create mode 100644 src/components/command-palette/commands/actions.ts create mode 100644 src/components/command-palette/commands/navigation.ts create mode 100644 src/hooks/useKeyboardShortcuts.ts diff --git a/src/components/command-palette/CommandPalette.tsx b/src/components/command-palette/CommandPalette.tsx new file mode 100644 index 0000000..3ffd409 --- /dev/null +++ b/src/components/command-palette/CommandPalette.tsx @@ -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(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(() => { + 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(() => { + 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(); + for (const cmd of contextualCommands) { + const existing = groups.get(cmd.group) ?? []; + existing.push(cmd); + groups.set(cmd.group, existing); + } + return groups; + }, [contextualCommands]); + + return ( + + + + +
+

No results found.

+

+ Try searching for "dashboard", "locker", or "notifications" +

+
+
+ + {filteredNavCommands.map((cmd) => ( + runCommand(() => router.push(cmd.href))} + > + + {cmd.label} + {cmd.shortcut && {cmd.shortcut}} + + ))} + + + + {filteredActionCommands.map((cmd) => ( + { + 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.label} + + ))} + + {groupedContextual.size > 0 && ( + <> + + {Array.from(groupedContextual.entries()).map(([group, cmds]) => ( + + {cmds.map((cmd) => ( + runCommand(cmd.onSelect)} + > + {cmd.icon && } + {cmd.label} + {cmd.shortcut && {cmd.shortcut}} + + ))} + + ))} + + )} +
+
+
+ + ↑↓ + navigate + + + ↵ + select + + + esc + close + +
+ ⌘K to toggle +
+
+ ); +} diff --git a/src/components/command-palette/CommandPaletteContext.tsx b/src/components/command-palette/CommandPaletteContext.tsx new file mode 100644 index 0000000..9e787cb --- /dev/null +++ b/src/components/command-palette/CommandPaletteContext.tsx @@ -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(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>(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( + () => ({ registerCommand, unregisterCommand, commands }), + [registerCommand, unregisterCommand, commands], + ); + + return ( + {children} + ); +} + +/** 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]); +} diff --git a/src/components/command-palette/KeySequenceIndicator.tsx b/src/components/command-palette/KeySequenceIndicator.tsx new file mode 100644 index 0000000..b2efaf8 --- /dev/null +++ b/src/components/command-palette/KeySequenceIndicator.tsx @@ -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 ( +
+ {pendingKey} + ... +
+ ); +} diff --git a/src/components/command-palette/KeyboardShortcutsProvider.tsx b/src/components/command-palette/KeyboardShortcutsProvider.tsx new file mode 100644 index 0000000..a93a464 --- /dev/null +++ b/src/components/command-palette/KeyboardShortcutsProvider.tsx @@ -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} + + + ); +} + diff --git a/src/components/command-palette/commands/actions.ts b/src/components/command-palette/commands/actions.ts new file mode 100644 index 0000000..5f5d927 --- /dev/null +++ b/src/components/command-palette/commands/actions.ts @@ -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", + }, +]; diff --git a/src/components/command-palette/commands/navigation.ts b/src/components/command-palette/commands/navigation.ts new file mode 100644 index 0000000..1d44c66 --- /dev/null +++ b/src/components/command-palette/commands/navigation.ts @@ -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"], + }, +]; diff --git a/src/hooks/useKeyboardShortcuts.ts b/src/hooks/useKeyboardShortcuts.ts new file mode 100644 index 0000000..9ecf9d1 --- /dev/null +++ b/src/hooks/useKeyboardShortcuts.ts @@ -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(null); + const pendingKeyRef = React.useRef(null); + const timeoutRef = React.useRef | null>(null); + const shortcutsRef = React.useRef(shortcuts); + shortcutsRef.current = shortcuts; + + const firstKeys = React.useMemo(() => { + const keys = new Set(); + 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 }; +}