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:
@@ -4,6 +4,7 @@ import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Plus, FileText, Settings } from 'lucide-react'
|
||||
import { QuickAdd } from '@/components/quick-add'
|
||||
|
||||
export function Header() {
|
||||
const pathname = usePathname()
|
||||
@@ -38,12 +39,15 @@ export function Header() {
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
<Link href="/new">
|
||||
<Button size="sm" className="gap-1.5">
|
||||
<Plus className="h-4 w-4" />
|
||||
Nueva nota
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="flex items-center gap-3">
|
||||
<QuickAdd />
|
||||
<Link href="/new">
|
||||
<Button size="sm" className="gap-1.5">
|
||||
<Plus className="h-4 w-4" />
|
||||
Nueva nota
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
|
||||
@@ -2,16 +2,167 @@
|
||||
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
|
||||
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'
|
||||
import { NoteType } from '@/types/note'
|
||||
import { Copy, Check } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface MarkdownContentProps {
|
||||
content: string
|
||||
className?: string
|
||||
noteType?: NoteType
|
||||
}
|
||||
|
||||
export function MarkdownContent({ content, className = '' }: MarkdownContentProps) {
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`prose max-w-none ${className}`}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
"absolute top-2 right-2 p-2 rounded-md transition-colors",
|
||||
"hover:bg-white/10",
|
||||
"flex items-center gap-1 text-xs"
|
||||
)}
|
||||
title="Copy code"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-4 w-4" />
|
||||
<span>Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4" />
|
||||
<span>Copy</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function InteractiveCheckbox({ checked, onChange }: { checked: boolean; onChange: (checked: boolean) => void }) {
|
||||
return (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="mr-2 h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ProcedureCheckboxes({ content }: { content: string }) {
|
||||
const lines = content.split('\n')
|
||||
const [checkedItems, setCheckedItems] = useState<Record<number, boolean>>({})
|
||||
|
||||
const handleToggle = (index: number) => {
|
||||
setCheckedItems(prev => ({ ...prev, [index]: !prev[index] }))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="procedure-checkboxes">
|
||||
{lines.map((line, index) => {
|
||||
const checkboxMatch = line.match(/^(\s*)-\s*\[([ x])\]\s*(.+)$/)
|
||||
if (checkboxMatch) {
|
||||
const [, indent, state, text] = checkboxMatch
|
||||
const isChecked = checkedItems[index] ?? (state === 'x')
|
||||
return (
|
||||
<div key={index} className={cn("flex items-center py-1", indent && `ml-${indent.length / 2}`)}>
|
||||
<InteractiveCheckbox checked={isChecked} onChange={() => handleToggle(index)} />
|
||||
<span className={isChecked ? 'line-through text-gray-500' : ''}>{text}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <div key={index}>{line}</div>
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function MarkdownContent({ content, className = '', noteType }: MarkdownContentProps) {
|
||||
if (noteType === 'procedure') {
|
||||
return (
|
||||
<div className={cn("prose max-w-none", className)}>
|
||||
<ProcedureCheckboxes content={content} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("prose max-w-none", className)}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
code({ className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || '')
|
||||
const codeString = String(children).replace(/\n$/, '')
|
||||
const isInline = !match
|
||||
|
||||
if (noteType === 'snippet' && match) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<SyntaxHighlighter
|
||||
style={oneDark}
|
||||
language={match[1]}
|
||||
PreTag="div"
|
||||
className="rounded-lg !bg-gray-900 !mt-4 !mb-4"
|
||||
>
|
||||
{codeString}
|
||||
</SyntaxHighlighter>
|
||||
<CopyButton text={codeString} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (noteType === 'command' && match?.[1] === 'bash') {
|
||||
return (
|
||||
<div className="relative group">
|
||||
<SyntaxHighlighter
|
||||
style={oneDark}
|
||||
language="bash"
|
||||
PreTag="div"
|
||||
className="rounded-lg !bg-gray-900 !mt-4 !mb-4"
|
||||
>
|
||||
{codeString}
|
||||
</SyntaxHighlighter>
|
||||
<CopyButton text={codeString} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isInline) {
|
||||
return (
|
||||
<code className="px-1 py-0.5 bg-gray-100 rounded text-sm font-mono" {...props}>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<SyntaxHighlighter
|
||||
style={oneDark}
|
||||
language={match?.[1] || 'text'}
|
||||
PreTag="div"
|
||||
className="rounded-lg !bg-gray-900 !mt-4 !mb-4"
|
||||
>
|
||||
{codeString}
|
||||
</SyntaxHighlighter>
|
||||
<CopyButton text={codeString} />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,600 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useRef, useEffect, useMemo } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Note, NoteType } from '@/types/note'
|
||||
import { Note, NoteType, Tag } from '@/types/note'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { getTemplate } from '@/lib/templates'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
// Command fields
|
||||
interface CommandFields {
|
||||
command: string
|
||||
description: string
|
||||
whenToUse: string
|
||||
example: string
|
||||
}
|
||||
|
||||
// Snippet fields
|
||||
interface SnippetFields {
|
||||
language: string
|
||||
code: string
|
||||
description: string
|
||||
notes: string
|
||||
}
|
||||
|
||||
// Decision fields
|
||||
interface DecisionFields {
|
||||
context: string
|
||||
decision: string
|
||||
alternatives: string
|
||||
consequences: string
|
||||
}
|
||||
|
||||
// Recipe fields
|
||||
interface RecipeFields {
|
||||
ingredients: string
|
||||
steps: string
|
||||
time: string
|
||||
notes: string
|
||||
}
|
||||
|
||||
// Procedure fields
|
||||
interface ProcedureFields {
|
||||
objective: string
|
||||
steps: string
|
||||
requirements: string
|
||||
commonProblems: string
|
||||
}
|
||||
|
||||
// Inventory fields
|
||||
interface InventoryFields {
|
||||
item: string
|
||||
quantity: string
|
||||
location: string
|
||||
notes: string
|
||||
}
|
||||
|
||||
// Note fields
|
||||
interface NoteFields {
|
||||
content: string
|
||||
}
|
||||
|
||||
type TypeFields = CommandFields | SnippetFields | DecisionFields | RecipeFields | ProcedureFields | InventoryFields | NoteFields
|
||||
|
||||
const defaultFields: Record<NoteType, TypeFields> = {
|
||||
command: { command: '', description: '', whenToUse: '', example: '' },
|
||||
snippet: { language: '', code: '', description: '', notes: '' },
|
||||
decision: { context: '', decision: '', alternatives: '', consequences: '' },
|
||||
recipe: { ingredients: '', steps: '', time: '', notes: '' },
|
||||
procedure: { objective: '', steps: '', requirements: '', commonProblems: '' },
|
||||
inventory: { item: '', quantity: '', location: '', notes: '' },
|
||||
note: { content: '' },
|
||||
}
|
||||
|
||||
function serializeToMarkdown(type: NoteType, fields: TypeFields): string {
|
||||
switch (type) {
|
||||
case 'command': {
|
||||
const f = fields as CommandFields
|
||||
return `## Comando\n\n${f.command}\n\n## Qué hace\n\n${f.description}\n\n## Cuándo usarlo\n\n${f.whenToUse}\n\n## Ejemplo\n\`\`\`bash\n${f.example}\n\`\`\``
|
||||
}
|
||||
case 'snippet': {
|
||||
const f = fields as SnippetFields
|
||||
return `## Snippet\n\n## Lenguaje\n\n${f.language}\n\n## Código\n\n\`\`\`${f.language}\n${f.code}\n\`\`\`\n\n## Qué resuelve\n\n${f.description}\n\n## Notas\n\n${f.notes}`
|
||||
}
|
||||
case 'decision': {
|
||||
const f = fields as DecisionFields
|
||||
return `## Contexto\n\n${f.context}\n\n## Decisión\n\n${f.decision}\n\n## Alternativas consideradas\n\n${f.alternatives}\n\n## Consecuencias\n\n${f.consequences}`
|
||||
}
|
||||
case 'recipe': {
|
||||
const f = fields as RecipeFields
|
||||
return `## Ingredientes\n\n${f.ingredients}\n\n## Pasos\n\n${f.steps}\n\n## Tiempo\n\n${f.time}\n\n## Notas\n\n${f.notes}`
|
||||
}
|
||||
case 'procedure': {
|
||||
const f = fields as ProcedureFields
|
||||
return `## Objetivo\n\n${f.objective}\n\n## Pasos\n\n${f.steps}\n\n## Requisitos\n\n${f.requirements}\n\n## Problemas comunes\n\n${f.commonProblems}`
|
||||
}
|
||||
case 'inventory': {
|
||||
const f = fields as InventoryFields
|
||||
return `## Item\n\n${f.item}\n\n## Cantidad\n\n${f.quantity}\n\n## Ubicación\n\n${f.location}\n\n## Notas\n\n${f.notes}`
|
||||
}
|
||||
case 'note':
|
||||
default: {
|
||||
const f = fields as NoteFields
|
||||
return `## Notas\n\n${f.content}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseMarkdownToFields(type: NoteType, content: string): TypeFields {
|
||||
const sections = content.split(/^##\s+/m).filter(Boolean)
|
||||
|
||||
switch (type) {
|
||||
case 'command': {
|
||||
const getSection = (name: string) => sections.find(s => s.startsWith(name + '\n'))?.split('\n').slice(1).join('\n').trim() || ''
|
||||
const exampleMatch = content.match(/```bash\n([\s\S]*?)```/)
|
||||
return {
|
||||
command: getSection('Comando'),
|
||||
description: getSection('Qué hace'),
|
||||
whenToUse: getSection('Cuándo usarlo'),
|
||||
example: exampleMatch ? exampleMatch[1].trim() : '',
|
||||
}
|
||||
}
|
||||
case 'snippet': {
|
||||
const getSection = (name: string) => sections.find(s => s.startsWith(name + '\n'))?.split('\n').slice(1).join('\n').trim() || ''
|
||||
const codeMatch = content.match(/```(\w+)?\n([\s\S]*?)```/)
|
||||
return {
|
||||
language: codeMatch?.[1] || getSection('Lenguaje') || '',
|
||||
code: codeMatch?.[2]?.trim() || '',
|
||||
description: getSection('Qué resuelve'),
|
||||
notes: getSection('Notas'),
|
||||
}
|
||||
}
|
||||
case 'decision': {
|
||||
const getSection = (name: string) => sections.find(s => s.startsWith(name + '\n'))?.split('\n').slice(1).join('\n').trim() || ''
|
||||
return {
|
||||
context: getSection('Contexto'),
|
||||
decision: getSection('Decisión'),
|
||||
alternatives: getSection('Alternativas consideradas'),
|
||||
consequences: getSection('Consecuencias'),
|
||||
}
|
||||
}
|
||||
case 'recipe': {
|
||||
const getSection = (name: string) => sections.find(s => s.startsWith(name + '\n'))?.split('\n').slice(1).join('\n').trim() || ''
|
||||
return {
|
||||
ingredients: getSection('Ingredientes'),
|
||||
steps: getSection('Pasos'),
|
||||
time: getSection('Tiempo'),
|
||||
notes: getSection('Notas'),
|
||||
}
|
||||
}
|
||||
case 'procedure': {
|
||||
const getSection = (name: string) => sections.find(s => s.startsWith(name + '\n'))?.split('\n').slice(1).join('\n').trim() || ''
|
||||
return {
|
||||
objective: getSection('Objetivo'),
|
||||
steps: getSection('Pasos'),
|
||||
requirements: getSection('Requisitos'),
|
||||
commonProblems: getSection('Problemas comunes'),
|
||||
}
|
||||
}
|
||||
case 'inventory': {
|
||||
const getSection = (name: string) => sections.find(s => s.startsWith(name + '\n'))?.split('\n').slice(1).join('\n').trim() || ''
|
||||
return {
|
||||
item: getSection('Item'),
|
||||
quantity: getSection('Cantidad'),
|
||||
location: getSection('Ubicación'),
|
||||
notes: getSection('Notas'),
|
||||
}
|
||||
}
|
||||
case 'note':
|
||||
default: {
|
||||
const noteContent = sections.find(s => s.startsWith('Notas\n'))?.split('\n').slice(1).join('\n').trim() || content
|
||||
return { content: noteContent }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Type-specific form components
|
||||
function CommandForm({ fields, onChange }: { fields: CommandFields; onChange: (f: CommandFields) => void }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Comando</label>
|
||||
<Input
|
||||
value={fields.command}
|
||||
onChange={(e) => onChange({ ...fields, command: e.target.value })}
|
||||
placeholder="git commit -m 'fix: resolve issue'"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Qué hace</label>
|
||||
<Textarea
|
||||
value={fields.description}
|
||||
onChange={(e) => onChange({ ...fields, description: e.target.value })}
|
||||
placeholder="Describe qué hace el comando..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Cuándo usarlo</label>
|
||||
<Textarea
|
||||
value={fields.whenToUse}
|
||||
onChange={(e) => onChange({ ...fields, whenToUse: e.target.value })}
|
||||
placeholder="Describe cuándo es apropiado usar este comando..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Ejemplo</label>
|
||||
<Textarea
|
||||
value={fields.example}
|
||||
onChange={(e) => onChange({ ...fields, example: e.target.value })}
|
||||
placeholder="Ejemplo de uso del comando"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SnippetForm({ fields, onChange }: { fields: SnippetFields; onChange: (f: SnippetFields) => void }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Lenguaje</label>
|
||||
<Input
|
||||
value={fields.language}
|
||||
onChange={(e) => onChange({ ...fields, language: e.target.value })}
|
||||
placeholder="typescript, python, bash, etc."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Código</label>
|
||||
<Textarea
|
||||
value={fields.code}
|
||||
onChange={(e) => onChange({ ...fields, code: e.target.value })}
|
||||
placeholder="Código del snippet"
|
||||
rows={8}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Qué resuelve</label>
|
||||
<Textarea
|
||||
value={fields.description}
|
||||
onChange={(e) => onChange({ ...fields, description: e.target.value })}
|
||||
placeholder="Describe qué problema resuelve este snippet..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Notas</label>
|
||||
<Textarea
|
||||
value={fields.notes}
|
||||
onChange={(e) => onChange({ ...fields, notes: e.target.value })}
|
||||
placeholder="Notas adicionales..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DecisionForm({ fields, onChange }: { fields: DecisionFields; onChange: (f: DecisionFields) => void }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Contexto</label>
|
||||
<Textarea
|
||||
value={fields.context}
|
||||
onChange={(e) => onChange({ ...fields, context: e.target.value })}
|
||||
placeholder="Cuál era la situación o problema..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Decisión</label>
|
||||
<Textarea
|
||||
value={fields.decision}
|
||||
onChange={(e) => onChange({ ...fields, decision: e.target.value })}
|
||||
placeholder="Cuál fue la decisión tomada..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Alternativas consideradas</label>
|
||||
<Textarea
|
||||
value={fields.alternatives}
|
||||
onChange={(e) => onChange({ ...fields, alternatives: e.target.value })}
|
||||
placeholder="Qué otras opciones se consideraron..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Consecuencias</label>
|
||||
<Textarea
|
||||
value={fields.consequences}
|
||||
onChange={(e) => onChange({ ...fields, consequences: e.target.value })}
|
||||
placeholder="Qué consecuencias tiene esta decisión..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RecipeForm({ fields, onChange }: { fields: RecipeFields; onChange: (f: RecipeFields) => void }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Ingredientes</label>
|
||||
<Textarea
|
||||
value={fields.ingredients}
|
||||
onChange={(e) => onChange({ ...fields, ingredients: e.target.value })}
|
||||
placeholder="Lista de ingredientes (uno por línea)"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Pasos</label>
|
||||
<Textarea
|
||||
value={fields.steps}
|
||||
onChange={(e) => onChange({ ...fields, steps: e.target.value })}
|
||||
placeholder="Pasos de la receta (uno por línea)"
|
||||
rows={5}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Tiempo</label>
|
||||
<Input
|
||||
value={fields.time}
|
||||
onChange={(e) => onChange({ ...fields, time: e.target.value })}
|
||||
placeholder="Ej: 30 minutos, 2 horas"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Notas</label>
|
||||
<Textarea
|
||||
value={fields.notes}
|
||||
onChange={(e) => onChange({ ...fields, notes: e.target.value })}
|
||||
placeholder="Notas adicionales..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProcedureForm({ fields, onChange }: { fields: ProcedureFields; onChange: (f: ProcedureFields) => void }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Objetivo</label>
|
||||
<Textarea
|
||||
value={fields.objective}
|
||||
onChange={(e) => onChange({ ...fields, objective: e.target.value })}
|
||||
placeholder="Cuál es el objetivo de este procedimiento..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Pasos</label>
|
||||
<Textarea
|
||||
value={fields.steps}
|
||||
onChange={(e) => onChange({ ...fields, steps: e.target.value })}
|
||||
placeholder="Pasos a seguir (uno por línea)"
|
||||
rows={6}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Requisitos</label>
|
||||
<Textarea
|
||||
value={fields.requirements}
|
||||
onChange={(e) => onChange({ ...fields, requirements: e.target.value })}
|
||||
placeholder="Qué se necesita para realizar esto..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Problemas comunes</label>
|
||||
<Textarea
|
||||
value={fields.commonProblems}
|
||||
onChange={(e) => onChange({ ...fields, commonProblems: e.target.value })}
|
||||
placeholder="Problemas frecuentes y cómo solucionarlos..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InventoryForm({ fields, onChange }: { fields: InventoryFields; onChange: (f: InventoryFields) => void }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Item</label>
|
||||
<Input
|
||||
value={fields.item}
|
||||
onChange={(e) => onChange({ ...fields, item: e.target.value })}
|
||||
placeholder="Nombre del item"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Cantidad</label>
|
||||
<Input
|
||||
value={fields.quantity}
|
||||
onChange={(e) => onChange({ ...fields, quantity: e.target.value })}
|
||||
placeholder="Cantidad o número de unidades"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Ubicación</label>
|
||||
<Input
|
||||
value={fields.location}
|
||||
onChange={(e) => onChange({ ...fields, location: e.target.value })}
|
||||
placeholder="Dónde está guardado"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Notas</label>
|
||||
<Textarea
|
||||
value={fields.notes}
|
||||
onChange={(e) => onChange({ ...fields, notes: e.target.value })}
|
||||
placeholder="Notas adicionales..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NoteTypeForm({ fields, onChange }: { fields: NoteFields; onChange: (f: NoteFields) => void }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Contenido</label>
|
||||
<Textarea
|
||||
value={fields.content}
|
||||
onChange={(e) => onChange({ ...fields, content: e.target.value })}
|
||||
placeholder="Contenido de la nota..."
|
||||
rows={15}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TagInput({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string[]
|
||||
onChange: (tags: string[]) => void
|
||||
}) {
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [suggestions, setSuggestions] = useState<Tag[]>([])
|
||||
const [showSuggestions, setShowSuggestions] = useState(false)
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSuggestions = async () => {
|
||||
if (inputValue.trim().length < 1) {
|
||||
setSuggestions([])
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/tags?q=${encodeURIComponent(inputValue)}`)
|
||||
if (res.ok) {
|
||||
const tags: Tag[] = await res.json()
|
||||
setSuggestions(tags.filter(t => !value.includes(t.name)))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching tag suggestions:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const debounce = setTimeout(fetchSuggestions, 150)
|
||||
return () => clearTimeout(debounce)
|
||||
}, [inputValue, value])
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setShowSuggestions(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const addTag = (tag: string) => {
|
||||
const trimmed = tag.trim()
|
||||
if (trimmed && !value.includes(trimmed)) {
|
||||
onChange([...value, trimmed])
|
||||
}
|
||||
setInputValue('')
|
||||
setSuggestions([])
|
||||
setShowSuggestions(false)
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
const removeTag = (tag: string) => {
|
||||
onChange(value.filter(t => t !== tag))
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (selectedIndex >= 0 && suggestions[selectedIndex]) {
|
||||
addTag(suggestions[selectedIndex].name)
|
||||
} else if (inputValue.trim()) {
|
||||
addTag(inputValue)
|
||||
}
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex(prev => Math.min(prev + 1, suggestions.length - 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex(prev => Math.max(prev - 1, -1))
|
||||
} else if (e.key === 'Escape') {
|
||||
setShowSuggestions(false)
|
||||
setSelectedIndex(-1)
|
||||
} else if (e.key === 'Backspace' && !inputValue && value.length > 0) {
|
||||
removeTag(value[value.length - 1])
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => {
|
||||
setInputValue(e.target.value)
|
||||
setShowSuggestions(true)
|
||||
setSelectedIndex(-1)
|
||||
}}
|
||||
onFocus={() => setShowSuggestions(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Escribe un tag y presiona Enter"
|
||||
/>
|
||||
|
||||
{value.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{value.map((tag) => (
|
||||
<Badge key={tag} variant="outline" className="flex items-center gap-1 pr-1">
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeTag(tag)}
|
||||
className="hover:bg-accent rounded p-0.5"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSuggestions && suggestions.length > 0 && (
|
||||
<div className="absolute z-10 w-full mt-1 bg-popover border rounded-lg shadow-md overflow-hidden">
|
||||
{suggestions.map((tag, index) => (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
className={`w-full text-left px-3 py-2 text-sm hover:bg-accent focus:bg-accent outline-none ${
|
||||
index === selectedIndex ? 'bg-accent' : ''
|
||||
}`}
|
||||
onClick={() => addTag(tag.name)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSuggestions && inputValue.trim() && suggestions.length === 0 && (
|
||||
<div className="absolute z-10 w-full mt-1 bg-popover border rounded-lg shadow-md overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-accent outline-none"
|
||||
onClick={() => addTag(inputValue)}
|
||||
>
|
||||
Crear "{inputValue.trim()}"
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const noteTypes: NoteType[] = ['command', 'snippet', 'decision', 'recipe', 'procedure', 'inventory', 'note']
|
||||
|
||||
@@ -20,29 +606,29 @@ interface NoteFormProps {
|
||||
export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
|
||||
const router = useRouter()
|
||||
const [title, setTitle] = useState(initialData?.title || '')
|
||||
const [content, setContent] = useState(initialData?.content || '')
|
||||
const [type, setType] = useState<NoteType>(initialData?.type || 'note')
|
||||
const [tagsInput, setTagsInput] = useState(initialData?.tags.map(t => t.tag.name).join(', ') || '')
|
||||
const [fields, setFields] = useState<TypeFields>(() => {
|
||||
if (initialData?.content) {
|
||||
return parseMarkdownToFields(initialData.type, initialData.content)
|
||||
}
|
||||
return defaultFields[type]
|
||||
})
|
||||
const [tags, setTags] = useState<string[]>(initialData?.tags.map(t => t.tag.name) || [])
|
||||
const [isFavorite, setIsFavorite] = useState(initialData?.isFavorite || false)
|
||||
const [isPinned, setIsPinned] = useState(initialData?.isPinned || false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const handleTypeChange = (newType: NoteType) => {
|
||||
setType(newType)
|
||||
if (!isEdit && !content) {
|
||||
setContent(getTemplate(newType))
|
||||
}
|
||||
setFields(defaultFields[newType])
|
||||
}
|
||||
|
||||
const content = useMemo(() => serializeToMarkdown(type, fields), [type, fields])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
|
||||
const tags = tagsInput
|
||||
.split(',')
|
||||
.map(t => t.trim())
|
||||
.filter(t => t.length > 0)
|
||||
|
||||
const noteData = {
|
||||
title,
|
||||
content,
|
||||
@@ -73,6 +659,26 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const renderTypeForm = () => {
|
||||
switch (type) {
|
||||
case 'command':
|
||||
return <CommandForm fields={fields as CommandFields} onChange={(f) => setFields(f)} />
|
||||
case 'snippet':
|
||||
return <SnippetForm fields={fields as SnippetFields} onChange={(f) => setFields(f)} />
|
||||
case 'decision':
|
||||
return <DecisionForm fields={fields as DecisionFields} onChange={(f) => setFields(f)} />
|
||||
case 'recipe':
|
||||
return <RecipeForm fields={fields as RecipeFields} onChange={(f) => setFields(f)} />
|
||||
case 'procedure':
|
||||
return <ProcedureForm fields={fields as ProcedureFields} onChange={(f) => setFields(f)} />
|
||||
case 'inventory':
|
||||
return <InventoryForm fields={fields as InventoryFields} onChange={(f) => setFields(f)} />
|
||||
case 'note':
|
||||
default:
|
||||
return <NoteTypeForm fields={fields as NoteFields} onChange={(f) => setFields(f)} />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4 max-w-2xl">
|
||||
<div>
|
||||
@@ -103,29 +709,12 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Contenido</label>
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="Contenido de la nota"
|
||||
rows={15}
|
||||
required
|
||||
/>
|
||||
{renderTypeForm()}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Tags (separados por coma)</label>
|
||||
<Input
|
||||
value={tagsInput}
|
||||
onChange={(e) => setTagsInput(e.target.value)}
|
||||
placeholder="bash, node, react"
|
||||
/>
|
||||
{tagsInput && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{tagsInput.split(',').map(t => t.trim()).filter(t => t).map((tag) => (
|
||||
<Badge key={tag} variant="outline">{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<label className="block text-sm font-medium mb-1">Tags</label>
|
||||
<TagInput value={tags} onChange={setTags} />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
@@ -157,4 +746,4 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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