"use client"; /** * CommandPalette — Cmd+K / Ctrl+K global quick-launcher for the admin. * * Behavior: * - Toggled with Cmd+K (mac) or Ctrl+K (win/linux) on `document`. * - Closes on Escape, on backdrop click, or after a navigation. * - Fuzzy case-insensitive match on `label` / `category` / `keywords`. * - Top 8 results. Renders null when closed (no DOM noise). * - Keyboard: ↑/↓ to move, Enter to select, type to filter. * * Self-contained: does not read from the sidebar, does not depend on any * server data. The static list lives in `./command-palette-data.ts`. * * The component is intentionally NOT mounted in the admin layout by this * file. The main thread / sidebar agent wires it in once it's reviewed. */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { BarChart3, Clock, Command, CreditCard, Droplets, FileText, LayoutDashboard, LucideIcon, Map as MapIcon, MapPin, Megaphone, Package, Plug, PlusCircle, Puzzle, Receipt, RefreshCw, Route, ScrollText, Search, Send, Settings, ShoppingCart, Sparkles, Store, Tag, Truck, Upload, UserPlus, Users, Wallet, } from "lucide-react"; import { PALETTE_ENTRIES, type PaletteEntry } from "./command-palette-data"; const MAX_RESULTS = 8; // --------------------------------------------------------------------------- // Icon registry // --------------------------------------------------------------------------- const ICON_MAP: Record = { // Pages LayoutDashboard, Command, ShoppingCart, MapPin, Package, Truck, Send, Megaphone, FileText, Users, ScrollText, Store, Upload, Sparkles, Clock, Droplets, Route, BarChart3, Receipt, Settings, Tag, CreditCard, Plug, Wallet, Puzzle, // Quick actions PlusCircle, UserPlus, RefreshCw, // Misc / fallbacks Search, Map: MapIcon, }; // --------------------------------------------------------------------------- // Filtering / scoring // --------------------------------------------------------------------------- function scoreEntry(entry: PaletteEntry, query: string): number { if (!query) return 1; const q = query.toLowerCase(); const label = entry.label.toLowerCase(); const category = entry.category.toLowerCase(); let score = 0; if (label === q) score += 100; else if (label.startsWith(q)) score += 50; else if (label.includes(q)) score += 25; if (category.toLowerCase().includes(q)) score += 10; if (entry.keywords?.some((k) => k.toLowerCase().includes(q))) score += 5; // Quick actions get a small boost when no specific match (so they surface // on the empty query list, alongside the first batch of pages). if (!query && entry.type === "action") score = 0.5; if (!query && entry.type === "page") score = 1 - entry.label.length * 0.001; return score; } function filterEntries(query: string): PaletteEntry[] { const scored: { entry: PaletteEntry; score: number }[] = []; for (const e of PALETTE_ENTRIES) { const score = scoreEntry(e, query); if (score > 0) { scored.push({ entry: e, score }); } } scored.sort((a, b) => b.score - a.score); return scored.slice(0, MAX_RESULTS).map((r) => r.entry); } // --------------------------------------------------------------------------- // Highlight helper // --------------------------------------------------------------------------- function HighlightedText({ text, query }: { text: string; query: string }) { if (!query) return <>{text}; const q = query.toLowerCase(); const idx = text.toLowerCase().indexOf(q); if (idx === -1) return <>{text}; return ( <> {text.slice(0, idx)} {text.slice(idx, idx + query.length)} {text.slice(idx + query.length)} ); } // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- export default function CommandPalette() { const [open, setOpen] = useState(false); const [toggleCount, setToggleCount] = useState(0); // Open/close on Cmd+K / Ctrl+K. Toggle so the same shortcut closes it. useEffect(() => { function onKeyDown(e: KeyboardEvent) { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault(); setOpen((o) => !o); setToggleCount((c) => c + 1); } } document.addEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown); }, []); if (!open) return null; return setOpen(false)} />; } function CommandPaletteDialog({ onClose }: { onClose: () => void }) { const [query, setQuery] = useState(""); const [selected, setSelected] = useState(0); const inputRef = useRef(null); const dialogRef = useRef(null); const router = useRouter(); // Native handles focus trapping, ESC, and the backdrop. useEffect(() => { const dialog = dialogRef.current; if (!dialog) return; if (!dialog.open) dialog.showModal(); const raf = requestAnimationFrame(() => inputRef.current?.focus()); const onCancel = (e: Event) => { e.preventDefault(); onClose(); }; dialog.addEventListener("cancel", onCancel); return () => { cancelAnimationFrame(raf); dialog.removeEventListener("cancel", onCancel); }; }, [onClose]); // Filtering const results = useMemo(() => filterEntries(query), [query]); // Always render a valid selection index (clamped to results length). const safeSelected = results.length === 0 ? 0 : Math.min(selected, results.length - 1); const navigate = useCallback( (href: string) => { onClose(); router.push(href); }, [router, onClose] ); // In-input keyboard nav. Attached to the input itself so it works whether // the user is typing or the input just has focus. const handleInputKeyDown = (e: React.KeyboardEvent) => { if (e.key === "ArrowDown") { e.preventDefault(); if (results.length === 0) return; setSelected((s) => (s + 1) % results.length); } else if (e.key === "ArrowUp") { e.preventDefault(); if (results.length === 0) return; setSelected((s) => (s - 1 + results.length) % results.length); } else if (e.key === "Enter") { e.preventDefault(); const entry = results[safeSelected]; if (entry) navigate(entry.href); } }; return (
{/* Search input row */}
{/* Results */}
{results.length === 0 ? (
No results for “{query}”
) : ( results.map((entry, i) => { const Icon = ICON_MAP[entry.iconName] ?? Search; const isSelected = i === safeSelected; return ( ); }) )}
{/* Footer hint */}
↑↓ to navigate  ·  ↵ to select  ·  esc to close
); }