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:
2026-03-22 18:22:28 -03:00
parent 8c80a12b81
commit cde0a143a5
6 changed files with 215 additions and 0 deletions

View File

@@ -2,6 +2,8 @@ import type { Metadata } from 'next'
import './globals.css' import './globals.css'
import { Toaster } from '@/components/ui/sonner' import { Toaster } from '@/components/ui/sonner'
import { Header } from '@/components/header' import { Header } from '@/components/header'
import { CommandPalette } from '@/components/command-palette'
import { ShortcutsProvider } from '@/components/shortcuts-provider'
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Recall - Gestor de Conocimiento Personal', title: 'Recall - Gestor de Conocimiento Personal',
@@ -19,6 +21,8 @@ export default function RootLayout({
<Header /> <Header />
{children} {children}
<Toaster /> <Toaster />
<CommandPalette />
<ShortcutsProvider />
</body> </body>
</html> </html>
) )

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

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

View 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} />
)
}

View 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
View 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'] },
]