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

@@ -1,121 +1,138 @@
import { NextRequest, NextResponse } from 'next/server'
import { NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
import { noteSchema } from '@/lib/validators'
import { noteSchema, NoteInput } from '@/lib/validators'
import { createErrorResponse, createSuccessResponse, ValidationError } from '@/lib/errors'
import { syncBacklinks } from '@/lib/backlinks'
export async function GET() {
const notes = await prisma.note.findMany({
include: { tags: { include: { tag: true } } },
})
try {
const notes = await prisma.note.findMany({
include: { tags: { include: { tag: true } } },
})
const exportData = notes.map(note => ({
...note,
tags: note.tags.map(nt => nt.tag.name),
createdAt: note.createdAt.toISOString(),
updatedAt: note.updatedAt.toISOString(),
}))
const exportData = notes.map(note => ({
...note,
tags: note.tags.map(nt => nt.tag.name),
createdAt: note.createdAt.toISOString(),
updatedAt: note.updatedAt.toISOString(),
}))
return NextResponse.json(exportData, { status: 200 })
return createSuccessResponse(exportData)
} catch (error) {
return createErrorResponse(error)
}
}
export async function POST(req: NextRequest) {
const body = await req.json()
try {
const body = await req.json()
if (!Array.isArray(body)) {
return NextResponse.json({ error: 'Invalid format: expected array' }, { status: 400 })
}
const importedNotes: Array<{ id?: string; title: string }> = []
const errors: string[] = []
for (let i = 0; i < body.length; i++) {
const result = noteSchema.safeParse(body[i])
if (!result.success) {
errors.push(`Item ${i}: ${result.error.issues.map(e => e.message).join(', ')}`)
continue
if (!Array.isArray(body)) {
throw new ValidationError([{ path: 'body', message: 'Invalid format: expected array' }])
}
importedNotes.push(result.data)
}
if (errors.length > 0) {
return NextResponse.json({ error: 'Validation failed', details: errors }, { status: 400 })
}
const importedNotes: NoteInput[] = []
const errors: string[] = []
const parseDate = (dateStr: string | undefined): Date => {
if (!dateStr) return new Date()
const parsed = new Date(dateStr)
return isNaN(parsed.getTime()) ? new Date() : parsed
}
for (let i = 0; i < body.length; i++) {
const result = noteSchema.safeParse(body[i])
if (!result.success) {
errors.push(`Item ${i}: ${result.error.issues.map(e => e.message).join(', ')}`)
continue
}
importedNotes.push(result.data)
}
let processed = 0
if (errors.length > 0) {
throw new ValidationError(errors)
}
await prisma.$transaction(async (tx) => {
for (const item of importedNotes) {
const tags = item.tags || []
const { tags: _, ...noteData } = item
const parseDate = (dateStr: string | undefined): Date => {
if (!dateStr) return new Date()
const parsed = new Date(dateStr)
return isNaN(parsed.getTime()) ? new Date() : parsed
}
const createdAt = parseDate((item as { createdAt?: string }).createdAt)
const updatedAt = parseDate((item as { updatedAt?: string }).updatedAt)
let processed = 0
if (item.id) {
const existing = await tx.note.findUnique({ where: { id: item.id } })
if (existing) {
await tx.note.update({
where: { id: item.id },
data: { ...noteData, createdAt, updatedAt },
})
await tx.noteTag.deleteMany({ where: { noteId: item.id } })
processed++
await prisma.$transaction(async (tx) => {
for (const item of importedNotes) {
const tags = item.tags || []
const { tags: _, ...noteData } = item
const createdAt = parseDate((item as { createdAt?: string }).createdAt)
const updatedAt = parseDate((item as { updatedAt?: string }).updatedAt)
if (item.id) {
const existing = await tx.note.findUnique({ where: { id: item.id } })
if (existing) {
await tx.note.update({
where: { id: item.id },
data: { ...noteData, createdAt, updatedAt },
})
await tx.noteTag.deleteMany({ where: { noteId: item.id } })
processed++
} else {
await tx.note.create({
data: {
...noteData,
id: item.id,
createdAt,
updatedAt,
},
})
processed++
}
} else {
await tx.note.create({
data: {
...noteData,
id: item.id,
createdAt,
updatedAt,
},
const existingByTitle = await tx.note.findFirst({
where: { title: item.title },
})
if (existingByTitle) {
await tx.note.update({
where: { id: existingByTitle.id },
data: { ...noteData, updatedAt },
})
await tx.noteTag.deleteMany({ where: { noteId: existingByTitle.id } })
} else {
await tx.note.create({
data: {
...noteData,
createdAt,
updatedAt,
},
})
}
processed++
}
} else {
const existingByTitle = await tx.note.findFirst({
where: { title: item.title },
})
if (existingByTitle) {
await tx.note.update({
where: { id: existingByTitle.id },
data: { ...noteData, updatedAt },
})
await tx.noteTag.deleteMany({ where: { noteId: existingByTitle.id } })
} else {
await tx.note.create({
data: {
...noteData,
createdAt,
updatedAt,
},
})
const noteId = item.id
? (await tx.note.findUnique({ where: { id: item.id } }))?.id
: (await tx.note.findFirst({ where: { title: item.title } }))?.id
if (noteId && tags.length > 0) {
for (const tagName of tags) {
const tag = await tx.tag.upsert({
where: { name: tagName },
create: { name: tagName },
update: {},
})
await tx.noteTag.create({
data: { noteId, tagId: tag.id },
})
}
}
processed++
}
const noteId = item.id
? (await tx.note.findUnique({ where: { id: item.id } }))?.id
: (await tx.note.findFirst({ where: { title: item.title } }))?.id
if (noteId && tags.length > 0) {
for (const tagName of tags) {
const tag = await tx.tag.upsert({
where: { name: tagName },
create: { name: tagName },
update: {},
})
await tx.noteTag.create({
data: { noteId, tagId: tag.id },
})
if (noteId) {
const note = await tx.note.findUnique({ where: { id: noteId } })
if (note) {
await syncBacklinks(note.id, note.content)
}
}
}
}
})
})
return NextResponse.json({ success: true, count: processed }, { status: 201 })
return createSuccessResponse({ success: true, count: processed }, 201)
} catch (error) {
return createErrorResponse(error)
}
}

View File

@@ -0,0 +1,24 @@
import { NextRequest } from 'next/server'
import { getBacklinksForNote, getOutgoingLinksForNote } from '@/lib/backlinks'
import { createErrorResponse, createSuccessResponse } from '@/lib/errors'
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const { searchParams } = new URL(req.url)
const direction = searchParams.get('direction') || 'backlinks'
if (direction === 'outgoing') {
const outgoing = await getOutgoingLinksForNote(id)
return createSuccessResponse(outgoing)
}
const backlinks = await getBacklinksForNote(id)
return createSuccessResponse(backlinks)
} catch (error) {
return createErrorResponse(error)
}
}

View File

@@ -1,60 +1,88 @@
import { NextRequest, NextResponse } from 'next/server'
import { NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
import { updateNoteSchema } from '@/lib/validators'
import { syncBacklinks } from '@/lib/backlinks'
import { createErrorResponse, createSuccessResponse, NotFoundError, ValidationError } from '@/lib/errors'
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const note = await prisma.note.findUnique({
where: { id },
include: { tags: { include: { tag: true } } },
})
if (!note) {
return NextResponse.json({ error: 'Note not found' }, { status: 404 })
try {
const { id } = await params
const note = await prisma.note.findUnique({
where: { id },
include: { tags: { include: { tag: true } } },
})
if (!note) {
throw new NotFoundError('Note')
}
return createSuccessResponse(note)
} catch (error) {
return createErrorResponse(error)
}
return NextResponse.json(note)
}
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const body = await req.json()
const result = updateNoteSchema.safeParse(body)
if (!result.success) {
return NextResponse.json({ error: result.error.issues }, { status: 400 })
}
const { tags, ...noteData } = result.data
// Delete existing tags
await prisma.noteTag.deleteMany({ where: { noteId: id } })
const note = await prisma.note.update({
where: { id },
data: {
...noteData,
tags: tags && tags.length > 0 ? {
create: await Promise.all(
(tags as string[]).map(async (tagName) => {
const tag = await prisma.tag.upsert({
where: { name: tagName },
create: { name: tagName },
update: {},
try {
const { id } = await params
const body = await req.json()
const result = updateNoteSchema.safeParse(body)
if (!result.success) {
throw new ValidationError(result.error.issues)
}
const { tags, ...noteData } = result.data
const existingNote = await prisma.note.findUnique({ where: { id } })
if (!existingNote) {
throw new NotFoundError('Note')
}
await prisma.noteTag.deleteMany({ where: { noteId: id } })
const note = await prisma.note.update({
where: { id },
data: {
...noteData,
tags: tags && tags.length > 0 ? {
create: await Promise.all(
(tags as string[]).map(async (tagName) => {
const tag = await prisma.tag.upsert({
where: { name: tagName },
create: { name: tagName },
update: {},
})
return { tagId: tag.id }
})
return { tagId: tag.id }
})
),
} : undefined,
},
include: { tags: { include: { tag: true } } },
})
return NextResponse.json(note)
),
} : undefined,
},
include: { tags: { include: { tag: true } } },
})
if (noteData.content !== undefined) {
await syncBacklinks(note.id, noteData.content)
}
return createSuccessResponse(note)
} catch (error) {
return createErrorResponse(error)
}
}
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params
await prisma.note.delete({ where: { id } })
return NextResponse.json({ success: true })
try {
const { id } = await params
const existingNote = await prisma.note.findUnique({ where: { id } })
if (!existingNote) {
throw new NotFoundError('Note')
}
await prisma.backlink.deleteMany({ where: { OR: [{ sourceNoteId: id }, { targetNoteId: id }] } })
await prisma.note.delete({ where: { id } })
return createSuccessResponse({ success: true })
} catch (error) {
return createErrorResponse(error)
}
}

View File

@@ -0,0 +1,56 @@
import { NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
import { parseQuickAdd } from '@/lib/quick-add'
import { syncBacklinks } from '@/lib/backlinks'
import { createErrorResponse, createSuccessResponse, ValidationError } from '@/lib/errors'
export async function POST(req: NextRequest) {
try {
let text: string
const contentType = req.headers.get('content-type') || ''
if (contentType.includes('application/json')) {
const body = await req.json()
text = body.text || body.content || ''
} else {
text = await req.text()
}
if (!text || !text.trim()) {
throw new ValidationError([{ path: 'text', message: 'Text is required' }])
}
const { type, tags, content } = parseQuickAdd(text)
const lines = content.split('\n')
const title = lines[0] || content.slice(0, 100)
const noteContent = lines.length > 1 ? lines.slice(1).join('\n').trim() : ''
const note = await prisma.note.create({
data: {
title: title.trim(),
content: noteContent || title.trim(),
type,
tags: tags.length > 0 ? {
create: await Promise.all(
tags.map(async (tagName) => {
const tag = await prisma.tag.upsert({
where: { name: tagName },
create: { name: tagName },
update: {},
})
return { tagId: tag.id }
})
),
} : undefined,
},
include: { tags: { include: { tag: true } } },
})
await syncBacklinks(note.id, note.content)
return createSuccessResponse(note, 201)
} catch (error) {
return createErrorResponse(error)
}
}

View File

@@ -1,43 +1,68 @@
import { NextRequest, NextResponse } from 'next/server'
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 { syncBacklinks } from '@/lib/backlinks'
import { createErrorResponse, createSuccessResponse, ValidationError } from '@/lib/errors'
export async function GET() {
const notes = await prisma.note.findMany({
include: { tags: { include: { tag: true } } },
orderBy: [{ isPinned: 'desc' }, { updatedAt: 'desc' }],
})
return NextResponse.json(notes)
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
if (q || type || tag) {
const notes = await noteQuery(q, { type, tag })
return createSuccessResponse(notes)
}
const notes = await prisma.note.findMany({
include: { tags: { include: { tag: true } } },
orderBy: [{ isPinned: 'desc' }, { updatedAt: 'desc' }],
})
return createSuccessResponse(notes)
} catch (error) {
return createErrorResponse(error)
}
}
export async function POST(req: NextRequest) {
const body = await req.json()
const result = noteSchema.safeParse(body)
if (!result.success) {
return NextResponse.json({ error: result.error.issues }, { status: 400 })
}
const { tags, ...noteData } = result.data
const note = await prisma.note.create({
data: {
...noteData,
tags: tags && tags.length > 0 ? {
create: await Promise.all(
(tags as string[]).map(async (tagName) => {
const tag = await prisma.tag.upsert({
where: { name: tagName },
create: { name: tagName },
update: {},
try {
const body = await req.json()
const result = noteSchema.safeParse(body)
if (!result.success) {
throw new ValidationError(result.error.issues)
}
const { tags, ...noteData } = result.data
const note = await prisma.note.create({
data: {
...noteData,
tags: tags && tags.length > 0 ? {
create: await Promise.all(
(tags as string[]).map(async (tagName) => {
const normalizedTagName = normalizeTag(tagName)
const tag = await prisma.tag.upsert({
where: { name: normalizedTagName },
create: { name: normalizedTagName },
update: {},
})
return { tagId: tag.id }
})
return { tagId: tag.id }
})
),
} : undefined,
},
include: { tags: { include: { tag: true } } },
})
return NextResponse.json(note, { status: 201 })
),
} : undefined,
},
include: { tags: { include: { tag: true } } },
})
await syncBacklinks(note.id, note.content)
return createSuccessResponse(note, 201)
} catch (error) {
return createErrorResponse(error)
}
}

View File

@@ -0,0 +1,53 @@
import { NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
import { createErrorResponse, createSuccessResponse } from '@/lib/errors'
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url)
const query = searchParams.get('q') || ''
const limit = parseInt(searchParams.get('limit') || '10', 10)
if (!query.trim()) {
const recentNotes = await prisma.note.findMany({
take: limit,
orderBy: { updatedAt: 'desc' },
select: { id: true, title: true, type: true },
})
return createSuccessResponse(recentNotes)
}
const queryLower = query.toLowerCase()
const notes = await prisma.note.findMany({
where: {
title: { contains: query },
},
take: limit,
orderBy: [
{ isPinned: 'desc' },
{ updatedAt: 'desc' },
],
select: {
id: true,
title: true,
type: true,
},
})
const exactMatch = notes.find(
(n) => n.title.toLowerCase() === queryLower
)
const otherMatches = notes.filter(
(n) => n.title.toLowerCase() !== queryLower
)
if (exactMatch) {
return createSuccessResponse([exactMatch, ...otherMatches])
}
return createSuccessResponse(notes)
} catch (error) {
return createErrorResponse(error)
}
}

View File

@@ -1,41 +1,18 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { NextRequest } from 'next/server'
import { searchNotes } from '@/lib/search'
import { createErrorResponse, createSuccessResponse } from '@/lib/errors'
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url)
const q = searchParams.get('q') || ''
const type = searchParams.get('type')
const tag = searchParams.get('tag')
const where: Record<string, unknown> = {}
if (q) {
where.OR = [
{ title: { contains: q } },
{ content: { contains: q } },
]
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 })
return createSuccessResponse(notes)
} catch (error) {
return createErrorResponse(error)
}
if (type) {
where.type = type
}
if (tag) {
where.tags = {
some: {
tag: { name: tag },
},
}
}
const notes = await prisma.note.findMany({
where,
include: { tags: { include: { tag: true } } },
orderBy: [
{ isPinned: 'desc' },
{ updatedAt: 'desc' },
],
})
return NextResponse.json(notes)
}

40
src/app/api/tags/route.ts Normal file
View File

@@ -0,0 +1,40 @@
import { NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
import { normalizeTag } from '@/lib/tags'
import { createErrorResponse, createSuccessResponse } from '@/lib/errors'
/**
* GET /api/tags - List all existing tags
*/
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url)
const q = searchParams.get('q')
if (q !== null) {
const normalizedQuery = normalizeTag(q)
const tags = await prisma.tag.findMany({
where: {
name: {
contains: normalizedQuery,
},
},
select: { id: true, name: true },
orderBy: { name: 'asc' },
take: 10,
})
return createSuccessResponse(tags)
}
const tags = await prisma.tag.findMany({
select: { id: true, name: true },
orderBy: { name: 'asc' },
})
return createSuccessResponse(tags)
} catch (error) {
return createErrorResponse(error)
}
}

View File

@@ -0,0 +1,17 @@
import { NextRequest } from 'next/server'
import { suggestTags } from '@/lib/tags'
import { createErrorResponse, createSuccessResponse } from '@/lib/errors'
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url)
const title = searchParams.get('title') || ''
const content = searchParams.get('content') || ''
const tags = suggestTags(title, content)
return createSuccessResponse(tags)
} catch (error) {
return createErrorResponse(error)
}
}

