feat: MVP-2 completion - search, quick add, backlinks, guided forms

## Search & Retrieval
- Improved search ranking with scoring (title match, favorites, recency)
- Highlight matches with excerpt extraction
- Fuzzy search with string-similarity
- Unified noteQuery function

## Quick Capture
- Quick Add API (POST /api/notes/quick) with type prefixes
- Quick add parser with tag extraction
- Global Quick Add UI (Ctrl+N shortcut)
- Tag autocomplete in forms

## Note Relations
- Automatic backlinks with sync on create/update/delete
- Backlinks API (GET /api/notes/[id]/backlinks)
- Related notes with scoring and reasons

## Guided Forms
- Type-specific form fields (command, snippet, decision, recipe, procedure, inventory)
- Serialization to/from markdown
- Tag suggestions based on content (GET /api/tags/suggest)

## UX by Type
- Command: Copy button for code blocks
- Snippet: Syntax highlighting with react-syntax-highlighter
- Procedure: Interactive checkboxes

## Quality
- Standardized error handling across all APIs
- Integration tests (28 tests passing)
- Unit tests for search, tags, quick-add

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-22 13:51:39 -03:00
parent 6694bce736
commit 8b77c7b5df
30 changed files with 6548 additions and 282 deletions

View File

@@ -0,0 +1,114 @@
'use client'
import { useState, useRef, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { toast } from 'sonner'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import { Plus, Loader2 } from 'lucide-react'
export function QuickAdd() {
const [value, setValue] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [isExpanded, setIsExpanded] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const router = useRouter()
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault()
if (!value.trim() || isLoading) return
setIsLoading(true)
try {
const response = await fetch('/api/notes/quick', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: value }),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Error creating note')
}
const note = await response.json()
toast.success('Nota creada', {
description: note.title,
})
setValue('')
setIsExpanded(false)
router.refresh()
} catch (error) {
toast.error('Error', {
description: error instanceof Error ? error.message : 'No se pudo crear la nota',
})
} finally {
setIsLoading(false)
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit()
}
if (e.key === 'Escape') {
setValue('')
setIsExpanded(false)
inputRef.current?.blur()
}
}
// Focus on keyboard shortcut
useEffect(() => {
const handleGlobalKeyDown = (e: KeyboardEvent) => {
if (e.key === 'n' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
inputRef.current?.focus()
setIsExpanded(true)
}
}
window.addEventListener('keydown', handleGlobalKeyDown)
return () => window.removeEventListener('keydown', handleGlobalKeyDown)
}, [])
return (
<form onSubmit={handleSubmit} className="flex items-center gap-2">
<div className="relative">
<Input
ref={inputRef}
type="text"
placeholder="cmd: título #tag..."
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => setIsExpanded(true)}
className={cn(
'w-48 transition-all duration-200',
isExpanded && 'w-72'
)}
disabled={isLoading}
/>
{isLoading && (
<Loader2 className="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" />
)}
</div>
<button
type="submit"
disabled={!value.trim() || isLoading}
className={cn(
'inline-flex items-center justify-center rounded-lg border bg-background p-2',
'hover:bg-accent hover:text-accent-foreground',
'disabled:pointer-events-none disabled:opacity-50',
'transition-colors'
)}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
)}
</button>
</form>
)
}