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