View File

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

View File

@@ -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>

View File

@@ -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 &quot;{inputValue.trim()}&quot;
</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>
)
}
}

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

122
src/lib/backlinks.ts Normal file
View File

@@ -0,0 +1,122 @@
import { prisma } from '@/lib/prisma'
const BACKLINK_REGEX = /\[\[([^\]]+)\]\]/g
export function parseBacklinks(content: string): string[] {
const matches = content.matchAll(BACKLINK_REGEX)
const titles = new Set<string>()
for (const match of matches) {
const title = match[1].trim()
if (title) {
titles.add(title)
}
}
return Array.from(titles)
}
export async function syncBacklinks(noteId: string, content: string): Promise<void> {
const linkedTitles = parseBacklinks(content)
await prisma.backlink.deleteMany({
where: { sourceNoteId: noteId },
})
if (linkedTitles.length === 0) return
const targetNotes = await prisma.note.findMany({
where: {
title: { in: linkedTitles },
},
select: { id: true, title: true },
})
const titleToId = new Map(targetNotes.map((n) => [n.title.toLowerCase(), n.id]))
const backlinksToCreate: { sourceNoteId: string; targetNoteId: string }[] = []
for (const title of linkedTitles) {
const targetNoteId = titleToId.get(title.toLowerCase())
if (targetNoteId && targetNoteId !== noteId) {
backlinksToCreate.push({
sourceNoteId: noteId,
targetNoteId,
})
}
}
if (backlinksToCreate.length > 0) {
await prisma.backlink.createMany({
data: backlinksToCreate,
})
}
}
export interface BacklinkWithNote {
id: string
sourceNoteId: string
targetNoteId: string
createdAt: string
sourceNote: {
id: string
title: string
type: string
}
}
export async function getBacklinksForNote(noteId: string): Promise<BacklinkWithNote[]> {
const backlinks = await prisma.backlink.findMany({
where: { targetNoteId: noteId },
include: {
sourceNote: {
select: {
id: true,
title: true,
type: true,
},
},
},
orderBy: { createdAt: 'desc' },
})
return backlinks.map((bl) => ({
id: bl.id,
sourceNoteId: bl.sourceNoteId,
targetNoteId: bl.targetNoteId,
createdAt: bl.createdAt.toISOString(),
sourceNote: {
id: bl.sourceNote.id,
title: bl.sourceNote.title,
type: bl.sourceNote.type,
},
}))
}
export async function getOutgoingLinksForNote(noteId: string): Promise<BacklinkWithNote[]> {
const backlinks = await prisma.backlink.findMany({
where: { sourceNoteId: noteId },
include: {
targetNote: {
select: {
id: true,
title: true,
type: true,
},
},
},
orderBy: { createdAt: 'desc' },
})
return backlinks.map((bl) => ({
id: bl.id,
sourceNoteId: bl.sourceNoteId,
targetNoteId: bl.targetNoteId,
createdAt: bl.createdAt.toISOString(),
sourceNote: {
id: bl.targetNote.id,
title: bl.targetNote.title,
type: bl.targetNote.type,
},
}))
}

