This commit is contained in:
2026-03-22 13:01:46 -03:00
parent af0910f428
commit 6694bce736
52 changed files with 4949 additions and 102 deletions

View File

@@ -0,0 +1,15 @@
{
"permissions": {
"allow": [
"Bash(npx prisma:*)",
"Bash(ls:*)",
"Bash(npm install:*)",
"Bash(cat:*)",
"Bash(npm run:*)",
"Bash(node:*)",
"Bash(curl:*)",
"Bash(npx tsc:*)",
"Bash(npm list:*)"
]
}
}

2
.gitignore vendored
View File

@@ -39,3 +39,5 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
/src/generated/prisma

View File

@@ -21,3 +21,9 @@ Once code is added, document:
## Commands ## Commands
Build, test, and lint commands will be documented here once the project structure is established. Build, test, and lint commands will be documented here once the project structure is established.
## Resumen
- Cuando te pida realizar un resumen del proyecto debes crear un archivo con el siguiente formato de nombre yyyy-mm-dd-resumen.md en la carpeta resumen.
- Si no existe crea una carpeta resumen en la raiz del proyecto.
- Crearemos resumenes de forma incremental y el primero debe contener todo lo existente hasta el momento.

BIN
dev.db Normal file

Binary file not shown.

2007
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,28 +6,42 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint",
"db:seed": "npx tsx prisma/seed.ts"
},
"prisma": {
"seed": "npx tsx prisma/seed.ts"
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.3.0", "@base-ui/react": "^1.3.0",
"@prisma/client": "^5.22.0",
"@tailwindcss/typography": "^0.5.19",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dotenv": "^17.3.1",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"next": "16.2.1", "next": "16.2.1",
"next-themes": "^0.4.6",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"shadcn": "^4.1.0", "shadcn": "^4.1.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0" "tw-animate-css": "^1.4.0",
"zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20.19.37",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.2.1", "eslint-config-next": "16.2.1",
"prisma": "^5.22.0",
"tailwindcss": "^4", "tailwindcss": "^4",
"tsx": "^4.21.0",
"typescript": "^5" "typescript": "^5"
} }
} }

BIN
prisma/dev.db Normal file

Binary file not shown.

View File

@@ -0,0 +1,30 @@
-- CreateTable
CREATE TABLE "Note" (
"id" TEXT NOT NULL PRIMARY KEY,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"type" TEXT NOT NULL DEFAULT 'note',
"isFavorite" BOOLEAN NOT NULL DEFAULT false,
"isPinned" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Tag" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "NoteTag" (
"noteId" TEXT NOT NULL,
"tagId" TEXT NOT NULL,
PRIMARY KEY ("noteId", "tagId"),
CONSTRAINT "NoteTag_noteId_fkey" FOREIGN KEY ("noteId") REFERENCES "Note" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "NoteTag_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "Tag" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "Tag_name_key" ON "Tag"("name");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "sqlite"

35
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,35 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model Note {
id String @id @default(cuid())
title String
content String
type String @default("note")
isFavorite Boolean @default(false)
isPinned Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tags NoteTag[]
}
model Tag {
id String @id @default(cuid())
name String @unique
notes NoteTag[]
}
model NoteTag {
noteId String
tagId String
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
@@id([noteId, tagId])
}

87
prisma/seed.ts Normal file
View File

@@ -0,0 +1,87 @@
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function main() {
await prisma.noteTag.deleteMany()
await prisma.note.deleteMany()
await prisma.tag.deleteMany()
const notes = [
{
title: 'Install Node.js with nvm',
content: '## Comando\n\n```bash\nnvm install node\nnvm use node\n```\n\n## Qué hace\nInstala la última versión de Node.js usando nvm.\n\n## Cuándo usarlo\nCuando necesitas instalar Node.js en un sistema nuevo.',
type: 'command',
tags: ['bash', 'node', 'devops'],
},
{
title: 'React useEffect cleanup pattern',
content: '## Snippet\n\n## Lenguaje\nTypeScript/React\n\n## Qué resuelve\nLimpieza correcta de suscripciones en useEffect.\n\n## Código\n```typescript\nuseEffect(() => {\n const controller = new AbortController()\n return () => controller.abort()\n}, [])\n```',
type: 'snippet',
tags: ['code', 'react', 'frontend'],
},
{
title: 'Usar PostgreSQL para producción',
content: '## Contexto\nEl MVP usa SQLite pero en producción necesitamos más capacidad.\n\n## Decisión\nMigrar a PostgreSQL manteniendo el mismo Prisma ORM.\n\n## Alternativas consideradas\n- MySQL: mejor soporte JSON pero menos popular\n- MongoDB: demasiado flexible\n\n## Consecuencias\n- Mejor concurrencia\n- Migración transparente con Prisma',
type: 'decision',
tags: ['arquitectura', 'backend'],
},
{
title: 'Pollo al horno con hierbas',
content: '## Ingredientes\n- 1 pollo entero (~1.5kg)\n- 4 dientes de ajo\n- Romero fresco\n- Tomillo\n- Aceite de oliva\n- Sal y pimienta\n\n## Pasos\n1. Precalentar horno a 200°C\n2. Limpiar y secar el pollo\n3. Untar con aceite y especias\n4. Hornear 1 hora\n5. Descansar 10 min antes de cortar\n\n## Tiempo\n1h 15min total\n\n## Notas\nQueda muy jugoso si lo vuelves a bañar con sus jugos a mitad de cocción.',
type: 'recipe',
tags: ['cocina'],
},
{
title: 'Renovar pasaporte argentino',
content: '## Objetivo\nRenovar el pasaporte argentino vencido.\n\n## Pasos\n1. Sacar turno online en turno.gob.ar\n2. Llevar DNI original\n3. Llevar pasaporte anterior\n4. Pagar tasa de renovación\n5. Esperar ~15 días hábiles\n\n## Requisitos\n- DNI vigente\n- Pasaporte anterior\n\n## Problemas comunes\n- Los turnos se agotan rápido',
type: 'procedure',
tags: ['trámite', 'hogar'],
},
{
title: 'Inventario cocina',
content: '## Item | Cantidad | Ubicación\nArroz | 2kg | Alacena\nFideos | 5 paquetes | Alacena\nLentejas | 1kg | Alacena\nAceite | 2L | Bajo mesada\nSal | 3 paquetes | Mesa\n\n## Notas\nRevisar fechas de vencimiento cada 6 meses.',
type: 'inventory',
tags: ['hogar', 'inventario'],
},
{
title: 'Ideas para vacaciones 2026',
content: '## Opciones\n1. Costa atlántica argentina\n2. Bariloche (invierno)\n3. Viaje a Europa\n\n## Presupuesto estimado\n- Argentina: $500-800 USD\n- Europa: $2000-3000 USD\n\n## Preferencias\n- Prefiero naturaleza sobre ciudades',
type: 'note',
tags: ['viajes', 'planificación'],
},
{
title: 'Resumen libro: Atomic Habits',
content: '## Ideas principales\n- Hábitos compound: pequeños cambios dan grandes resultados\n- No importa si eres mejor o peor, importa tu sistema\n- 1% mejor cada día = 37x mejor al año\n\n## Aplicar\n- Crear morning routine\n- Eliminar malos hábitos con diseño ambiental\n- No perder rachas',
type: 'note',
tags: ['lectura', 'productividad'],
},
]
for (const note of notes) {
const { tags, ...noteData } = note
await prisma.note.create({
data: {
...noteData,
tags: {
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 }
})
),
},
},
})
}
console.log('Seed completed')
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect())

