feat: Phase 4 - Command Palette (Ctrl+K) for quick navigation and actions
Browse files
src/components/CommandPalette.tsx
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { motion, AnimatePresence } from 'motion/react';
|
| 2 |
+
import { Search, Home, Folder, LayoutGrid, Download, ListOrdered, Settings, Timer, Palette, Globe } from 'lucide-react';
|
| 3 |
+
import { useState, useEffect, useRef } from 'react';
|
| 4 |
+
import { cn } from '../../lib/utils';
|
| 5 |
+
|
| 6 |
+
interface CommandItem { id: string; label: string; icon: React.ReactNode; action: () => void; section?: string; keywords?: string; }
|
| 7 |
+
|
| 8 |
+
export function CommandPalette({ open, onClose, onNavigate }: { open: boolean; onClose: () => void; onNavigate: (view: string) => void }) {
|
| 9 |
+
const [query, setQuery] = useState('');
|
| 10 |
+
const [selectedIdx, setSelectedIdx] = useState(0);
|
| 11 |
+
const inputRef = useRef<HTMLInputElement>(null);
|
| 12 |
+
|
| 13 |
+
const commands: CommandItem[] = [
|
| 14 |
+
{ id: 'home', label: 'Go to Home', icon: <Home className="w-4 h-4" />, action: () => onNavigate('home'), section: 'Navigate', keywords: 'home dashboard start' },
|
| 15 |
+
{ id: 'web', label: 'Open Browser', icon: <Globe className="w-4 h-4" />, action: () => onNavigate('web'), section: 'Navigate', keywords: 'browser web tab browse' },
|
| 16 |
+
{ id: 'library', label: 'Open Library', icon: <Folder className="w-4 h-4" />, action: () => onNavigate('library'), section: 'Navigate', keywords: 'library references images' },
|
| 17 |
+
{ id: 'board', label: 'Open Board', icon: <LayoutGrid className="w-4 h-4" />, action: () => onNavigate('board'), section: 'Navigate', keywords: 'board canvas pureref reference' },
|
| 18 |
+
{ id: 'study', label: 'Quick Study Mode', icon: <Timer className="w-4 h-4" />, action: () => onNavigate('study'), section: 'Navigate', keywords: 'study timer practice draw' },
|
| 19 |
+
{ id: 'downloads', label: 'Downloads', icon: <Download className="w-4 h-4" />, action: () => onNavigate('downloads'), section: 'Navigate', keywords: 'downloads files' },
|
| 20 |
+
{ id: 'sessions', label: 'Sessions', icon: <ListOrdered className="w-4 h-4" />, action: () => onNavigate('sessions'), section: 'Navigate', keywords: 'sessions tabs workspace' },
|
| 21 |
+
{ id: 'settings', label: 'Settings', icon: <Settings className="w-4 h-4" />, action: () => onNavigate('settings'), section: 'Navigate', keywords: 'settings preferences theme' },
|
| 22 |
+
{ id: 'palette', label: 'Color Tools', icon: <Palette className="w-4 h-4" />, action: () => onNavigate('colors'), section: 'Tools', keywords: 'color palette export extract' },
|
| 23 |
+
];
|
| 24 |
+
|
| 25 |
+
const filtered = query.trim() ? commands.filter(c => c.label.toLowerCase().includes(query.toLowerCase()) || c.keywords?.toLowerCase().includes(query.toLowerCase())) : commands;
|
| 26 |
+
|
| 27 |
+
useEffect(() => { if (open) { setQuery(''); setSelectedIdx(0); setTimeout(() => inputRef.current?.focus(), 50); } }, [open]);
|
| 28 |
+
useEffect(() => { setSelectedIdx(0); }, [query]);
|
| 29 |
+
|
| 30 |
+
useEffect(() => {
|
| 31 |
+
if (!open) return;
|
| 32 |
+
const handler = (e: KeyboardEvent) => {
|
| 33 |
+
if (e.key === 'Escape') { onClose(); return; }
|
| 34 |
+
if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIdx(i => Math.min(i + 1, filtered.length - 1)); }
|
| 35 |
+
if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIdx(i => Math.max(i - 1, 0)); }
|
| 36 |
+
if (e.key === 'Enter' && filtered[selectedIdx]) { filtered[selectedIdx].action(); onClose(); }
|
| 37 |
+
};
|
| 38 |
+
window.addEventListener('keydown', handler);
|
| 39 |
+
return () => window.removeEventListener('keydown', handler);
|
| 40 |
+
}, [open, filtered, selectedIdx, onClose]);
|
| 41 |
+
|
| 42 |
+
if (!open) return null;
|
| 43 |
+
|
| 44 |
+
return (
|
| 45 |
+
<AnimatePresence>
|
| 46 |
+
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 z-[200] flex items-start justify-center pt-[15vh] bg-black/40 backdrop-blur-sm" onClick={onClose}>
|
| 47 |
+
<motion.div initial={{ opacity: 0, y: -20, scale: 0.95 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: -20, scale: 0.95 }} transition={{ duration: 0.15 }} onClick={e => e.stopPropagation()} className="w-[520px] bg-dusk-surface border border-dusk-border rounded-2xl shadow-2xl overflow-hidden">
|
| 48 |
+
{/* Search Input */}
|
| 49 |
+
<div className="flex items-center gap-3 px-5 h-14 border-b border-dusk-border/60">
|
| 50 |
+
<Search className="w-5 h-5 text-dusk-text-muted shrink-0" />
|
| 51 |
+
<input ref={inputRef} value={query} onChange={e => setQuery(e.target.value)} placeholder="Type a command or search..." className="flex-1 bg-transparent text-[15px] text-dusk-text placeholder:text-dusk-text-muted/60 outline-none" autoFocus />
|
| 52 |
+
<kbd className="text-[10px] font-bold text-dusk-text-muted bg-dusk-bg px-2 py-0.5 rounded border border-dusk-border">ESC</kbd>
|
| 53 |
+
</div>
|
| 54 |
+
{/* Results */}
|
| 55 |
+
<div className="max-h-[360px] overflow-auto py-2">
|
| 56 |
+
{filtered.length === 0 && <div className="text-center text-dusk-text-muted py-8 text-sm">No matching commands</div>}
|
| 57 |
+
{filtered.map((cmd, idx) => (
|
| 58 |
+
<button key={cmd.id} onClick={() => { cmd.action(); onClose(); }} onMouseEnter={() => setSelectedIdx(idx)} className={cn('w-full flex items-center gap-3 px-5 py-2.5 text-left transition-colors', idx === selectedIdx ? 'bg-dusk-accent/10 text-dusk-accent' : 'text-dusk-text hover:bg-dusk-surface-hover')}>
|
| 59 |
+
<span className={cn('shrink-0', idx === selectedIdx ? 'text-dusk-accent' : 'text-dusk-text-muted')}>{cmd.icon}</span>
|
| 60 |
+
<span className="text-[14px] font-medium">{cmd.label}</span>
|
| 61 |
+
{cmd.section && <span className="ml-auto text-[11px] text-dusk-text-muted">{cmd.section}</span>}
|
| 62 |
+
</button>
|
| 63 |
+
))}
|
| 64 |
+
</div>
|
| 65 |
+
</motion.div>
|
| 66 |
+
</motion.div>
|
| 67 |
+
</AnimatePresence>
|
| 68 |
+
);
|
| 69 |
+
}
|