|
| 1 | +'use client' |
| 2 | + |
| 3 | +import { useState, useEffect, useRef, useCallback } from 'react' |
| 4 | +import { DEFAULT_HANZO_APPS, type HanzoApp } from './types' |
| 5 | + |
| 6 | +// --------------------------------------------------------------------------- |
| 7 | +// Types |
| 8 | +// --------------------------------------------------------------------------- |
| 9 | + |
| 10 | +export type CommandItem = { |
| 11 | + id: string |
| 12 | + title: string |
| 13 | + description?: string |
| 14 | + href?: string |
| 15 | + action?: () => void |
| 16 | + icon?: React.ReactNode |
| 17 | + category: string |
| 18 | + external?: boolean |
| 19 | + keywords?: string[] |
| 20 | +} |
| 21 | + |
| 22 | +export type HanzoCommandPaletteProps = { |
| 23 | + /** Additional app-specific commands merged with built-in cross-app commands */ |
| 24 | + commands?: CommandItem[] |
| 25 | + /** Override the default Hanzo apps used for cross-app navigation */ |
| 26 | + apps?: HanzoApp[] |
| 27 | + /** Current app id — used to exclude from cross-app navigation */ |
| 28 | + currentAppId?: string |
| 29 | + /** Controlled open state */ |
| 30 | + open?: boolean |
| 31 | + /** Called when palette wants to close */ |
| 32 | + onOpenChange?: (open: boolean) => void |
| 33 | + /** Custom navigation handler (defaults to window.location for external, history push for relative) */ |
| 34 | + onNavigate?: (href: string, external?: boolean) => void |
| 35 | +} |
| 36 | + |
| 37 | +// --------------------------------------------------------------------------- |
| 38 | +// Built-in cross-app commands derived from DEFAULT_HANZO_APPS |
| 39 | +// --------------------------------------------------------------------------- |
| 40 | + |
| 41 | +function buildCrossAppCommands(apps: HanzoApp[], currentAppId?: string): CommandItem[] { |
| 42 | + return apps |
| 43 | + .filter((app) => app.id !== currentAppId) |
| 44 | + .map((app) => ({ |
| 45 | + id: `app-${app.id}`, |
| 46 | + title: app.label, |
| 47 | + description: app.description, |
| 48 | + href: app.href, |
| 49 | + category: 'Hanzo Apps', |
| 50 | + external: true, |
| 51 | + keywords: [app.id, app.label.toLowerCase()], |
| 52 | + })) |
| 53 | +} |
| 54 | + |
| 55 | +// --------------------------------------------------------------------------- |
| 56 | +// Component |
| 57 | +// --------------------------------------------------------------------------- |
| 58 | + |
| 59 | +export function HanzoCommandPalette({ |
| 60 | + commands: appCommands = [], |
| 61 | + apps, |
| 62 | + currentAppId, |
| 63 | + open: controlledOpen, |
| 64 | + onOpenChange, |
| 65 | + onNavigate, |
| 66 | +}: HanzoCommandPaletteProps) { |
| 67 | + const [internalOpen, setInternalOpen] = useState(false) |
| 68 | + const open = controlledOpen ?? internalOpen |
| 69 | + const setOpen = useCallback( |
| 70 | + (v: boolean) => { |
| 71 | + if (onOpenChange) onOpenChange(v) |
| 72 | + else setInternalOpen(v) |
| 73 | + }, |
| 74 | + [onOpenChange], |
| 75 | + ) |
| 76 | + |
| 77 | + const [search, setSearch] = useState('') |
| 78 | + const [selectedIndex, setSelectedIndex] = useState(0) |
| 79 | + const inputRef = useRef<HTMLInputElement>(null) |
| 80 | + const listRef = useRef<HTMLDivElement>(null) |
| 81 | + |
| 82 | + // Merge cross-app + app-specific commands |
| 83 | + const crossApp = buildCrossAppCommands(apps ?? DEFAULT_HANZO_APPS, currentAppId) |
| 84 | + const allCommands = [...appCommands, ...crossApp] |
| 85 | + |
| 86 | + // Filter |
| 87 | + const q = search.toLowerCase() |
| 88 | + const filtered = q |
| 89 | + ? allCommands.filter( |
| 90 | + (cmd) => |
| 91 | + cmd.title.toLowerCase().includes(q) || |
| 92 | + cmd.description?.toLowerCase().includes(q) || |
| 93 | + cmd.keywords?.some((k) => k.includes(q)), |
| 94 | + ) |
| 95 | + : allCommands |
| 96 | + |
| 97 | + // Group by category |
| 98 | + const grouped: Record<string, CommandItem[]> = {} |
| 99 | + for (const cmd of filtered) { |
| 100 | + ;(grouped[cmd.category] ??= []).push(cmd) |
| 101 | + } |
| 102 | + const flat = Object.values(grouped).flat() |
| 103 | + |
| 104 | + // Reset on search change |
| 105 | + useEffect(() => setSelectedIndex(0), [search]) |
| 106 | + |
| 107 | + // Focus on open |
| 108 | + useEffect(() => { |
| 109 | + if (open) { |
| 110 | + setSearch('') |
| 111 | + setSelectedIndex(0) |
| 112 | + requestAnimationFrame(() => inputRef.current?.focus()) |
| 113 | + } |
| 114 | + }, [open]) |
| 115 | + |
| 116 | + // Global Cmd+K |
| 117 | + useEffect(() => { |
| 118 | + const handler = (e: KeyboardEvent) => { |
| 119 | + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { |
| 120 | + e.preventDefault() |
| 121 | + setOpen(!open) |
| 122 | + } |
| 123 | + } |
| 124 | + document.addEventListener('keydown', handler) |
| 125 | + return () => document.removeEventListener('keydown', handler) |
| 126 | + }, [open, setOpen]) |
| 127 | + |
| 128 | + // Navigate helper |
| 129 | + const go = useCallback( |
| 130 | + (cmd: CommandItem) => { |
| 131 | + if (cmd.action) { |
| 132 | + cmd.action() |
| 133 | + } else if (cmd.href) { |
| 134 | + if (onNavigate) { |
| 135 | + onNavigate(cmd.href, cmd.external) |
| 136 | + } else if (cmd.external) { |
| 137 | + window.open(cmd.href, '_blank') |
| 138 | + } else { |
| 139 | + window.location.href = cmd.href |
| 140 | + } |
| 141 | + } |
| 142 | + setOpen(false) |
| 143 | + }, |
| 144 | + [onNavigate, setOpen], |
| 145 | + ) |
| 146 | + |
| 147 | + // Keyboard nav |
| 148 | + const handleKeyDown = useCallback( |
| 149 | + (e: React.KeyboardEvent) => { |
| 150 | + if (e.key === 'ArrowDown') { |
| 151 | + e.preventDefault() |
| 152 | + setSelectedIndex((i) => (i + 1) % (flat.length || 1)) |
| 153 | + } else if (e.key === 'ArrowUp') { |
| 154 | + e.preventDefault() |
| 155 | + setSelectedIndex((i) => (i - 1 + (flat.length || 1)) % (flat.length || 1)) |
| 156 | + } else if (e.key === 'Enter' && flat[selectedIndex]) { |
| 157 | + e.preventDefault() |
| 158 | + go(flat[selectedIndex]) |
| 159 | + } else if (e.key === 'Escape') { |
| 160 | + setOpen(false) |
| 161 | + } |
| 162 | + }, |
| 163 | + [flat, selectedIndex, go, setOpen], |
| 164 | + ) |
| 165 | + |
| 166 | + // Scroll selected into view |
| 167 | + useEffect(() => { |
| 168 | + const el = listRef.current?.querySelector(`[data-idx="${selectedIndex}"]`) |
| 169 | + el?.scrollIntoView({ block: 'nearest' }) |
| 170 | + }, [selectedIndex]) |
| 171 | + |
| 172 | + if (!open) return null |
| 173 | + |
| 174 | + return ( |
| 175 | + <> |
| 176 | + {/* Backdrop */} |
| 177 | + <div |
| 178 | + className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100]" |
| 179 | + onClick={() => setOpen(false)} |
| 180 | + /> |
| 181 | + |
| 182 | + {/* Palette */} |
| 183 | + <div className="fixed top-[15%] left-1/2 -translate-x-1/2 w-full max-w-xl z-[101]"> |
| 184 | + <div className="bg-[#111113] border border-white/[0.08] rounded-xl shadow-2xl overflow-hidden"> |
| 185 | + {/* Search */} |
| 186 | + <div className="flex items-center gap-3 px-4 py-3 border-b border-white/[0.07]"> |
| 187 | + <svg |
| 188 | + className="w-4 h-4 text-white/30" |
| 189 | + fill="none" |
| 190 | + viewBox="0 0 24 24" |
| 191 | + stroke="currentColor" |
| 192 | + strokeWidth={2} |
| 193 | + > |
| 194 | + <path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> |
| 195 | + </svg> |
| 196 | + <input |
| 197 | + ref={inputRef} |
| 198 | + type="text" |
| 199 | + value={search} |
| 200 | + onChange={(e) => setSearch(e.target.value)} |
| 201 | + onKeyDown={handleKeyDown} |
| 202 | + placeholder="Search commands..." |
| 203 | + className="flex-1 bg-transparent text-white text-[13px] placeholder-white/30 outline-none" |
| 204 | + /> |
| 205 | + <kbd className="px-1.5 py-0.5 text-[10px] font-mono bg-white/[0.06] rounded text-white/30"> |
| 206 | + ESC |
| 207 | + </kbd> |
| 208 | + </div> |
| 209 | + |
| 210 | + {/* Results */} |
| 211 | + <div ref={listRef} className="max-h-[400px] overflow-y-auto py-1"> |
| 212 | + {flat.length === 0 ? ( |
| 213 | + <div className="px-4 py-8 text-center text-white/30 text-[13px]"> |
| 214 | + No results for “{search}” |
| 215 | + </div> |
| 216 | + ) : ( |
| 217 | + Object.entries(grouped).map(([category, items]) => ( |
| 218 | + <div key={category}> |
| 219 | + <div className="px-4 py-2 text-[10px] font-semibold text-white/25 uppercase tracking-widest"> |
| 220 | + {category} |
| 221 | + </div> |
| 222 | + {items.map((cmd) => { |
| 223 | + const idx = flat.indexOf(cmd) |
| 224 | + const selected = idx === selectedIndex |
| 225 | + return ( |
| 226 | + <button |
| 227 | + key={cmd.id} |
| 228 | + data-idx={idx} |
| 229 | + onClick={() => go(cmd)} |
| 230 | + onMouseEnter={() => setSelectedIndex(idx)} |
| 231 | + className={`w-full flex items-center gap-3 px-4 py-2 text-left transition-colors ${ |
| 232 | + selected ? 'bg-white/[0.06] text-white' : 'text-white/50 hover:bg-white/[0.03]' |
| 233 | + }`} |
| 234 | + > |
| 235 | + {cmd.icon && ( |
| 236 | + <span className={`w-5 h-5 flex items-center justify-center ${selected ? 'text-white/70' : 'text-white/25'}`}> |
| 237 | + {cmd.icon} |
| 238 | + </span> |
| 239 | + )} |
| 240 | + <div className="flex-1 min-w-0"> |
| 241 | + <span className="text-[13px] font-medium truncate block">{cmd.title}</span> |
| 242 | + {cmd.description && ( |
| 243 | + <span className="text-[11px] text-white/25 truncate block">{cmd.description}</span> |
| 244 | + )} |
| 245 | + </div> |
| 246 | + {selected && ( |
| 247 | + <span className="text-white/25 text-[11px]">↵</span> |
| 248 | + )} |
| 249 | + </button> |
| 250 | + ) |
| 251 | + })} |
| 252 | + </div> |
| 253 | + )) |
| 254 | + )} |
| 255 | + </div> |
| 256 | + |
| 257 | + {/* Footer */} |
| 258 | + <div className="px-4 py-2 border-t border-white/[0.07] flex items-center justify-between"> |
| 259 | + <div className="flex items-center gap-4 text-[10px] text-white/20"> |
| 260 | + <span className="flex items-center gap-1"> |
| 261 | + <kbd className="px-1 py-0.5 bg-white/[0.06] rounded text-[9px]">↑</kbd> |
| 262 | + <kbd className="px-1 py-0.5 bg-white/[0.06] rounded text-[9px]">↓</kbd> |
| 263 | + navigate |
| 264 | + </span> |
| 265 | + <span className="flex items-center gap-1"> |
| 266 | + <kbd className="px-1 py-0.5 bg-white/[0.06] rounded text-[9px]">↵</kbd> |
| 267 | + select |
| 268 | + </span> |
| 269 | + </div> |
| 270 | + <span className="text-[10px] text-white/20">⌘K</span> |
| 271 | + </div> |
| 272 | + </div> |
| 273 | + </div> |
| 274 | + </> |
| 275 | + ) |
| 276 | +} |
0 commit comments