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 './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>
|
||||||
)
|
)
|
||||||
|
|||||||
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