113
src/lib/errors.ts Normal file
View File

@@ -0,0 +1,113 @@
import { NextResponse } from 'next/server'
import { ZodError } from 'zod'
export interface ApiError {
code: string
message: string
details?: unknown
}
export interface ApiResponse<T = unknown> {
success: boolean
data?: T
error?: ApiError
timestamp: string
}
export class AppError extends Error {
constructor(
public code: string,
message: string,
public statusCode: number = 500,
public details?: unknown
) {
super(message)
this.name = 'AppError'
}
}
export class NotFoundError extends AppError {
constructor(resource: string) {
super('NOT_FOUND', `${resource} not found`, 404)
}
}
export class ValidationError extends AppError {
constructor(details: unknown) {
super('VALIDATION_ERROR', 'Validation failed', 400, details)
}
}
export class UnauthorizedError extends AppError {
constructor() {
super('UNAUTHORIZED', 'Unauthorized access', 401)
}
}
export class ForbiddenError extends AppError {
constructor() {
super('FORBIDDEN', 'Access forbidden', 403)
}
}
export class ConflictError extends AppError {
constructor(message: string) {
super('CONFLICT', message, 409)
}
}
export function formatZodError(error: ZodError): ApiError {
return {
code: 'VALIDATION_ERROR',
message: 'Validation failed',
details: error.issues.map((issue) => ({
path: issue.path.join('.'),
message: issue.message,
})),
}
}
export function createErrorResponse(error: unknown): NextResponse {
if (error instanceof AppError) {
const body: ApiResponse = {
success: false,
error: {
code: error.code,
message: error.message,
details: error.details,
},
timestamp: new Date().toISOString(),
}
return NextResponse.json(body, { status: error.statusCode })
}
if (error instanceof ZodError) {
const body: ApiResponse = {
success: false,
error: formatZodError(error),
timestamp: new Date().toISOString(),
}
return NextResponse.json(body, { status: 400 })
}
console.error('Unexpected error:', error)
const body: ApiResponse = {
success: false,
error: {
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
},
timestamp: new Date().toISOString(),
}
return NextResponse.json(body, { status: 500 })
}
export function createSuccessResponse<T>(data: T, statusCode: number = 200): NextResponse {
const body: ApiResponse<T> = {
success: true,
data,
timestamp: new Date().toISOString(),
}
return NextResponse.json(body, { status: statusCode })
}

