feat: MVP-5 Sprint 2 - Command Palette and Global Shortcuts
- Add Command Palette (Ctrl+K / Cmd+K) with search and navigation - Add global keyboard shortcuts: g h (dashboard), g n (notes), n (new note) - Add keyboard shortcuts help dialog (?) - Add shortcuts provider component - Shortcuts ignore inputs/textareas for proper UX
This commit is contained in:
@@ -2,6 +2,8 @@ import type { Metadata } from 'next'
|
||||
import './globals.css'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import { Header } from '@/components/header'
|
||||
import { CommandPalette } from '@/components/command-palette'
|
||||
import { ShortcutsProvider } from '@/components/shortcuts-provider'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Recall - Gestor de Conocimiento Personal',
|
||||
@@ -19,6 +21,8 @@ export default function RootLayout({
|
||||
<Header />
|
||||
{children}
|
||||
<Toaster />
|
||||
<CommandPalette />
|
||||
<ShortcutsProvider />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
79
src/components/command-palette.tsx
Normal file
79
src/components/command-palette.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
'use client'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { commands, CommandItem } from '@/lib/command-items'
|
||||
import { Search, FileText, Settings, Home, Plus } from 'lucide-react'
|
||||
|
||||
export function CommandPalette() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const router = useRouter()
|
||||
|
||||
// Listen for Ctrl+K / Cmd+K
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
setOpen(true)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [])
|
||||
|
||||
// Filter commands by query
|
||||
const filtered = commands.filter(cmd =>
|
||||
cmd.label.toLowerCase().includes(query.toLowerCase()) ||
|
||||
cmd.keywords?.some(k => k.includes(query.toLowerCase()))
|
||||
)
|
||||
|
||||
// Keyboard navigation
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
if (e.key === 'ArrowDown') setSelectedIndex(i => Math.min(i + 1, filtered.length - 1))
|
||||
if (e.key === 'ArrowUp') setSelectedIndex(i => Math.max(i - 1, 0))
|
||||
if (e.key === 'Enter' && filtered[selectedIndex]) {
|
||||
executeCommand(filtered[selectedIndex])
|
||||
}
|
||||
}
|
||||
|
||||
// Execute command
|
||||
const executeCommand = (cmd: CommandItem) => {
|
||||
router.push(cmd.id === 'action-new' ? '/new' : `/${cmd.id.split('-')[1]}`)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-md p-0 gap-0">
|
||||
<div className="flex items-center border-b px-3">
|
||||
<Search className="h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Buscar comandos..."
|
||||
className="border-0 focus-visible:ring-0"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{filtered.map((cmd, i) => (
|
||||
<button
|
||||
key={cmd.id}
|
||||
onClick={() => executeCommand(cmd)}
|
||||
className={`w-full px-3 py-2 text-left flex items-center gap-2 ${
|
||||
i === selectedIndex ? 'bg-muted' : 'hover:bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm">{cmd.label}</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto">{cmd.group}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
39
src/components/keyboard-shortcuts-dialog.tsx
Normal file
39
src/components/keyboard-shortcuts-dialog.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
'use client'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Keyboard } from 'lucide-react'
|
||||
|
||||
const shortcuts = [
|
||||
{ keys: ['n'], description: 'Nueva nota' },
|
||||
{ keys: ['g', 'h'], description: 'Ir al Dashboard' },
|
||||
{ keys: ['g', 'n'], description: 'Ir a Notas' },
|
||||
{ keys: ['/'], description: 'Enfocar búsqueda' },
|
||||
{ keys: ['?'], description: 'Mostrar atajos' },
|
||||
{ keys: ['Ctrl', 'K'], description: 'Command Palette' },
|
||||
]
|
||||
|
||||
export function KeyboardShortcutsDialog({ open, onOpenChange }: { open: boolean, onOpenChange: (o: boolean) => void }) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Keyboard className="h-5 w-5" />
|
||||
Atajos de teclado
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="divide-y">
|
||||
{shortcuts.map((s) => (
|
||||
<div key={s.description} className="flex justify-between py-2">
|
||||
<span className="text-sm">{s.description}</span>
|
||||
<div className="flex gap-1">
|
||||
{s.keys.map((k) => (
|
||||
<kbd key={k} className="px-2 py-1 bg-muted rounded text-xs">{k}</kbd>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
12
src/components/shortcuts-provider.tsx
Normal file
12
src/components/shortcuts-provider.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useGlobalShortcuts } from '@/hooks/use-global-shortcuts'
|
||||
import { KeyboardShortcutsDialog } from '@/components/keyboard-shortcuts-dialog'
|
||||
|
||||
export function ShortcutsProvider() {
|
||||
const { showHelp, setShowHelp } = useGlobalShortcuts()
|
||||
|
||||
return (
|
||||
<KeyboardShortcutsDialog open={showHelp} onOpenChange={setShowHelp} />
|
||||
)
|
||||
}
|
||||
65
src/hooks/use-global-shortcuts.ts
Normal file
65
src/hooks/use-global-shortcuts.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
const SHORTCUTS: Record<string, string> = {
|
||||
'g h': '/', // go to dashboard
|
||||
'g n': '/notes', // go to notes
|
||||
'n': '/new', // new note
|
||||
'/': '/notes', // focus search (navigate to notes)
|
||||
'?': 'show-help', // show shortcuts dialog
|
||||
}
|
||||
|
||||
export function useGlobalShortcuts() {
|
||||
const router = useRouter()
|
||||
const [showHelp, setShowHelp] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let keystroke = ''
|
||||
let timeout: NodeJS.Timeout
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Ignore if in input/textarea/contenteditable
|
||||
const target = e.target as HTMLElement
|
||||
if (target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.isContentEditable) {
|
||||
return
|
||||
}
|
||||
|
||||
// Handle ? for help
|
||||
if (e.key === '?' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
setShowHelp(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Track g + key combos
|
||||
if (e.key === 'g' && !e.metaKey && !e.ctrlKey) {
|
||||
keystroke = 'g'
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(() => { keystroke = '' }, 500)
|
||||
return
|
||||
}
|
||||
|
||||
if (keystroke === 'g') {
|
||||
const combo = 'g ' + e.key
|
||||
if (SHORTCUTS[combo]) {
|
||||
e.preventDefault()
|
||||
router.push(SHORTCUTS[combo])
|
||||
keystroke = ''
|
||||
}
|
||||
}
|
||||
|
||||
// Direct shortcuts
|
||||
if (e.key === 'n' && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault()
|
||||
router.push('/new')
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [router])
|
||||
|
||||
return { showHelp, setShowHelp }
|
||||
}
|
||||
16
src/lib/command-items.ts
Normal file
16
src/lib/command-items.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export interface CommandItem {
|
||||
id: string
|
||||
label: string
|
||||
description?: string
|
||||
group: 'navigation' | 'actions' | 'search' | 'recent'
|
||||
keywords?: string[]
|
||||
action?: () => void
|
||||
icon?: string
|
||||
}
|
||||
|
||||
export const commands: CommandItem[] = [
|
||||
{ id: 'nav-dashboard', label: 'Ir al Dashboard', group: 'navigation', keywords: ['home'] },
|
||||
{ id: 'nav-notes', label: 'Ir a Notas', group: 'navigation', keywords: ['all notes'] },
|
||||
{ id: 'nav-settings', label: 'Ir a Configuración', group: 'navigation', keywords: ['settings'] },
|
||||
{ id: 'action-new', label: 'Crear nueva nota', group: 'actions', keywords: ['new note', 'create'] },
|
||||
]
|
||||
Reference in New Issue
Block a user