View File

@@ -0,0 +1,165 @@
# Resumen del Proyecto - 2026-03-22
## Nombre
**Recall** - Sistema de gestión de notas personales
## Descripción
Aplicación web para crear, editar, buscar y organizar notas personales con soporte para tags, tipos de notas, favoritos, y pins. Permite exportar/importar notas en JSON y MD.
---
## Tech Stack
| Categoría | Tecnología |
|-----------|------------|
| Framework | Next.js 16.2.1 (React 19.2.4) |
| Base UI | @base-ui/react 1.3.0 |
| Database | SQLite con Prisma ORM |
| Validation | Zod 4.3.6 |
| Styling | Tailwind CSS 4 + CSS Variables |
| Icons | Lucide React |
| Markdown | react-markdown + remark-gfm |
| Toast | sonner 2.0.7 |
---
## Estructura del Proyecto
```
src/
├── app/
│ ├── api/
│ │ ├── export-import/route.ts # GET (exportar) / POST (importar)
│ │ ├── notes/
│ │ │ ├── route.ts # GET (listar) / POST (crear)
│ │ │ └── [id]/route.ts # GET / PUT / DELETE
│ │ └── search/route.ts # Búsqueda full-text
│ ├── edit/[id]/page.tsx # Editar nota
│ ├── new/page.tsx # Crear nota
│ ├── notes/
│ │ ├── page.tsx # Lista de notas con filtros
│ │ └── [id]/page.tsx # Detalle de nota
│ ├── settings/page.tsx # Configuración (export/import)
│ ├── layout.tsx
│ ├── page.tsx # Dashboard
│ └── globals.css
├── components/
│ ├── ui/ # Componentes base (Button, Card, Dialog, etc.)
│ ├── dashboard.tsx
│ ├── delete-note-button.tsx # Botón eliminar con modal confirmación
│ ├── header.tsx
│ ├── markdown-content.tsx
│ ├── note-card.tsx
│ ├── note-form.tsx
│ ├── note-list.tsx
│ ├── related-notes.tsx
│ ├── search-bar.tsx
│ └── tag-filter.tsx
├── lib/
│ ├── prisma.ts # Cliente Prisma singleton
│ ├── related.ts # Algoritmo para notas relacionadas
│ ├── tags.ts # Utilidades de tags
│ ├── templates.ts # Plantillas para nuevos tipos de nota
│ ├── utils.ts # cn() helper
│ └── validators.ts # Esquemas Zod
└── types/
└── note.ts # Tipos TypeScript para NoteType
```
---
## Modelo de Datos (Prisma)
### Note
| Campo | Tipo | Descripción |
|-------|------|-------------|
| id | String | CUID único |
| title | String | Título de la nota |
| content | String | Contenido en Markdown |
| type | String | Tipo: command, snippet, decision, recipe, procedure, inventory, note |
| isFavorite | Boolean | Marcada como favorita |
| isPinned | Boolean | Fijada arriba |
| createdAt | DateTime | Fecha creación |
| updatedAt | DateTime | Última modificación |
| tags | NoteTag[] | Relación many-to-many |
### Tag
| Campo | Tipo | Descripción |
|-------|------|-------------|
| id | String | CUID único |
| name | String | Nombre único |
| notes | NoteTag[] | Relación many-to-many |
### NoteTag (tabla de unión)
| Campo | Tipo | Descripción |
|-------|------|-------------|
| noteId | String | FK a Note |
| tagId | String | FK a Tag |
---
## Rutas de la Aplicación
| Ruta | Descripción |
|------|-------------|
| `/` | Dashboard con notas recientes |
| `/notes` | Lista de todas las notas con filtros (búsqueda, tipo, tag) |
| `/notes/[id]` | Detalle de una nota |
| `/new` | Crear nueva nota |
| `/edit/[id]` | Editar nota existente |
| `/settings` | Configuración: exportar/importar notas |
---
## APIs
### GET/POST `/api/export-import`
- **GET**: Exporta todas las notas como JSON
- **POST**: Importa notas desde JSON o MD
- Soporta `.json` (formato exportado)
- Soporta `.md` (usa primer `# Heading` como título)
### GET/POST `/api/notes`
- **GET**: Lista notas (soporta query params: q, type, tag)
- **POST**: Crea nueva nota
### GET/PUT/DELETE `/api/notes/[id]`
- **GET**: Obtiene nota por ID
- **PUT**: Actualiza nota
- **DELETE**: Elimina nota
### GET `/api/search`
- Búsqueda full-text por título y contenido
---
## Funcionalidades Implementadas
1. **CRUD de Notas** - Crear, leer, actualizar, eliminar
2. **Tipos de Notas** - command, snippet, decision, recipe, procedure, inventory, note
3. **Tags** - Sistema de tags con many-to-many
4. **Favoritos y Pins** - Marcar notas como favorites/fijadas
5. **Búsqueda y Filtros** - Por texto, tipo y tag
6. **Exportar/Importar** - Formato JSON y MD
7. **Modal de Confirmación** - Al eliminar nota
8. **Notas Relacionadas** - Algoritmo de相关性
9. **Plantillas** - Para diferentes tipos de notas
10. **Dashboard** - Vista general con notas recientes
---
## Componentes UI Principales
- Button, Card, Badge, Dialog, Input, Select, Tabs, Textarea
- Avatar, DropdownMenu, Sonner (toasts)
---
## Notas Técnicas
- Uses `app/` router (Next.js 13+ App Router)
- Server Components para fetching de datos
- Client Components para interactividad (forms, dialogs)
- Prisma con SQLite (archivo `dev.db`)
- Zod para validación de schemas
- CSS Variables para theming con `next-themes`

