feat: MVP-4 Sprint 1 - Query parser, real-time search, keyboard navigation

- Add query-parser.ts with AST for type:, tag:, is:favorite, is:pinned filters
- Integrate parser into search API
- Add real-time search with 300ms debounce in search-bar
- Add keyboard navigation (↑↓ Enter ESC) for Spotlight-like UX
- Add dropdown results display with loading state
- Add 22 tests for query parser

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-22 17:33:17 -03:00
parent d5c418c84f
commit 9af25927b7
7 changed files with 650 additions and 28 deletions

View File

@@ -1,34 +1,178 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, useRef } from 'react'
import { useRouter } from 'next/navigation'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Search } from 'lucide-react'
import { Search, Loader2 } from 'lucide-react'
import { ScoredNote } from '@/lib/search'
export function SearchBar() {
const [query, setQuery] = useState('')
const [results, setResults] = useState<ScoredNote[]>([])
const [isLoading, setIsLoading] = useState(false)
const [isOpen, setIsOpen] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(-1)
const router = useRouter()
const inputRef = useRef<HTMLInputElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const debounceRef = useRef<NodeJS.Timeout | null>(null)
// Debounced search
useEffect(() => {
if (!query.trim()) {
setResults([])
setIsOpen(false)
return
}
if (debounceRef.current) {
clearTimeout(debounceRef.current)
}
debounceRef.current = setTimeout(async () => {
setIsLoading(true)
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`)
const json = await res.json()
if (json.success) {
setResults(json.data.slice(0, 8))
setIsOpen(true)
setSelectedIndex(-1)
}
} catch (error) {
console.error('Search failed:', error)
} finally {
setIsLoading(false)
}
}, 300)
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current)
}
}
}, [query])
// Click outside to close
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
inputRef.current &&
!inputRef.current.contains(event.target as Node)
) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
if (query.trim()) {
router.push(`/notes?q=${encodeURIComponent(query)}`)
setIsOpen(false)
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!isOpen || results.length === 0) return
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setSelectedIndex((prev) => (prev < results.length - 1 ? prev + 1 : prev))
break
case 'ArrowUp':
e.preventDefault()
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1))
break
case 'Enter':
e.preventDefault()
if (selectedIndex >= 0 && results[selectedIndex]) {
router.push(`/notes/${results[selectedIndex].id}`)
setIsOpen(false)
setQuery('')
} else {
handleSearch(e)
}
break
case 'Escape':
e.preventDefault()
setIsOpen(false)
setSelectedIndex(-1)
break
}
}
const handleResultClick = (note: ScoredNote) => {
router.push(`/notes/${note.id}`)
setIsOpen(false)
setQuery('')
}
return (
<form onSubmit={handleSearch} className="flex gap-2 w-full">
<Input
type="text"
placeholder="Buscar notas..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="flex-1 min-w-0"
/>
<Button type="submit" variant="secondary" size="icon">
<Search className="h-4 w-4" />
</Button>
</form>
<div className="relative w-full">
<form onSubmit={handleSearch} className="flex gap-2 w-full">
<Input
ref={inputRef}
type="text"
placeholder="Buscar notas..."
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
className="flex-1 min-w-0"
/>
<Button type="submit" variant="secondary" size="icon" disabled={isLoading}>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Search className="h-4 w-4" />
)}
</Button>
</form>
{isOpen && results.length > 0 && (
<div
ref={dropdownRef}
className="absolute top-full left-0 right-0 mt-1 bg-background border rounded-md shadow-lg z-50 max-h-96 overflow-y-auto"
>
{results.map((note, index) => (
<button
key={note.id}
onClick={() => handleResultClick(note)}
className={`w-full px-3 py-2 text-left flex items-center justify-between gap-2 transition-colors ${
index === selectedIndex ? 'bg-muted' : 'hover:bg-muted/50'
}`}
>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{note.title}</div>
{note.highlight && (
<div
className="text-sm text-muted-foreground truncate"
dangerouslySetInnerHTML={{ __html: note.highlight }}
/>
)}
</div>
<span
className={`text-xs px-2 py-0.5 rounded ${
note.type === 'document'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
: note.type === 'task'
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'
}`}
>
{note.type}
</span>
</button>
))}
</div>
)}
</div>
)
}
}