From cde0a143a530017e7cba43f3a769bca0b1fc10d1 Mon Sep 17 00:00:00 2001 From: Daniel Arroyo Date: Sun, 22 Mar 2026 18:22:28 -0300 Subject: [PATCH] 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 --- src/app/layout.tsx | 4 + src/components/command-palette.tsx | 79 ++++++++++++++++++++ src/components/keyboard-shortcuts-dialog.tsx | 39 ++++++++++ src/components/shortcuts-provider.tsx | 12 +++ src/hooks/use-global-shortcuts.ts | 65 ++++++++++++++++ src/lib/command-items.ts | 16 ++++ 6 files changed, 215 insertions(+) create mode 100644 src/components/command-palette.tsx create mode 100644 src/components/keyboard-shortcuts-dialog.tsx create mode 100644 src/components/shortcuts-provider.tsx create mode 100644 src/hooks/use-global-shortcuts.ts create mode 100644 src/lib/command-items.ts diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 4789d57..97b40f3 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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({
{children} + + ) diff --git a/src/components/command-palette.tsx b/src/components/command-palette.tsx new file mode 100644 index 0000000..cbc79c2 --- /dev/null +++ b/src/components/command-palette.tsx @@ -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 ( + + +
+ + setQuery(e.target.value)} + onKeyDown={handleKeyDown} + /> +
+
+ {filtered.map((cmd, i) => ( + + ))} +
+
+
+ ) +} diff --git a/src/components/keyboard-shortcuts-dialog.tsx b/src/components/keyboard-shortcuts-dialog.tsx new file mode 100644 index 0000000..11fb4a7 --- /dev/null +++ b/src/components/keyboard-shortcuts-dialog.tsx @@ -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 ( + + + + + + Atajos de teclado + + +
+ {shortcuts.map((s) => ( +
+ {s.description} +
+ {s.keys.map((k) => ( + {k} + ))} +
+
+ ))} +
+
+
+ ) +} diff --git a/src/components/shortcuts-provider.tsx b/src/components/shortcuts-provider.tsx new file mode 100644 index 0000000..30985d5 --- /dev/null +++ b/src/components/shortcuts-provider.tsx @@ -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 ( + + ) +} diff --git a/src/hooks/use-global-shortcuts.ts b/src/hooks/use-global-shortcuts.ts new file mode 100644 index 0000000..1916479 --- /dev/null +++ b/src/hooks/use-global-shortcuts.ts @@ -0,0 +1,65 @@ +import { useEffect, useState } from 'react' +import { useRouter } from 'next/navigation' + +const SHORTCUTS: Record = { + '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 } +} diff --git a/src/lib/command-items.ts b/src/lib/command-items.ts new file mode 100644 index 0000000..1e79eb2 --- /dev/null +++ b/src/lib/command-items.ts @@ -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'] }, +]