View File

@@ -0,0 +1,121 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { noteSchema } from '@/lib/validators'
export async function GET() {
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(),
}))
return NextResponse.json(exportData, { status: 200 })
}
export async function POST(req: NextRequest) {
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
}
importedNotes.push(result.data)
}
if (errors.length > 0) {
return NextResponse.json({ error: 'Validation failed', details: errors }, { status: 400 })
}
const parseDate = (dateStr: string | undefined): Date => {
if (!dateStr) return new Date()
const parsed = new Date(dateStr)
return isNaN(parsed.getTime()) ? new Date() : parsed
}
let processed = 0
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 {
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++
}
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 },
})
}
}
}
})
return NextResponse.json({ success: true, count: processed }, { status: 201 })
}

View File

@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { updateNoteSchema } from '@/lib/validators'
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 })
}
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: {},
})
return { tagId: tag.id }
})
),
} : undefined,
},
include: { tags: { include: { tag: true } } },
})
return NextResponse.json(note)
}
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 })
}

View File

@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { noteSchema } from '@/lib/validators'
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 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: {},
})
return { tagId: tag.id }
})
),
} : undefined,
},
include: { tags: { include: { tag: true } } },
})
return NextResponse.json(note, { status: 201 })
}

View File

@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
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 } },
]
}
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)
}

View File

@@ -0,0 +1,31 @@
import { prisma } from '@/lib/prisma'
import { notFound } from 'next/navigation'
import { NoteForm } from '@/components/note-form'
import { NoteType } from '@/types/note'
export default async function EditNotePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const note = await prisma.note.findUnique({
where: { id },
include: { tags: { include: { tag: true } } },
})
if (!note) {
notFound()
}
const noteWithTags = {
...note,
createdAt: note.createdAt.toISOString(),
updatedAt: note.updatedAt.toISOString(),
type: note.type as NoteType,
tags: note.tags.map(nt => ({ tag: nt.tag })),
}
return (
<main className="container mx-auto py-8 px-4">
<h1 className="text-2xl font-bold mb-6">Editar nota</h1>
<NoteForm initialData={noteWithTags} isEdit />
</main>
)
}

View File

@@ -1,6 +1,7 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@import "shadcn/tailwind.css"; @import "shadcn/tailwind.css";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));

View File

@@ -1,33 +1,25 @@
import type { Metadata } from "next"; import type { Metadata } from 'next'
import { Geist, Geist_Mono } from "next/font/google"; import './globals.css'
import "./globals.css"; import { Toaster } from '@/components/ui/sonner'
import { Header } from '@/components/header'
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: 'Recall - Gestor de Conocimiento Personal',
description: "Generated by create next app", description: 'Captura rápido, relaciona solo, encuentra cuando importa',
}; }
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: {
children: React.ReactNode; children: React.ReactNode
}>) { }) {
return ( return (
<html <html lang="es">
lang="en" <body className="min-h-screen bg-white">
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} <Header />
> {children}
<body className="min-h-full flex flex-col">{children}</body> <Toaster />
</body>
</html> </html>
); )
} }

10
src/app/new/page.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { NoteForm } from '@/components/note-form'
export default function NewNotePage() {
return (
<main className="container mx-auto py-8 px-4">
<h1 className="text-2xl font-bold mb-6">Crear nueva nota</h1>
<NoteForm />
</main>
)
}

14
src/app/not-found.tsx Normal file
View File

@@ -0,0 +1,14 @@
import Link from 'next/link'
import { Button } from '@/components/ui/button'
export default function NotFound() {
return (
<div className="container mx-auto py-16 text-center">
<h1 className="text-4xl font-bold mb-4">404</h1>
<p className="text-gray-600 mb-6">Página no encontrada</p>
<Link href="/">
<Button>Volver al inicio</Button>
</Link>
</div>
)
}