240
src/lib/guided-fields.ts Normal file
View File

@@ -0,0 +1,240 @@
import type { NoteType } from '@/types/note'
export interface GuidedData {
// Command
command?: string
description?: string
example?: string
// Snippet
language?: string
code?: string
snippetDescription?: string
// Decision
context?: string
decision?: string
alternatives?: string
consequences?: string
// Recipe
ingredients?: string
steps?: string
time?: string
recipeNotes?: string
// Procedure
objective?: string
procedureSteps?: string
requirements?: string
commonProblems?: string
// Inventory
item?: string
quantity?: string
location?: string
inventoryNotes?: string
}
export function serializeToMarkdown(type: NoteType, data: GuidedData): string {
switch (type) {
case 'command':
return `## Comando
${data.command || ''}
## Qué hace
${data.description || ''}
## Cuándo usarlo
## Ejemplo
\`\`\`bash
${data.example || ''}
\`\`\`
`
case 'snippet':
return `## Snippet
## Lenguaje
${data.language || ''}
## Qué resuelve
${data.snippetDescription || ''}
## Código
\`\`\`${data.language || ''}
${data.code || ''}
\`\`\`
`
case 'decision':
return `## Contexto
${data.context || ''}
## Decisión
${data.decision || ''}
## Alternativas consideradas
${data.alternatives || ''}
## Consecuencias
${data.consequences || ''}
`
case 'recipe':
return `## Ingredientes
${data.ingredients || ''}
## Pasos
${data.steps || ''}
## Tiempo
${data.time || ''}
## Notas
${data.recipeNotes || ''}
`
case 'procedure':
return `## Objetivo
${data.objective || ''}
## Pasos
${data.procedureSteps || ''}
## Requisitos
${data.requirements || ''}
## Problemas comunes
${data.commonProblems || ''}
`
case 'inventory':
return `## Item
${data.item || ''}
## Cantidad
${data.quantity || ''}
## Ubicación
${data.location || ''}
## Notas
${data.inventoryNotes || ''}
`
default:
return data.context || ''
}
}
export function parseMarkdownToGuided(type: NoteType, content: string): GuidedData {
const sections = content.split(/^##\s+/m).filter(Boolean)
const result: GuidedData = {}
for (const section of sections) {
const lines = section.split('\n')
const title = lines[0].trim().toLowerCase()
const body = lines.slice(2).join('\n').trim()
switch (title) {
case 'comando':
result.command = body
break
case 'qué hace':
result.description = body
break
case 'ejemplo':
const match = body.match(/```bash\n([\s\S]*?)```/)
result.example = match ? match[1].trim() : body.replace(/```\w*\n?/g, '').trim()
break
case 'lenguaje':
result.language = body
break
case 'qué resuelve':
result.snippetDescription = body
break
case 'código':
result.code = body.replace(/```\w*\n?/g, '').trim()
break
case 'contexto':
result.context = body
break
case 'decisión':
result.decision = body
break
case 'alternativas consideradas':
result.alternatives = body
break
case 'consecuencias':
result.consequences = body
break
case 'ingredientes':
result.ingredients = body
break
case 'pasos':
result.steps = body
break
case 'tiempo':
result.time = body
break
case 'notas':
result.recipeNotes = body
break
case 'objetivo':
result.objective = body
break
case 'requisitos':
result.requirements = body
break
case 'problemas comunes':
result.commonProblems = body
break
case 'item':
result.item = body
break
case 'cantidad':
result.quantity = body
break
case 'ubicación':
result.location = body
break
}
}
return result
}
export function isGuidedContent(type: NoteType, content: string): boolean {
if (!content) return false
const guidedTypes: NoteType[] = ['command', 'snippet', 'decision', 'recipe', 'procedure', 'inventory']
if (!guidedTypes.includes(type)) return false
// Check if content follows the guided template pattern
const patterns: Record<NoteType, RegExp> = {
command: /^##\s+Comando\n/m,
snippet: /^##\s+Snippet\n/m,
decision: /^##\s+Contexto\n/m,
recipe: /^##\s+Ingredientes\n/m,
procedure: /^##\s+Objetivo\n/m,
inventory: /^##\s+Item\n/m,
note: /^/m,
}
return patterns[type].test(content)
}

52
src/lib/quick-add.ts Normal file
View File

@@ -0,0 +1,52 @@
import { NoteType } from '@/types/note'
export interface QuickAddResult {
type: NoteType
tags: string[]
content: string
}
const TYPE_PREFIXES: Record<string, NoteType> = {
'cmd:': 'command',
'snip:': 'snippet',
'dec:': 'decision',
'rec:': 'recipe',
'proc:': 'procedure',
'inv:': 'inventory',
}
const TAG_REGEX = /#([a-z0-9]+)/g
export function parseQuickAdd(text: string): QuickAddResult {
let remaining = text.trim()
let type: NoteType = 'note'
// Extract type prefix
for (const [prefix, noteType] of Object.entries(TYPE_PREFIXES)) {
if (remaining.toLowerCase().startsWith(prefix)) {
type = noteType
remaining = remaining.slice(prefix.length).trim()
break
}
}
// Extract tags
const tags: string[] = []
const tagMatches = remaining.match(TAG_REGEX)
if (tagMatches) {
for (const match of tagMatches) {
const tagName = match.slice(1).toLowerCase().trim()
if (tagName && !tags.includes(tagName)) {
tags.push(tagName)
}
}
// Remove tags from content
remaining = remaining.replace(TAG_REGEX, '').trim()
}
return {
type,
tags,
content: remaining,
}
}

195
src/lib/search.ts Normal file
View File

@@ -0,0 +1,195 @@
import { prisma } from '@/lib/prisma'
import stringSimilarity from 'string-similarity'
export interface SearchFilters {
type?: string
tag?: string
}
export interface ScoredNote {
id: string
title: string
content: string
type: string
isFavorite: boolean
isPinned: boolean
createdAt: string
updatedAt: string
tags: { tag: { id: string; name: string } }[]
score: number
highlight?: string
matchType: 'exact' | 'fuzzy'
}
const FUZZY_THRESHOLD = 0.3
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000
export function highlightMatches(text: string, query: string): string {
if (!query.trim()) return text.slice(0, 150)
const words = query.toLowerCase().split(/\s+/).filter(w => w.length > 1)
if (words.length === 0) return text.slice(0, 150)
const textLower = text.toLowerCase()
let matchIndex = -1
for (const word of words) {
const idx = textLower.indexOf(word)
if (idx !== -1) {
matchIndex = idx
break
}
}
let excerpt: string
if (matchIndex !== -1) {
const start = Math.max(0, matchIndex - 75)
const end = Math.min(text.length, matchIndex + 75)
excerpt = text.slice(start, end)
if (start > 0) excerpt = '...' + excerpt
if (end < text.length) excerpt = excerpt + '...'
} else {
excerpt = text.slice(0, 150)
if (text.length > 150) excerpt += '...'
}
for (const word of words) {
const regex = new RegExp(`(${escapeRegex(word)})`, 'gi')
excerpt = excerpt.replace(regex, '<mark>$1</mark>')
}
return excerpt
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function scoreNote(
note: {
id: string
title: string
content: string
type: string
isFavorite: boolean
isPinned: boolean
createdAt: Date
updatedAt: Date
tags: { tag: { id: string; name: string } }[]
},
query: string,
exactTitleMatch: boolean
): { score: number; matchType: 'exact' | 'fuzzy' } {
let score = 0
let matchType: 'exact' | 'fuzzy' = 'exact'
const queryLower = query.toLowerCase()
const titleLower = note.title.toLowerCase()
const contentLower = note.content.toLowerCase()
if (exactTitleMatch) {
if (titleLower === queryLower) {
score += 10
} else if (titleLower.includes(queryLower)) {
score += 5
}
} else {
const similarity = stringSimilarity.compareTwoStrings(queryLower, titleLower)
if (similarity >= FUZZY_THRESHOLD) {
score += similarity * 5
matchType = 'fuzzy'
}
}
if (contentLower.includes(queryLower)) {
score += 3
}
if (note.isFavorite) {
score += 2
}
if (note.isPinned) {
score += 1
}
const now = Date.now()
const updatedAt = note.updatedAt.getTime()
if (now - updatedAt < SEVEN_DAYS_MS) {
score += 1
}
return { score, matchType }
}
export async function noteQuery(
query: string,
filters: SearchFilters = {}
): Promise<ScoredNote[]> {
const queryLower = query.toLowerCase().trim()
const allNotes = await prisma.note.findMany({
include: { tags: { include: { tag: true } } },
})
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)
if (!hasTag) continue
}
const titleLower = note.title.toLowerCase()
const contentLower = note.content.toLowerCase()
const exactTitleMatch = titleLower.includes(queryLower)
const exactContentMatch = contentLower.includes(queryLower)
if (!queryLower) {
const { score, matchType } = { score: 0, matchType: 'exact' as const }
scored.push({
...note,
score,
matchType,
createdAt: note.createdAt.toISOString(),
updatedAt: note.updatedAt.toISOString(),
})
continue
}
if (!exactTitleMatch && !exactContentMatch) {
const similarity = stringSimilarity.compareTwoStrings(queryLower, titleLower)
if (similarity < FUZZY_THRESHOLD && !contentLower.includes(queryLower)) {
continue
}
}
const { score, matchType } = scoreNote(note, queryLower, exactTitleMatch || exactContentMatch)
const highlight = highlightMatches(
exactTitleMatch ? note.title + ' ' + note.content : note.content,
query
)
scored.push({
...note,
score,
matchType,
highlight,
createdAt: note.createdAt.toISOString(),
updatedAt: note.updatedAt.toISOString(),
})
}
return scored
.sort((a, b) => b.score - a.score)
}
export async function searchNotes(
query: string,
filters: SearchFilters = {}
): Promise<ScoredNote[]> {
return noteQuery(query, filters)
}

