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

@@ -2,7 +2,7 @@ import { NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
import { noteSchema } from '@/lib/validators'
import { normalizeTag } from '@/lib/tags'
import { noteQuery } from '@/lib/search'
import { searchNotes } from '@/lib/search'
import { syncBacklinks } from '@/lib/backlinks'
import { createErrorResponse, createSuccessResponse, ValidationError } from '@/lib/errors'
@@ -14,7 +14,7 @@ export async function GET(req: NextRequest) {
const tag = searchParams.get('tag') || undefined
if (q || type || tag) {
const notes = await noteQuery(q, { type, tag })
const notes = await searchNotes(q, { type, tag })
return createSuccessResponse(notes)
}

View File

@@ -1,15 +1,15 @@
import { NextRequest } from 'next/server'
import { searchNotes } from '@/lib/search'
import { parseQuery } from '@/lib/query-parser'
import { createErrorResponse, createSuccessResponse } from '@/lib/errors'
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url)
const q = searchParams.get('q') || ''
const type = searchParams.get('type') || undefined
const tag = searchParams.get('tag') || undefined
const notes = await searchNotes(q, { type, tag })
const queryAST = parseQuery(q)
const notes = await searchNotes(queryAST.text, queryAST.filters)
return createSuccessResponse(notes)
} catch (error) {

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

61
src/lib/query-parser.ts Normal file
View File

@@ -0,0 +1,61 @@
export interface QueryAST {
text: string
filters: {
type?: string
tag?: string
isFavorite?: boolean
isPinned?: boolean
}
}
const FILTER_REGEX = /(tag|type):(\S*)/gi
const IS_FAVORITE_REGEX = /\bis:favorite\b/gi
const IS_PINNED_REGEX = /\bis:pinned\b/gi
export function parseQuery(query: string): QueryAST {
if (!query || typeof query !== 'string') {
return { text: '', filters: {} }
}
const filters: QueryAST['filters'] = {}
// Extract type: filter
const typeMatches = query.matchAll(/(?:^|\s)(type):(\S*)/gi)
for (const match of typeMatches) {
const value = match[2]
if (value) {
filters.type = value
}
}
// Extract tag: filter
const tagMatches = query.matchAll(/(?:^|\s)(tag):(\S*)/gi)
for (const match of tagMatches) {
const value = match[2]
if (value) {
filters.tag = value
}
}
// Check for is:favorite (case insensitive filter name)
const isFavoriteMatches = query.match(/\bis:favorite\b/i)
if (isFavoriteMatches) {
filters.isFavorite = true
}
// Check for is:pinned (case insensitive filter name)
const isPinnedMatches = query.match(/\bis:pinned\b/i)
if (isPinnedMatches) {
filters.isPinned = true
}
// Remove all filter patterns from the query to get remaining text
let text = query
.replace(FILTER_REGEX, '')
.replace(IS_FAVORITE_REGEX, '')
.replace(IS_PINNED_REGEX, '')
.trim()
.replace(/\s+/g, ' ')
return { text, filters }
}

View File

@@ -1,10 +1,13 @@
import { prisma } from '@/lib/prisma'
import stringSimilarity from 'string-similarity'
import { getUsageStats } from '@/lib/usage'
import { parseQuery, QueryAST } from '@/lib/query-parser'
export interface SearchFilters {
type?: string
tag?: string
isFavorite?: boolean
isPinned?: boolean
}
export interface ScoredNote {
@@ -133,10 +136,19 @@ async function scoreNote(
}
export async function noteQuery(
query: string,
queryOrAST: string | QueryAST,
filters: SearchFilters = {}
): Promise<ScoredNote[]> {
const queryLower = query.toLowerCase().trim()
// Support both old signature (query string + filters) and new (QueryAST)
let queryAST: QueryAST
if (typeof queryOrAST === 'string') {
queryAST = { text: queryOrAST, filters }
} else {
queryAST = queryOrAST
}
const { text: queryText, filters: appliedFilters } = queryAST
const queryLower = queryText.toLowerCase().trim()
const allNotes = await prisma.note.findMany({
include: { tags: { include: { tag: true } } },
@@ -145,12 +157,14 @@ export async function noteQuery(
const scored: ScoredNote[] = []
for (const note of allNotes) {
if (filters.type && note.type !== filters.type) continue
if (filters.tag) {
const hasTag = note.tags.some(t => t.tag.name === filters.tag)
// Apply filters from AST BEFORE scoring
if (appliedFilters.type && note.type !== appliedFilters.type) continue
if (appliedFilters.tag) {
const hasTag = note.tags.some(t => t.tag.name === appliedFilters.tag)
if (!hasTag) continue
}
if (appliedFilters.isFavorite && note.isFavorite !== true) continue
if (appliedFilters.isPinned && note.isPinned !== true) continue
const titleLower = note.title.toLowerCase()
const contentLower = note.content.toLowerCase()
@@ -181,7 +195,7 @@ export async function noteQuery(
const highlight = highlightMatches(
exactTitleMatch ? note.title + ' ' + note.content : note.content,
query
queryText
)
scored.push({
@@ -202,5 +216,11 @@ export async function searchNotes(
query: string,
filters: SearchFilters = {}
): Promise<ScoredNote[]> {
return noteQuery(query, filters)
const queryAST: QueryAST = {
text: query,
filters: {
...filters,
},
}
return noteQuery(queryAST)
}