View File

@@ -0,0 +1,93 @@
import { prisma } from '@/lib/prisma'
import { notFound } from 'next/navigation'
import { RelatedNotes } from '@/components/related-notes'
import { getRelatedNotes } from '@/lib/related'
import { MarkdownContent } from '@/components/markdown-content'
import { DeleteNoteButton } from '@/components/delete-note-button'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { ArrowLeft, Edit, Heart, Pin } from 'lucide-react'
import { NoteType } from '@/types/note'
const typeColors: Record<string, string> = {
command: 'bg-green-100 text-green-800',
snippet: 'bg-blue-100 text-blue-800',
decision: 'bg-purple-100 text-purple-800',
recipe: 'bg-orange-100 text-orange-800',
procedure: 'bg-yellow-100 text-yellow-800',
inventory: 'bg-gray-100 text-gray-800',
note: 'bg-slate-100 text-slate-800',
}
export default async function NoteDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const note = await prisma.note.findUnique({
where: { id },
include: { tags: { include: { tag: true } } },
})
if (!note) {
notFound()
}
const related = await getRelatedNotes(id, 5)
const noteType = note.type as NoteType
return (
<main className="container mx-auto py-8 px-4 max-w-4xl">
<div className="mb-6">
<Link href="/notes">
<Button variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4 mr-1" /> Volver
</Button>
</Link>
</div>
<div className="flex items-start justify-between gap-4 mb-6">
<div>
<h1 className="text-3xl font-bold mb-2">{note.title}</h1>
<div className="flex items-center gap-3">
<Badge className={typeColors[noteType] || typeColors.note}>
{noteType}
</Badge>
{note.isFavorite && <Heart className="h-5 w-5 text-pink-500 fill-pink-500" />}
{note.isPinned && <Pin className="h-5 w-5 text-amber-500" />}
<span className="text-sm text-gray-500">
Actualizada: {new Date(note.updatedAt).toLocaleDateString('en-CA')}
</span>
</div>
</div>
<div className="flex gap-2">
<Link href={`/edit/${note.id}`}>
<Button variant="outline" size="sm">
<Edit className="h-4 w-4 mr-1" /> Editar
</Button>
</Link>
<DeleteNoteButton noteId={note.id} noteTitle={note.title} />
</div>
</div>
{note.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mb-6">
{note.tags.map(({ tag }) => (
<Link key={tag.id} href={`/notes?tag=${tag.name}`}>
<Badge variant="outline" className="cursor-pointer hover:bg-gray-100">
{tag.name}
</Badge>
</Link>
))}
</div>
)}
<div className="mb-8">
<MarkdownContent content={note.content} className="bg-gray-50 p-4 rounded-lg border" />
</div>
{related.length > 0 && (
<RelatedNotes notes={related} />
)}
</main>
)
}

92
src/app/notes/page.tsx Normal file
View File

@@ -0,0 +1,92 @@
import { prisma } from '@/lib/prisma'
import { NoteList } from '@/components/note-list'
import { SearchBar } from '@/components/search-bar'
import { TagFilter } from '@/components/tag-filter'
import { NoteType } from '@/types/note'
const NOTE_TYPES: NoteType[] = ['command', 'snippet', 'decision', 'recipe', 'procedure', 'inventory', 'note']
interface SearchParams {
q?: string
type?: string
tag?: string
}
async function searchNotes(searchParams: SearchParams) {
const where: Record<string, unknown> = {}
if (searchParams.q) {
where.OR = [
{ title: { contains: searchParams.q } },
{ content: { contains: searchParams.q } },
]
}
if (searchParams.type && NOTE_TYPES.includes(searchParams.type as NoteType)) {
where.type = searchParams.type
}
if (searchParams.tag) {
where.tags = {
some: {
tag: { name: searchParams.tag },
},
}
}
const notes = await prisma.note.findMany({
where,
include: { tags: { include: { tag: true } } },
orderBy: [{ isPinned: 'desc' }, { updatedAt: 'desc' }],
})
return notes
}
async function getAllTags() {
const tags = await prisma.tag.findMany({
orderBy: { name: 'asc' },
})
return tags.map((t) => t.name)
}
export default async function NotesPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const params = await searchParams
const [notes, tags] = await Promise.all([searchNotes(params), getAllTags()])
const notesWithTags = notes.map(note => ({
...note,
type: note.type as NoteType,
createdAt: note.createdAt.toISOString(),
updatedAt: note.updatedAt.toISOString(),
tags: note.tags.map(nt => ({ tag: nt.tag })),
}))
const hasFilters = params.q || params.type || params.tag
return (
<main className="container mx-auto py-8 px-4">
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between mb-6">
<h1 className="text-2xl font-bold">
{hasFilters ? 'Resultados de búsqueda' : 'Todas las notas'}
</h1>
<div className="flex flex-col sm:flex-row gap-2 items-stretch sm:items-center w-full sm:w-auto">
<div className="w-full sm:w-auto">
<SearchBar />
</div>
<TagFilter tags={tags} selectedTag={params.tag || null} />
</div>
</div>
{hasFilters && (
<div className="flex flex-wrap gap-2 mb-4">
{params.q && <span className="text-sm">Búsqueda: &quot;{params.q}&quot;</span>}
{params.type && <span className="text-sm">Tipo: {params.type}</span>}
{params.tag && <span className="text-sm">Tag: {params.tag}</span>}
</div>
)}
<NoteList notes={notesWithTags} />
</main>
)
}

View File

