feat(admin): add Cmd+K command palette component

Adds a self-contained <CommandPalette /> client component plus a
static data file that lists every admin page (per the new IA in
docs/superpowers/specs/2026-06-17-admin-redesign.md §4) and a
small set of quick actions.

Behavior:
- Toggles on Cmd+K (mac) / Ctrl+K (win/linux) at document level.
- Escape or backdrop click closes; Enter or click navigates.
- Fuzzy case-insensitive match on label / category / keywords,
  top 8 results, highlighted match substring.
- ↑/↓ keyboard nav, mouse hover updates selection, auto-focus
  on input when opened, body scroll lock while open.
- Visual: uses existing --admin-card-bg / --admin-border /
  --admin-primary-soft / --admin-radius-lg / --admin-shadow-lg
  tokens; fade-in 180ms, scale 0.98→1 animation.
- Returns null when closed (no DOM noise).

Out of scope for this pass:
- Component is NOT mounted in the admin layout (main thread wires
  it in). The data file is the source of truth; the palette does
  not import from AdminSidebar.
- localStorage-based Recent items are left as a TODO comment
  in the component; v1 ships without persistence.

No new dependencies; uses lucide-react (already in package.json).
npx tsc --noEmit is clean.
This commit is contained in:
Tyler
2026-06-17 00:13:23 -06:00
parent 18fb44ed38
commit 750efdd318
2 changed files with 870 additions and 0 deletions
+468
View File
@@ -0,0 +1,468 @@
"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<string, LucideIcon> = {
// 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 = PALETTE_ENTRIES
.map((e) => ({ entry: e, score: scoreEntry(e, query) }))
.filter((r) => r.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, MAX_RESULTS);
return scored.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)}
<mark
style={{
background: "transparent",
color: "var(--admin-primary)",
fontWeight: 600,
padding: 0,
}}
>
{text.slice(idx, idx + query.length)}
</mark>
{text.slice(idx + query.length)}
</>
);
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function CommandPalette() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [selected, setSelected] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const router = useRouter();
// 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);
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, []);
// Reset on open, focus the input, and lock body scroll.
useEffect(() => {
if (!open) {
document.body.style.overflow = "";
return;
}
setQuery("");
setSelected(0);
document.body.style.overflow = "hidden";
// Defer focus to next frame so the input is mounted and the modal
// animation has started (keeps the focus ring from flickering in).
const raf = requestAnimationFrame(() => inputRef.current?.focus());
return () => {
cancelAnimationFrame(raf);
document.body.style.overflow = "";
};
}, [open]);
// Global Escape while open (covers the case where focus is not on input).
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault();
setOpen(false);
}
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open]);
// Filtering
const results = useMemo(() => filterEntries(query), [query]);
// Reset selection on every query change so the top result is always active.
useEffect(() => {
setSelected(0);
}, [query]);
// Clamp selection to results length
useEffect(() => {
if (selected >= results.length && results.length > 0) {
setSelected(results.length - 1);
} else if (results.length === 0) {
setSelected(0);
}
}, [results, selected]);
const navigate = useCallback(
(href: string) => {
setOpen(false);
router.push(href);
},
[router]
);
// 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<HTMLInputElement>) => {
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[selected];
if (entry) navigate(entry.href);
}
};
if (!open) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-label="Command palette"
onClick={() => setOpen(false)}
style={{
position: "fixed",
inset: 0,
zIndex: 60,
backgroundColor: "rgba(26, 24, 20, 0.4)",
backdropFilter: "blur(4px)",
WebkitBackdropFilter: "blur(4px)",
display: "flex",
alignItems: "flex-start",
justifyContent: "center",
paddingTop: "15vh",
animation: "cp-fade-in 180ms ease-out",
}}
>
{/* Local keyframes — scoped via the dialog's runtime so they don't
leak into the global stylesheet. */}
<style>{`
@keyframes cp-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes cp-scale-in {
from { opacity: 0; transform: scale(0.98); }
to { opacity: 1; transform: scale(1); }
}
`}</style>
<div
onClick={(e) => e.stopPropagation()}
style={{
width: "100%",
maxWidth: "36rem",
margin: "0 1rem",
backgroundColor: "var(--admin-card-bg)",
border: "1px solid var(--admin-border)",
borderRadius: "var(--admin-radius-lg)",
boxShadow: "var(--admin-shadow-lg)",
overflow: "hidden",
animation: "cp-scale-in 180ms ease-out",
display: "flex",
flexDirection: "column",
}}
>
{/* Search input row */}
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.75rem",
padding: "0.875rem 1rem",
borderBottom: "1px solid var(--admin-border)",
}}
>
<Search
size={18}
style={{ color: "var(--admin-text-muted)", flexShrink: 0 }}
aria-hidden="true"
/>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleInputKeyDown}
placeholder="Search pages, actions, anything…"
aria-label="Search command palette"
spellCheck={false}
autoComplete="off"
style={{
flex: 1,
minWidth: 0,
border: "none",
outline: "none",
background: "transparent",
fontSize: "1rem",
lineHeight: 1.4,
color: "var(--admin-text-primary)",
fontFamily: "inherit",
}}
/>
</div>
{/* Results */}
<div
role="listbox"
aria-label="Results"
style={{
maxHeight: "60vh",
overflowY: "auto",
padding: "0.375rem",
}}
>
{/*
TODO (next pass): recent items
- Persist a small ring buffer of last-visited entries in localStorage
under "rc-cmdk-recent" (cap at 5).
- Read on mount, render as a "Recent" section above "Pages" when
present and the query is empty.
- Update on `navigate()` after a successful route push.
Skipped for v1 per the design spec.
*/}
{results.length === 0 ? (
<div
style={{
padding: "2rem 1rem",
textAlign: "center",
color: "var(--admin-text-muted)",
fontSize: "0.875rem",
}}
>
No results for &ldquo;{query}&rdquo;
</div>
) : (
results.map((entry, i) => {
const Icon = ICON_MAP[entry.iconName] ?? Search;
const isSelected = i === selected;
return (
<button
key={entry.id}
type="button"
role="option"
aria-selected={isSelected}
onClick={() => navigate(entry.href)}
onMouseEnter={() => setSelected(i)}
style={{
display: "flex",
alignItems: "center",
gap: "0.75rem",
width: "100%",
padding: "0.625rem 0.75rem",
borderRadius: "var(--admin-radius-md)",
border: "none",
background: isSelected
? "var(--admin-primary-soft)"
: "transparent",
color: isSelected
? "var(--admin-primary)"
: "var(--admin-text-primary)",
cursor: "pointer",
textAlign: "left",
fontSize: "0.875rem",
lineHeight: 1.3,
fontFamily: "inherit",
transition:
"background-color 80ms ease-out, color 80ms ease-out",
}}
>
<Icon
size={16}
style={{
flexShrink: 0,
opacity: isSelected ? 1 : 0.7,
}}
aria-hidden="true"
/>
<span
style={{
flex: 1,
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontWeight: 500,
}}
>
<HighlightedText text={entry.label} query={query} />
</span>
<span
style={{
fontSize: "0.6875rem",
textTransform: "uppercase",
letterSpacing: "0.05em",
color: "var(--admin-text-muted)",
fontWeight: 500,
flexShrink: 0,
}}
>
{entry.category}
</span>
</button>
);
})
)}
</div>
{/* Footer hint */}
<div
style={{
padding: "0.5rem 1rem 0.625rem",
borderTop: "1px solid var(--admin-border)",
fontSize: "0.6875rem",
color: "var(--admin-text-muted)",
textAlign: "center",
letterSpacing: "0.02em",
userSelect: "none",
}}
>
to navigate &nbsp;·&nbsp; to select &nbsp;·&nbsp; esc to close
</div>
</div>
</div>
);
}
@@ -0,0 +1,402 @@
/**
* Command Palette — static entry list.
*
* Source of truth for the Cmd+K palette. The palette component imports
* `PALETTE_ENTRIES` and filters/scores this list. This file does NOT
* import from the sidebar — keep them independent so the palette
* works even if the sidebar is restructured.
*
* Entries are grouped by the new admin IA (see
* docs/superpowers/specs/2026-06-17-admin-redesign.md §4):
* Workspace · Operations · Communications · Growth ·
* Tracking · Insights · Settings · Public
*
* `type` distinguishes pages (jump to a route) from quick actions
* (a route + query string that opens a creation flow / composer).
*/
export type PaletteEntryType = "page" | "action";
export type PaletteEntry = {
/** Stable id, used as React key. */
id: string;
/** "page" = nav to route, "action" = nav to a creation flow / composer. */
type: PaletteEntryType;
/** Primary text shown in the result row. */
label: string;
/** Route to navigate to when selected. */
href: string;
/** Sidebar group (Pages) or "Quick action" (Actions). */
category: string;
/** Lucide icon name (PascalCase, e.g. "ShoppingCart"). */
iconName: string;
/** Extra search terms (lowercase, comma-separated in code). */
keywords?: string[];
};
export const PALETTE_ENTRIES: PaletteEntry[] = [
// ── Workspace ──────────────────────────────────────────────────────
{
id: "page-dashboard",
type: "page",
label: "Dashboard",
href: "/admin",
category: "Workspace",
iconName: "LayoutDashboard",
keywords: ["home", "overview"],
},
{
id: "page-command-center",
type: "page",
label: "Command Center",
href: "/admin/command-center",
category: "Workspace",
iconName: "Command",
keywords: ["platform", "admin", "ops", "triage"],
},
// ── Operations ─────────────────────────────────────────────────────
{
id: "page-orders",
type: "page",
label: "Orders",
href: "/admin/orders",
category: "Operations",
iconName: "ShoppingCart",
keywords: ["sales", "purchases", "invoices"],
},
{
id: "page-stops",
type: "page",
label: "Stops & Routes",
href: "/admin/stops",
category: "Operations",
iconName: "MapPin",
keywords: ["schedule", "pickup locations", "calendar"],
},
{
id: "page-products",
type: "page",
label: "Products",
href: "/admin/products",
category: "Operations",
iconName: "Package",
keywords: ["catalog", "sku", "inventory"],
},
{
id: "page-pickup",
type: "page",
label: "Driver Pickup",
href: "/admin/pickup",
category: "Operations",
iconName: "Truck",
keywords: ["fulfillment", "driver"],
},
{
id: "page-shipping",
type: "page",
label: "Shipping",
href: "/admin/shipping",
category: "Operations",
iconName: "Send",
keywords: ["shipments", "labels", "fedex"],
},
// ── Communications ─────────────────────────────────────────────────
{
id: "page-communications",
type: "page",
label: "Harvest Reach",
href: "/admin/communications",
category: "Communications",
iconName: "Megaphone",
keywords: ["email", "sms", "campaigns", "templates", "contacts", "blasts"],
},
{
id: "page-communications-templates",
type: "page",
label: "Email Templates",
href: "/admin/communications?tab=templates",
category: "Communications",
iconName: "FileText",
keywords: ["harvest reach", "templates"],
},
{
id: "page-communications-contacts",
type: "page",
label: "Contacts",
href: "/admin/communications?tab=contacts",
category: "Communications",
iconName: "Users",
keywords: ["harvest reach", "audience", "subscribers"],
},
{
id: "page-communications-logs",
type: "page",
label: "Message Logs",
href: "/admin/communications?tab=logs",
category: "Communications",
iconName: "ScrollText",
keywords: ["harvest reach", "history", "delivered"],
},
// ── Growth ─────────────────────────────────────────────────────────
{
id: "page-wholesale",
type: "page",
label: "Wholesale",
href: "/admin/wholesale",
category: "Growth",
iconName: "Store",
keywords: ["b2b", "buyers", "deposits"],
},
{
id: "page-import",
type: "page",
label: "Import Center",
href: "/admin/import",
category: "Growth",
iconName: "Upload",
keywords: ["csv", "bulk", "upload"],
},
{
id: "page-ai",
type: "page",
label: "AI Intelligence",
href: "/admin/advanced",
category: "Growth",
iconName: "Sparkles",
keywords: ["automation", "assistant", "smart", "claude"],
},
// ── Tracking ───────────────────────────────────────────────────────
{
id: "page-time-tracking",
type: "page",
label: "Time Tracking",
href: "/admin/time-tracking",
category: "Tracking",
iconName: "Clock",
keywords: ["workers", "pins", "tasks", "shifts"],
},
{
id: "page-water-log",
type: "page",
label: "Water Log",
href: "/admin/water-log",
category: "Tracking",
iconName: "Droplets",
keywords: ["irrigation", "usage"],
},
{
id: "page-route-trace",
type: "page",
label: "Route Trace",
href: "/admin/route-trace",
category: "Tracking",
iconName: "Route",
keywords: ["trace", "map", "gps"],
},
// ── Insights ───────────────────────────────────────────────────────
{
id: "page-reports",
type: "page",
label: "Reports",
href: "/admin/reports",
category: "Insights",
iconName: "BarChart3",
keywords: ["analytics", "kpi"],
},
{
id: "page-taxes",
type: "page",
label: "Tax Dashboard",
href: "/admin/taxes",
category: "Insights",
iconName: "Receipt",
keywords: ["taxes", "compliance", "1099"],
},
// ── Settings ───────────────────────────────────────────────────────
{
id: "page-settings",
type: "page",
label: "General Settings",
href: "/admin/settings",
category: "Settings",
iconName: "Settings",
keywords: ["config", "preferences"],
},
{
id: "page-settings-brand",
type: "page",
label: "Brand Settings",
href: "/admin/settings/brand",
category: "Settings",
iconName: "Tag",
keywords: ["branding", "logo", "name"],
},
{
id: "page-settings-billing",
type: "page",
label: "Billing",
href: "/admin/settings/billing",
category: "Settings",
iconName: "CreditCard",
keywords: ["subscription", "plan", "invoice", "stripe"],
},
{
id: "page-users",
type: "page",
label: "Users & Permissions",
href: "/admin/users",
category: "Settings",
iconName: "Users",
keywords: ["team", "roles", "admins"],
},
{
id: "page-integrations",
type: "page",
label: "Integrations",
href: "/admin/settings/integrations",
category: "Settings",
iconName: "Plug",
keywords: ["stripe", "square", "resend", "connect"],
},
{
id: "page-payments",
type: "page",
label: "Payments",
href: "/admin/settings/payments",
category: "Settings",
iconName: "Wallet",
keywords: ["processor", "stripe", "square"],
},
{
id: "page-shipping-settings",
type: "page",
label: "Shipping Settings",
href: "/admin/settings/shipping",
category: "Settings",
iconName: "Truck",
keywords: ["zones", "rates", "fedex"],
},
{
id: "page-addons",
type: "page",
label: "Add-ons",
href: "/admin/settings/apps",
category: "Settings",
iconName: "Puzzle",
keywords: ["features", "extensions", "apps"],
},
// ── Public storefronts ─────────────────────────────────────────────
{
id: "page-storefront-tuxedo",
type: "page",
label: "Tuxedo Storefront",
href: "/tuxedo",
category: "Public",
iconName: "Store",
keywords: ["public", "site", "shop"],
},
{
id: "page-storefront-indian-river",
type: "page",
label: "Indian River Direct",
href: "/indian-river-direct",
category: "Public",
iconName: "Store",
keywords: ["public", "site", "shop"],
},
// ── Quick actions ──────────────────────────────────────────────────
{
id: "action-new-order",
type: "action",
label: "Create new order",
href: "/admin/orders?new=true",
category: "Quick action",
iconName: "PlusCircle",
keywords: ["new", "add", "order"],
},
{
id: "action-new-product",
type: "action",
label: "Add new product",
href: "/admin/products/new",
category: "Quick action",
iconName: "PlusCircle",
keywords: ["new", "add", "product", "sku"],
},
{
id: "action-new-stop",
type: "action",
label: "Add new stop",
href: "/admin/stops/new",
category: "Quick action",
iconName: "PlusCircle",
keywords: ["new", "add", "stop", "location"],
},
{
id: "action-send-campaign",
type: "action",
label: "Send campaign",
href: "/admin/communications/compose",
category: "Quick action",
iconName: "Send",
keywords: ["email", "sms", "blast", "harvest reach"],
},
{
id: "action-open-billing",
type: "action",
label: "Open billing",
href: "/admin/settings/billing",
category: "Quick action",
iconName: "CreditCard",
keywords: ["subscription", "plan", "stripe"],
},
{
id: "action-invite-user",
type: "action",
label: "Invite team member",
href: "/admin/users",
category: "Quick action",
iconName: "UserPlus",
keywords: ["user", "team", "admin"],
},
{
id: "action-sync-square",
type: "action",
label: "Sync Square",
href: "/admin/settings/square-sync",
category: "Quick action",
iconName: "RefreshCw",
keywords: ["sync", "square", "inventory"],
},
{
id: "action-generate-report",
type: "action",
label: "Generate report",
href: "/admin/reports",
category: "Quick action",
iconName: "FileText",
keywords: ["report", "export", "analytics"],
},
];
/**
* Map from PaletteEntry.iconName (PascalCase lucide name) to the imported
* component. Centralized here so the palette component doesn't have to
* know about every icon — it just looks up the component by string.
*
* The component imports lucide-react icons lazily (only the ones we
* actually use). Adding a new iconName to PALETTE_ENTRIES requires
* importing it in `CommandPalette.tsx` and adding it to the map here.
*/
export const PALETTE_ICON_NAMES = PALETTE_ENTRIES.reduce<string[]>((acc, e) => {
if (!acc.includes(e.iconName)) acc.push(e.iconName);
return acc;
}, []);