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:
114
src/components/quick-add.tsx
Normal file
114
src/components/quick-add.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user