View File

@@ -1,3 +1,17 @@
/**
* Normalizes a tag by converting to lowercase and trimming whitespace.
*/
export function normalizeTag(tag: string): string {
return tag.toLowerCase().trim()
}
/**
* Normalizes an array of tags.
*/
export function normalizeTags(tags: string[]): string[] {
return tags.map(normalizeTag)
}
const TAG_KEYWORDS: Record<string, string[]> = {
code: ['code', 'function', 'class', 'algorithm', 'programming', 'javascript', 'typescript', 'python', 'react'],
bash: ['bash', 'shell', 'command', 'terminal', 'script', 'cli'],

View File

@@ -1,60 +1,246 @@
export const templates: Record<string, string> = {
command: `## Comando
import type { NoteType } from '@/types/note'
export interface GuidedField {
command: {
command: string
description: string
example: string
}
snippet: {
language: string
code: string
description: string
}
decision: {
context: string
decision: string
alternatives: string
consequences: string
}
recipe: {
ingredients: string
steps: string
time: string
}
procedure: {
objective: string
steps: string
requirements: string
}
inventory: {
item: string
quantity: string
location: string
}
note: Record<string, never>
}
export type GuidedType = keyof GuidedField
export function isGuidedType(type: NoteType): type is GuidedType {
return type !== 'note'
}
export function isFreeMarkdown(content: string): boolean {
if (!content) return false
const lines = content.trim().split('\n')
const guidedPatterns = [
/^##\s*(Comando|Qué hace|Cuando usarlo|Ejemplo)$/,
/^##\s*(Snippet|Lenguaje|Qué resuelve|Notas)$/,
/^##\s*(Contexto|Decisión|Alternativas|を考慮|Consecuencias)$/,
/^##\s*(Ingredientes|Pasos|Tiempo|Notas)$/,
/^##\s*(Objetivo|Requisitos|Problemas comunes)$/,
/^##\s*(Item|Cantidad|Ubicación|Notas)$/,
/^##\s*Notas$/,
]
let matchCount = 0
for (const line of lines) {
for (const pattern of guidedPatterns) {
if (pattern.test(line)) {
matchCount++
break
}
}
}
return matchCount < 3
}
export function serializeToMarkdown(type: NoteType, fields: Record<string, string>): string {
switch (type) {
case 'command':
return `## Comando
${fields.command || ''}
## Qué hace
## Cuándo usarlo
${fields.description || ''}
## Ejemplo
\`\`\`bash
\`\`\`bash
${fields.example || ''}
\`\`\`
`,
snippet: `## Snippet
`
case 'snippet':
return `## Snippet
## Lenguaje
## Qué resuelve
${fields.language || ''}
## Notas
`,
decision: `## Contexto
## Código
\`\`\`${fields.language || ''}
${fields.code || ''}
\`\`\`
## Descripción
${fields.description || ''}
`
case 'decision':
return `## Contexto
${fields.context || ''}
## Decisión
${fields.decision || ''}
## Alternativas consideradas
${fields.alternatives || ''}
## Consecuencias
`,
recipe: `## Ingredientes
${fields.consequences || ''}
`
case 'recipe':
return `## Ingredientes
${fields.ingredients || ''}
## Pasos
${fields.steps || ''}
## Tiempo
## Notas
`,
procedure: `## Objetivo
${fields.time || ''}
`
case 'procedure':
return `## Objetivo
${fields.objective || ''}
## Pasos
${fields.steps || ''}
## Requisitos
## Problemas comunes
`,
inventory: `## Item
${fields.requirements || ''}
`
case 'inventory':
return `## Item
${fields.item || ''}
## Cantidad
${fields.quantity || ''}
## Ubicación
## Notas
`,
note: `## Notas
`,
${fields.location || ''}
`
case 'note':
default:
return fields.content || ''
}
}
export function getTemplate(type: string): string {
return templates[type] || templates.note
export function parseMarkdownToFields(type: NoteType, content: string): Record<string, string> {
const fields: Record<string, string> = {}
if (!content) return fields
const sectionPattern = /^##\s+(.+)$/gm
const sections: { title: string; content: string }[] = []
let lastIndex = 0
let match
while ((match = sectionPattern.exec(content)) !== null) {
if (lastIndex !== 0) {
const prevMatch = sectionPattern.exec(content)
if (prevMatch) {
sections.push({
title: prevMatch[1],
content: content.slice(lastIndex, match.index).trim()
})
}
}
lastIndex = match.index + match[0].length
}
const remainingContent = content.slice(lastIndex).trim()
if (remainingContent) {
sections.push({
title: sections.length > 0 ? sections[sections.length - 1].title : '',
content: remainingContent
})
}
switch (type) {
case 'command':
fields.command = extractSection(content, 'Comando')
fields.description = extractSection(content, 'Qué hace')
fields.example = extractCodeBlock(content)
break
case 'snippet':
fields.language = extractSection(content, 'Lenguaje')
fields.code = extractCodeBlock(content)
fields.description = extractSection(content, 'Descripción')
break
case 'decision':
fields.context = extractSection(content, 'Contexto')
fields.decision = extractSection(content, 'Decisión')
fields.alternatives = extractSection(content, 'Alternativas')
fields.consequences = extractSection(content, 'Consecuencias')
break
case 'recipe':
fields.ingredients = extractSection(content, 'Ingredientes')
fields.steps = extractSection(content, 'Pasos')
fields.time = extractSection(content, 'Tiempo')
break
case 'procedure':
fields.objective = extractSection(content, 'Objetivo')
fields.steps = extractSection(content, 'Pasos')
fields.requirements = extractSection(content, 'Requisitos')
break
case 'inventory':
fields.item = extractSection(content, 'Item')
fields.quantity = extractSection(content, 'Cantidad')
fields.location = extractSection(content, 'Ubicación')
break
case 'note':
default:
fields.content = content
}
return fields
}
function extractSection(content: string, sectionName: string): string {
const pattern = new RegExp(`##\\s+${sectionName}\\s*\\n([\\s\\S]*?)(?=##\\s+|\\z)`, 'i')
const match = content.match(pattern)
return match ? match[1].trim() : ''
}
function extractCodeBlock(content: string): string {
const match = content.match(/```[\w]*\n?([\s\S]*?)```/)
return match ? match[1].trim() : ''
}