@@ -1,65 +1,37 @@
import Image from "next/image"; import { prisma } from '@/lib/prisma'
import { Dashboard } from '@/components/dashboard'
import { NoteType } from '@/types/note'
export default function Home() { async function getNotes() {
return ( const notes = await prisma.note.findMany({
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black"> include: { tags: { include: { tag: true } } },
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start"> orderBy: { updatedAt: 'desc' },
<Image })
className="dark:invert" return notes
src="/next.svg" }
alt="Next.js logo"
width={100} export default async function HomePage() {
height={20} const allNotes = await getNotes()
priority
/> const notesWithTags = allNotes.map(note => ({
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left"> ...note,
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50"> createdAt: note.createdAt.toISOString(),
To get started, edit the page.tsx file. updatedAt: note.updatedAt.toISOString(),
</h1> type: note.type as NoteType,
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400"> tags: note.tags.map(nt => ({ tag: nt.tag })),
Looking for a starting point or more instructions? Head over to{" "} }))
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" const recentNotes = notesWithTags.slice(0, 6)
className="font-medium text-zinc-950 dark:text-zinc-50" const favoriteNotes = notesWithTags.filter(n => n.isFavorite)
> const pinnedNotes = notesWithTags.filter(n => n.isPinned)
Templates
</a>{" "} return (
or the{" "} <main className="container mx-auto pt-8 px-4">
<a <Dashboard
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" recentNotes={recentNotes}
className="font-medium text-zinc-950 dark:text-zinc-50" favoriteNotes={favoriteNotes}
> pinnedNotes={pinnedNotes}
Learning />
</a>{" "} </main>
center. )
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
} }

150
src/app/settings/page.tsx Normal file
View File

@@ -0,0 +1,150 @@
'use client'
import { useState, useRef } from 'react'
import { Download, Upload } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { toast } from 'sonner'
function parseMarkdownToNote(content: string, filename: string) {
const lines = content.split('\n')
let title = filename.replace(/\.md$/, '')
let body = content
const firstHeadingMatch = content.match(/^#\s+(.+)$/m)
if (firstHeadingMatch) {
title = firstHeadingMatch[1].trim()
const headingIndex = content.indexOf(firstHeadingMatch[0])
body = content.slice(headingIndex + firstHeadingMatch[0].length).trim()
}
return {
title,
content: body,
type: 'note',
}
}
export default function SettingsPage() {
const [importing, setImporting] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const handleExport = async () => {
try {
const response = await fetch('/api/export-import')
if (!response.ok) {
throw new Error('Error al exportar')
}
const data = await response.json()
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const date = new Date().toISOString().split('T')[0]
const a = document.createElement('a')
a.href = url
a.download = `recall-backup-${date}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
toast.success('Notas exportadas correctamente')
} catch {
toast.error('Error al exportar las notas')
}
}
const handleImport = async () => {
const file = fileInputRef.current?.files?.[0]
if (!file) {
toast.error('Selecciona un archivo JSON o MD')
return
}
setImporting(true)
try {
const text = await file.text()
const isMarkdown = file.name.endsWith('.md')
let payload: object[]
if (isMarkdown) {
const note = parseMarkdownToNote(text, file.name)
payload = [note]
} else {
payload = JSON.parse(text)
}
const response = await fetch('/api/export-import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const result = await response.json()
if (!response.ok) {
throw new Error(result.error || 'Error al importar')
}
toast.success(`${result.count} nota${result.count !== 1 ? 's' : ''} importada${result.count !== 1 ? 's' : ''} correctamente`)
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Error al importar las notas')
} finally {
setImporting(false)
}
}
return (
<main className="container mx-auto py-8 px-4">
<h1 className="text-2xl font-bold mb-6">Configuración</h1>
<div className="grid gap-6 max-w-xl">
<Card>
<CardHeader>
<CardTitle>Exportar notas</CardTitle>
<CardDescription>
Descarga todas tus notas en formato JSON. El archivo incluye títulos, contenido, tipos y tags.
</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={handleExport} className="gap-2">
<Download className="h-4 w-4" />
Exportar
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Importar notas</CardTitle>
<CardDescription>
Importa notas desde archivos JSON o MD. En archivos MD, el primer heading (#) se usa como título.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<input
ref={fileInputRef}
type="file"
accept=".json,.md"
className="block w-full text-sm text-muted-foreground file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border file:border-input file:text-sm file:font-medium file:bg-background hover:file:bg-muted"
/>
<Button
onClick={handleImport}
disabled={importing}
variant="outline"
className="gap-2"
>
<Upload className="h-4 w-4" />
{importing ? 'Importando...' : 'Importar'}
</Button>
</CardContent>
</Card>
</div>
</main>
)
}

View File

@@ -0,0 +1,63 @@
'use client'
import Link from 'next/link'
import { Note } from '@/types/note'
import { NoteList } from './note-list'
import { Button } from '@/components/ui/button'
import { SearchBar } from './search-bar'
import { ArrowRight } from 'lucide-react'
export function Dashboard({ recentNotes, favoriteNotes, pinnedNotes }: {
recentNotes: Note[]
favoriteNotes: Note[]
pinnedNotes: Note[]
}) {
return (
<>
<div className="flex justify-end mb-3">
<SearchBar />
</div>
<div className="space-y-8">
{pinnedNotes.length > 0 && (
<section>
<h2 className="text-xl font-semibold mb-3 flex items-center gap-2">
📌 Pineadas
</h2>
<NoteList notes={pinnedNotes} />
</section>
)}
{favoriteNotes.length > 0 && (
<section>
<h2 className="text-xl font-semibold mb-3 flex items-center gap-2">
Favoritas
</h2>
<NoteList notes={favoriteNotes} />
</section>
)}
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="text-xl font-semibold">Recientes</h2>
<Link href="/notes">
<Button variant="ghost" size="sm" className="gap-1">
Ver todas <ArrowRight className="h-4 w-4" />
</Button>
</Link>
</div>
{recentNotes.length > 0 ? (
<NoteList notes={recentNotes} />
) : (
<div className="text-center py-8 text-gray-500">
<p>No hay notas todavía.</p>
<Link href="/new">
<Button className="mt-4">Crea tu primera nota</Button>
</Link>
</div>
)}
</section>
</div>
</>
)
}

View File

@@ -0,0 +1,68 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
interface DeleteNoteButtonProps {
noteId: string
noteTitle: string
}
export function DeleteNoteButton({ noteId, noteTitle }: DeleteNoteButtonProps) {
const [open, setOpen] = useState(false)
const [deleting, setDeleting] = useState(false)
const router = useRouter()
const handleDelete = async () => {
setDeleting(true)
try {
const response = await fetch(`/api/notes/${noteId}`, {
method: 'DELETE',
})
if (response.ok) {
setOpen(false)
router.push('/notes')
router.refresh()
}
} catch {
setDeleting(false)
}
}
return (
<>
<Button variant="destructive" size="sm" onClick={() => setOpen(true)}>
<Trash2 className="h-4 w-4 mr-1" /> Eliminar
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Eliminar nota</DialogTitle>
<DialogDescription>
¿Estás seguro de que quieres eliminar &quot;{noteTitle}&quot;? Esta acción no se puede deshacer.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Cancelar
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={deleting}>
{deleting ? 'Eliminando...' : 'Eliminar'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

50
src/components/header.tsx Normal file
View File

@@ -0,0 +1,50 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Plus, FileText, Settings } from 'lucide-react'
export function Header() {
const pathname = usePathname()
return (
<header className="sticky top-0 z-40 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container mx-auto px-4 flex h-14 items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/" className="flex items-center gap-2">
<span className="text-xl font-bold">Recall</span>
</Link>
<nav className="flex items-center gap-1">
<Link href="/notes">
<Button
variant={pathname === '/notes' ? 'secondary' : 'ghost'}
size="sm"
className="gap-1.5"
>
<FileText className="h-4 w-4" />
Notas
</Button>
</Link>
<Link href="/settings">
<Button
variant={pathname === '/settings' ? 'secondary' : 'ghost'}
size="sm"
className="gap-1.5"
>
<Settings className="h-4 w-4" />
Configuración
</Button>
</Link>
</nav>
</div>
<Link href="/new">
<Button size="sm" className="gap-1.5">
<Plus className="h-4 w-4" />
Nueva nota
</Button>
</Link>
</div>
</header>
)
}

View File

@@ -0,0 +1,19 @@
'use client'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
interface MarkdownContentProps {
content: string
className?: string
}
export function MarkdownContent({ content, className = '' }: MarkdownContentProps) {
return (
<div className={`prose max-w-none ${className}`}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{content}
</ReactMarkdown>
</div>
)
}

View File

@@ -0,0 +1,56 @@
'use client'
import Link from 'next/link'
import { Note } from '@/types/note'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
const typeColors: Record<string, string> = {
command: 'bg-green-100 text-green-800',
snippet: 'bg-blue-100 text-blue-800',
decision: 'bg-purple-100 text-purple-800',
recipe: 'bg-orange-100 text-orange-800',
procedure: 'bg-yellow-100 text-yellow-800',
inventory: 'bg-gray-100 text-gray-800',
note: 'bg-slate-100 text-slate-800',
}
export function NoteCard({ note }: { note: Note }) {
const preview = note.content.slice(0, 100) + (note.content.length > 100 ? '...' : '')
const typeColor = typeColors[note.type] || typeColors.note
return (
<Link href={`/notes/${note.id}`}>
<Card className="hover:shadow-md transition-shadow cursor-pointer h-full">
<CardContent className="p-4">
<div className="flex items-start justify-between gap-2 mb-2">
<h3 className="font-semibold text-lg line-clamp-1">{note.title}</h3>
<div className="flex items-center gap-1">
{note.isPinned && <span className="text-amber-500">📌</span>}
{note.isFavorite && <span className="text-pink-500"></span>}
</div>
</div>
<div className="flex items-center gap-2 mb-2">
<Badge className={typeColor}>{note.type}</Badge>
<span className="text-xs text-gray-500">
{new Date(note.updatedAt).toLocaleDateString('en-CA')}
</span>
</div>
<p className="text-sm text-gray-600 line-clamp-2 mb-2">{preview}</p>
{note.tags && note.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{note.tags.map(({ tag }) => (
<Badge key={tag.id} variant="outline" className="text-xs">
{tag.name}
</Badge>
))}
</div>
)}
</CardContent>
</Card>
</Link>
)
}

View File

@@ -0,0 +1,160 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Note, NoteType } 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'
const noteTypes: NoteType[] = ['command', 'snippet', 'decision', 'recipe', 'procedure', 'inventory', 'note']
interface NoteFormProps {
initialData?: Note
isEdit?: boolean
}
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 [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))
}
}
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,
type,
isFavorite,
isPinned,
tags,
}
try {
const url = isEdit && initialData ? `/api/notes/${initialData.id}` : '/api/notes'
const method = isEdit ? 'PUT' : 'POST'
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(noteData),
})
if (res.ok) {
router.push('/notes')
router.refresh()
}
} catch (error) {
console.error('Error saving note:', error)
} finally {
setIsSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4 max-w-2xl">
<div>
<label className="block text-sm font-medium mb-1">Título</label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Título de la nota"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Tipo</label>
<Select value={type} onValueChange={(v) => handleTypeChange(v as NoteType)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{noteTypes.map((t) => (
<SelectItem key={t} value={t}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<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
/>
</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>
)}
</div>
<div className="flex gap-4">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={isFavorite}
onChange={(e) => setIsFavorite(e.target.checked)}
/>
<span className="text-sm">Favorita</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={isPinned}
onChange={(e) => setIsPinned(e.target.checked)}
/>
<span className="text-sm">Pineada</span>
</label>
</div>
<div className="flex gap-2">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Guardando...' : isEdit ? 'Actualizar' : 'Crear nota'}
</Button>
<Button type="button" variant="outline" onClick={() => router.back()}>
Cancelar
</Button>
</div>
</form>
)
}

View File

@@ -0,0 +1,23 @@
'use client'
import { Note } from '@/types/note'
import { NoteCard } from './note-card'
export function NoteList({ notes }: { notes: Note[] }) {
if (notes.length === 0) {
return (
<div className="text-center py-12 text-gray-500">
<p className="text-lg">No hay notas todavía</p>
<p className="text-sm">Crea tu primera nota para comenzar</p>
</div>
)
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{notes.map((note) => (
<NoteCard key={note.id} note={note} />
))}
</div>
)
}

View File

@@ -0,0 +1,50 @@
'use client'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
interface RelatedNote {
id: string
title: string
type: string
tags: string[]
score: number
reason: string
}
export function RelatedNotes({ notes }: { notes: RelatedNote[] }) {
if (notes.length === 0) return null
return (
<Card>
<CardHeader>
<CardTitle className="text-lg">Notas relacionadas</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{notes.map((note) => (
<Link key={note.id} href={`/notes/${note.id}`}>
<div className="p-3 rounded-lg border hover:bg-gray-50 transition-colors">
<div className="flex items-center justify-between mb-1">
<span className="font-medium text-sm">{note.title}</span>
<Badge variant="outline" className="text-xs">{note.type}</Badge>
</div>
<p className="text-xs text-gray-500 line-clamp-1">{note.reason}</p>
{note.tags.length > 0 && (
<div className="flex gap-1 mt-1">
{note.tags.slice(0, 3).map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
)}
</div>
</Link>
))}
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,34 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Search } from 'lucide-react'
export function SearchBar() {
const [query, setQuery] = useState('')
const router = useRouter()
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
if (query.trim()) {
router.push(`/notes?q=${encodeURIComponent(query)}`)
}
}
return (
<form onSubmit={handleSearch} className="flex gap-2 w-full">
<Input
type="text"
placeholder="Buscar notas..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="flex-1 min-w-0"
/>
<Button type="submit" variant="secondary" size="icon">
<Search className="h-4 w-4" />
</Button>
</form>
)
}

View File

@@ -0,0 +1,74 @@
'use client'
import * as React from 'react'
import { useRouter } from 'next/navigation'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { X, Search } from 'lucide-react'
interface TagFilterProps {
tags: string[]
selectedTag: string | null
}
export function TagFilter({ tags, selectedTag }: TagFilterProps) {
const router = useRouter()
const [search, setSearch] = React.useState('')
const filteredTags = tags.filter(tag =>
tag.toLowerCase().includes(search.toLowerCase())
)
const handleValueChange = (value: string | null) => {
if (!value || value === 'all') {
router.push('/notes')
} else {
router.push(`/notes?tag=${encodeURIComponent(value)}`)
}
}
const handleClearFilter = () => {
router.push('/notes')
}
return (
<div className="flex gap-2 items-center">
<div className="relative flex-1 min-w-0">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
className="w-full pl-7 h-8"
placeholder="Buscar tag..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<Select value={selectedTag || 'all'} onValueChange={handleValueChange}>
<SelectTrigger className="w-[140px] sm:w-[180px]">
<SelectValue placeholder="Todos" />
</SelectTrigger>
<SelectContent>
<div className="max-h-60 overflow-y-auto">
<SelectItem value="all">Todos los tags</SelectItem>
{filteredTags.length === 0 ? (
<div className="px-2 py-1.5 text-sm text-muted-foreground">
{search ? 'Sin resultados' : 'No hay tags'}
</div>
) : (
filteredTags.map((tag) => (
<SelectItem key={tag} value={tag}>
{tag}
</SelectItem>
))
)}
</div>
</SelectContent>
</Select>
{selectedTag && (
<Button variant="ghost" size="icon-xs" onClick={handleClearFilter}>
<X className="h-3 w-3" />
</Button>
)}
</div>
)
}

View File

@@ -0,0 +1,109 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}

View File

@@ -0,0 +1,52 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }

103
src/components/ui/card.tsx Normal file
View File

@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 text-sm ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,268 @@
"use client"
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,201 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,49 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster }

View File

@@ -0,0 +1,82 @@
"use client"
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

9
src/lib/prisma.ts Normal file
View File

@@ -0,0 +1,9 @@
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma

81
src/lib/related.ts Normal file
View File

@@ -0,0 +1,81 @@
import { prisma } from '@/lib/prisma'
interface ScoredNote {
id: string
title: string
type: string
tags: string[]
score: number
reason: string
}
export async function getRelatedNotes(noteId: string, limit = 5): Promise<ScoredNote[]> {
const note = await prisma.note.findUnique({
where: { id: noteId },
include: { tags: { include: { tag: true } } },
})
if (!note) return []
const noteTagNames = note.tags.map(t => t.tag.name)
const noteWords = note.title.toLowerCase().split(/\s+/).filter(w => w.length > 2)
const noteContentWords = note.content.toLowerCase().split(/\s+/).filter(w => w.length > 4)
const allNotes = await prisma.note.findMany({
where: { id: { not: noteId } },
include: { tags: { include: { tag: true } } },
})
const scored: ScoredNote[] = []
for (const other of allNotes) {
let score = 0
const reasons: string[] = []
// +3 si comparten tipo
if (other.type === note.type) {
score += 3
reasons.push(`Same type (${note.type})`)
}
// +2 por cada tag compartido
const sharedTags = noteTagNames.filter(t => other.tags.some(ot => ot.tag.name === t))
score += sharedTags.length * 2
if (sharedTags.length > 0) {
reasons.push(`Shared tags: ${sharedTags.join(', ')}`)
}
// +1 por palabra relevante compartida en título
const sharedTitleWords = noteWords.filter(w =>
other.title.toLowerCase().includes(w)
)
score += Math.min(sharedTitleWords.length, 2) // max +2
if (sharedTitleWords.length > 0) {
reasons.push(`Title match: ${sharedTitleWords.slice(0, 2).join(', ')}`)
}
// +1 si keyword del contenido aparece en ambas
const sharedContentWords = noteContentWords.filter(w =>
other.content.toLowerCase().includes(w)
)
score += Math.min(sharedContentWords.length, 2) // max +2
if (sharedContentWords.length > 0) {
reasons.push(`Content: ${sharedContentWords.slice(0, 2).join(', ')}`)
}
if (score > 0) {
scored.push({
id: other.id,
title: other.title,
type: other.type,
tags: other.tags.map(t => t.tag.name),
score,
reason: reasons.join(' | '),
})
}
}
return scored
.sort((a, b) => b.score - a.score)
.slice(0, limit)
}

24
src/lib/tags.ts Normal file
View File

@@ -0,0 +1,24 @@
const TAG_KEYWORDS: Record<string, string[]> = {
code: ['code', 'function', 'class', 'algorithm', 'programming', 'javascript', 'typescript', 'python', 'react'],
bash: ['bash', 'shell', 'command', 'terminal', 'script', 'cli'],
sql: ['sql', 'database', 'query', 'table', 'select', 'insert'],
cocina: ['receta', 'cocina', 'comida', 'horno', 'sartén', 'ingrediente'],
hogar: ['casa', 'hogar', 'inventario', 'almacen', 'cocina', 'baño'],
arquitectura: ['arquitectura', 'design', 'pattern', 'system', 'microservice', 'api'],
backend: ['backend', 'server', 'database', 'api', 'endpoint'],
frontend: ['frontend', 'ui', 'react', 'component', 'css', 'tailwind'],
devops: ['docker', 'kubernetes', 'deploy', 'ci/cd', 'pipeline', 'cloud'],
}
export function suggestTags(title: string, content: string): string[] {
const text = `${title} ${content}`.toLowerCase()
const suggested: string[] = []
for (const [tag, keywords] of Object.entries(TAG_KEYWORDS)) {
if (keywords.some(keyword => text.includes(keyword))) {
suggested.push(tag)
}
}
return suggested.slice(0, 3)
}

60
src/lib/templates.ts Normal file
View File

@@ -0,0 +1,60 @@
export const templates: Record<string, string> = {
command: `## Comando
## Qué hace
## Cuándo usarlo
## Ejemplo
\`\`\`bash
\`\`\`
`,
snippet: `## Snippet
## Lenguaje
## Qué resuelve
## Notas
`,
decision: `## Contexto
## Decisión
## Alternativas consideradas
## Consecuencias
`,
recipe: `## Ingredientes
## Pasos
## Tiempo
## Notas
`,
procedure: `## Objetivo
## Pasos
## Requisitos
## Problemas comunes
`,
inventory: `## Item
## Cantidad
## Ubicación
## Notas
`,
note: `## Notas
`,
}
export function getTemplate(type: string): string {
return templates[type] || templates.note
}

26
src/lib/validators.ts Normal file
View File

@@ -0,0 +1,26 @@
import { z } from 'zod'
export const NoteTypeEnum = z.enum(['command', 'snippet', 'decision', 'recipe', 'procedure', 'inventory', 'note'])
export const noteSchema = z.object({
id: z.string().optional(),
title: z.string().min(1, 'Title is required').max(200),
content: z.string().min(1, 'Content is required'),
type: NoteTypeEnum.default('note'),
isFavorite: z.boolean().default(false),
isPinned: z.boolean().default(false),
tags: z.array(z.string()).optional(),
})
export const updateNoteSchema = noteSchema.partial().extend({
id: z.string(),
})
export const searchSchema = z.object({
q: z.string().optional(),
type: NoteTypeEnum.optional(),
tag: z.string().optional(),
})
export type NoteInput = z.infer<typeof noteSchema>
export type UpdateNoteInput = z.infer<typeof updateNoteSchema>

22
src/types/note.ts Normal file
View File

@@ -0,0 +1,22 @@
export type NoteType = 'command' | 'snippet' | 'decision' | 'recipe' | 'procedure' | 'inventory' | 'note'
export interface Tag {
id: string
name: string
}
export interface NoteTag {
tag: Tag
}
export interface Note {
id: string
title: string
content: string
type: NoteType
isFavorite: boolean
isPinned: boolean
createdAt: string
updatedAt: string
tags: NoteTag[]
}

View File

@@ -30,5 +30,5 @@
".next/dev/types/**/*.ts", ".next/dev/types/**/*.ts",
"**/*.mts" "**/*.mts"
], ],
"exclude": ["node_modules"] "exclude": ["node_modules", "prisma/seed.ts"]
} }