Compare commits

...

2 Commits

Author SHA1 Message Date
darroyo 6694bce736 mvp 2026-03-22 13:01:46 -03:00
darroyo af0910f428 feat: initial commit 2026-03-22 09:18:07 -03:00
66 changed files with 15186 additions and 0 deletions
+15
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:*)"
]
}
}
+43
View File
@@ -0,0 +1,43 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
/src/generated/prisma
+5
View File
@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
+29
View File
@@ -0,0 +1,29 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Status
This is a new project in its initial state. The repository has been initialized with:
- `main` branch (production)
- `develop` branch (development)
No source code, build configuration, or tests exist yet.
## Architecture
Once code is added, document:
- Tech stack and frameworks
- High-level component structure
- Key architectural patterns
- API design (if applicable)
## Commands
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.
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
BIN
View File
Binary file not shown.
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+761
View File
@@ -0,0 +1,761 @@
# MVP — Gestor de conocimiento personal práctico
Documento de ejecución pensado para usar directamente con Claude Code.
---
## 1. Objetivo del MVP
Construir una aplicación web local-first para gestión de conocimiento personal enfocada en **captura rápida**, **relación automática** y **recuperación útil**.
No es una app de notas genérica. El MVP debe resolver bien estos casos:
- guardar comandos frecuentes
- guardar snippets de código
- registrar decisiones técnicas
- guardar recetas
- guardar trámites o procedimientos
- mantener inventario doméstico simple
La propuesta de valor del MVP es:
1. **Guardar info rápido** con fricción mínima.
2. **Relacionarla sola** usando metadatos, enlaces sugeridos y contenido similar.
3. **Devolverla cuando importa** mediante búsqueda potente y vistas útiles.
---
## 2. Principios del producto
### 2.1 Principios funcionales
- Crear una nota debe tomar menos de 10 segundos.
- La búsqueda debe encontrar contenido por título, texto, tags y tipo.
- La app debe sugerir relaciones sin exigir organización manual compleja.
- El sistema debe ser útil desde el día 1 con pocas notas.
- Debe funcionar bien para conocimiento práctico, no solo escritura larga.
### 2.2 Principios técnicos
- **Local-first**: los datos viven primero en el dispositivo.
- **Simple de desplegar y mantener**.
- **Escalable por capas**: empezar pequeño sin bloquear futuras mejoras.
- **Estructura tipada**: tipos de contenido claros.
- **Extensible** para futuro tagging semántico, embeddings y sincronización.
---
## 3. Alcance del MVP
### 3.1 Incluye
#### Captura
- Crear nota rápida desde un input o modal.
- Campos mínimos:
- título
- contenido
- tipo
- tags manuales opcionales
- Crear desde plantillas simples según tipo.
#### Tipos de nota del MVP
- `command`
- `snippet`
- `decision`
- `recipe`
- `procedure`
- `inventory`
- `note` (genérico)
#### Organización automática
- Extracción automática de:
- fecha de creación
- fecha de actualización
- tipo
- tags sugeridos por heurística simple
- Detección de notas relacionadas por:
- coincidencia de tags
- similitud de título
- palabras clave compartidas
- mismo tipo
#### Recuperación
- Búsqueda full-text.
- Filtros por tipo y tags.
- Vista de resultados ordenada por relevancia simple.
- Vista de “relacionadas” dentro de cada nota.
- Vista de “recientes”.
#### Edición
- Editar nota existente.
- Eliminar nota.
- Marcar favorita.
- Pin opcional para destacar notas críticas.
#### Persistencia
- Base local con SQLite.
- Exportar/importar JSON.
---
### 3.2 No incluye en el MVP
- colaboración multiusuario
- sincronización en la nube
- permisos y autenticación compleja
- edición en tiempo real
- embeddings/vector DB en producción
- OCR
- app móvil nativa
- plugins
- automatizaciones complejas
- parser avanzado de documentos adjuntos
---
## 4. Stack sugerido
## Frontend
- **Next.js 15** con App Router
- **TypeScript**
- **Tailwind CSS**
- **shadcn/ui** para componentes
## Backend
- API routes / server actions de Next.js
- **Prisma** como ORM
- **SQLite** para MVP
## Búsqueda
- inicialmente con consultas SQL + normalización simple
- opcional: SQLite FTS si da tiempo
## Validación
- **Zod**
## Estado
- React server components + estado local mínimo
- si hace falta: Zustand muy limitado
## Testing
- **Vitest** para lógica
- **Playwright** para flujo principal si alcanza
## Motivo de esta elección
Este stack permite:
- iterar rápido
- mantener una sola codebase
- ejecutar localmente fácil
- migrar luego a Postgres sin rehacer todo
---
## 5. Arquitectura funcional
### 5.1 Entidades principales
#### Note
Campos sugeridos:
- `id`
- `title`
- `content`
- `type`
- `createdAt`
- `updatedAt`
- `isFavorite`
- `isPinned`
#### Tag
- `id`
- `name`
#### NoteTag
- `noteId`
- `tagId`
#### RelatedNote
- `id`
- `sourceNoteId`
- `targetNoteId`
- `score`
- `reason`
Opcional en MVP si se prefiere calcular en runtime en vez de persistir.
---
### 5.2 Modelo de datos recomendado
```prisma
model Note {
id String @id @default(cuid())
title String
content String
type NoteType @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])
}
enum NoteType {
command
snippet
decision
recipe
procedure
inventory
note
}
```
---
## 6. UX mínima del MVP
### 6.1 Pantallas
#### Home / Dashboard
Debe mostrar:
- barra de búsqueda principal
- botón “Nueva nota”
- sección de notas recientes
- sección de favoritas o pineadas
- filtros rápidos por tipo
#### Lista de notas
- búsqueda
- filtros por tipo
- filtros por tags
- cards compactas con:
- título
- tipo
- preview corta
- tags
- fecha de actualización
#### Detalle de nota
- título
- tipo
- contenido
- tags
- acciones: editar, borrar, favorita, pin
- bloque “Notas relacionadas”
#### Crear/editar nota
- formulario simple
- selector de tipo
- área de contenido grande
- campo de tags opcional
- templates por tipo
---
### 6.2 Flujo ideal
1. Usuario abre app.
2. Escribe una nota nueva en segundos.
3. La nota se guarda localmente.
4. El sistema sugiere tags y notas relacionadas.
5. Días después el usuario la encuentra por búsqueda, filtro o relación.
---
## 7. Lógica de negocio del MVP
### 7.1 Reglas de creación
Al crear una nota:
- validar que título y contenido no estén vacíos
- normalizar espacios
- guardar tipo
- parsear tags manuales si existen
- generar tags sugeridos por heurística opcional
### 7.2 Heurísticas de tags sugeridos
Heurística simple inicial:
- extraer palabras frecuentes relevantes del título
- detectar bloques de código para sugerir `code`, `bash`, `sql`, etc.
- detectar patrones:
- receta → `cocina`
- decisión → `arquitectura`, `backend`, etc. según keywords
- inventario → `hogar`
No hace falta IA real en MVP. Debe ser determinístico y simple.
### 7.3 Cálculo de relacionadas
Implementación simple:
- +3 puntos si comparten tipo
- +2 por cada tag compartido
- +1 por palabra relevante compartida en título
- +1 si una keyword del contenido aparece en ambas
Mostrar top 5 relacionadas con score > umbral.
Se puede calcular:
- on-demand en el detalle de la nota, o
- al guardar/editar la nota
Para el MVP, **on-demand** es suficiente.
### 7.4 Búsqueda
Primera versión:
- buscar en `title`
- buscar en `content`
- buscar en nombres de tags
- permitir filtro por `type`
Orden sugerido:
1. coincidencia en título
2. coincidencia en tags
3. coincidencia en contenido
4. updatedAt desc
---
## 8. Templates por tipo
### command
Campos base:
- título
- comando
- explicación
- contexto de uso
Template de contenido sugerido:
```md
## Comando
## Qué hace
## Cuándo usarlo
## Ejemplo
```
### snippet
```md
## Snippet
## Lenguaje
## Qué resuelve
## Notas
```
### decision
```md
## Contexto
## Decisión
## Alternativas consideradas
## Consecuencias
```
### recipe
```md
## Ingredientes
## Pasos
## Tiempo
## Notas
```
### procedure
```md
## Objetivo
## Pasos
## Requisitos
## Problemas comunes
```
### inventory
```md
## Item
## Cantidad
## Ubicación
## Notas
```
---
## 9. Estructura de carpetas sugerida
```txt
src/
app/
page.tsx
notes/
page.tsx
[id]/page.tsx
new/page.tsx
edit/[id]/page.tsx
api/
notes/route.ts
notes/[id]/route.ts
search/route.ts
components/
note-form.tsx
note-card.tsx
note-list.tsx
search-bar.tsx
related-notes.tsx
filters.tsx
dashboard.tsx
lib/
prisma.ts
db.ts
search.ts
related.ts
tags.ts
templates.ts
validators.ts
types/
note.ts
prisma/
schema.prisma
```
---
## 10. API / acciones mínimas
### CRUD de notas
- `GET /api/notes`
- `POST /api/notes`
- `GET /api/notes/:id`
- `PUT /api/notes/:id`
- `DELETE /api/notes/:id`
### búsqueda
- `GET /api/search?q=...&type=...&tag=...`
### export/import
- `GET /api/export`
- `POST /api/import`
---
## 11. Historias de usuario principales
### HU1 — Crear nota rápida
Como usuario,
quiero crear una nota en pocos segundos,
para no perder información útil.
Criterios de aceptación:
- puedo crear una nota con título, contenido y tipo
- queda persistida localmente
- aparece en recientes inmediatamente
### HU2 — Buscar conocimiento guardado
Como usuario,
quiero encontrar una nota por texto libre,
para recuperar información cuando la necesito.
Criterios:
- la búsqueda encuentra coincidencias en título y contenido
- puedo filtrar por tipo
- resultados aparecen rápido
### HU3 — Ver contenido relacionado
Como usuario,
quiero que el sistema me muestre notas relacionadas,
para redescubrir información útil.
Criterios:
- cada nota muestra hasta 5 relacionadas
- la relación se basa en reglas simples entendibles
### HU4 — Usar plantillas
Como usuario,
quiero crear notas con estructura base según el tipo,
para capturar mejor cada caso.
Criterios:
- al elegir un tipo puedo cargar un template sugerido
- puedo editar el template libremente
### HU5 — Exportar datos
Como usuario,
quiero exportar mis datos,
para no quedar atado a la app.
Criterios:
- exporta a JSON válido
- puedo reimportar ese JSON
---
## 12. Roadmap de implementación del MVP
### Fase 1 — Base funcional
Objetivo: tener CRUD y persistencia.
Entregables:
- setup Next.js + Tailwind + Prisma + SQLite
- schema Prisma
- migraciones
- CRUD básico de notas
- listado y detalle
- formulario crear/editar
### Fase 2 — Búsqueda y filtros
Objetivo: recuperar bien.
Entregables:
- search por título/contenido
- filtros por tipo
- filtros por tags
- home con recientes y favoritas
### Fase 3 — Relación automática
Objetivo: conectar conocimiento.
Entregables:
- heurística de tags sugeridos
- cálculo de relacionadas
- UI de relacionadas en detalle
### Fase 4 — Pulido MVP
Objetivo: dejarlo presentable y usable.
Entregables:
- templates por tipo
- export/import JSON
- validaciones
- estados vacíos
- seed de ejemplo
---
## 13. Definición de terminado
El MVP está listo cuando:
- se puede crear, editar y borrar notas
- las notas se persisten en SQLite
- se puede buscar por texto
- se puede filtrar por tipo y tags
- cada nota muestra relacionadas
- existen templates por tipo
- existe export/import JSON
- la UI es suficientemente clara para uso diario local
---
## 14. Riesgos y cómo reducirlos
### Riesgo 1: demasiada ambición
Mitigación:
- no agregar sync ni IA real al MVP
- priorizar velocidad de uso y recuperación
### Riesgo 2: búsqueda pobre
Mitigación:
- priorizar calidad de búsqueda antes de features cosméticas
- evaluar SQLite FTS si la búsqueda simple queda corta
### Riesgo 3: relaciones poco útiles
Mitigación:
- mantener heurísticas transparentes
- mostrar razones simples del match en una versión futura
### Riesgo 4: modelo demasiado genérico
Mitigación:
- usar tipos concretos desde el inicio
- mantener `note` genérico solo como fallback
---
## 15. Mejoras post-MVP
- sincronización entre dispositivos
- embeddings para similitud semántica real
- parser de comandos/snippets automático
- extensión de navegador para guardar rápido
- captura por share sheet móvil
- recordatorios contextuales
- grafos de relación
- OCR y adjuntos
- versionado de notas
- vistas especializadas por tipo
---
## 16. Prompt maestro para ejecutar en Claude Code
Usar este prompt como instrucción principal:
```txt
Quiero que construyas un MVP funcional de una aplicación llamada “Gestor de conocimiento personal práctico”.
Objetivo del producto:
- guardar info rápido
- relacionarla sola
- devolverla cuando importa
Casos de uso principales:
- comandos
- snippets
- decisiones técnicas
- recetas
- trámites/procedimientos
- inventario doméstico
Stack requerido:
- Next.js 15 con App Router
- TypeScript
- Tailwind CSS
- shadcn/ui
- Prisma
- SQLite
- Zod
Requisitos funcionales del MVP:
1. CRUD completo de notas.
2. Tipos de nota: command, snippet, decision, recipe, procedure, inventory, note.
3. Formulario de creación/edición con título, contenido, tipo y tags opcionales.
4. Dashboard con búsqueda, recientes y favoritas/pineadas.
5. Lista de notas con filtros por tipo y tags.
6. Búsqueda por título, contenido y tags.
7. Página de detalle con notas relacionadas.
8. Templates simples por tipo.
9. Exportar e importar JSON.
10. Persistencia local usando SQLite.
Reglas de relacionadas:
- +3 si comparten tipo
- +2 por cada tag compartido
- +1 por palabra relevante compartida en el título
- +1 por keyword compartida en el contenido
- mostrar top 5 con score suficiente
Restricciones:
- No implementar autenticación.
- No implementar sync cloud.
- No implementar IA compleja ni vector DB.
- Mantener el código limpio, modular y listo para evolucionar.
Quiero que generes:
1. La estructura inicial del proyecto.
2. El schema de Prisma.
3. Los componentes principales.
4. Las rutas/páginas necesarias.
5. Las utilidades para búsqueda, tags y relacionadas.
6. Un seed de datos de ejemplo.
7. Instrucciones claras para correr el proyecto.
Además:
- usa buenas prácticas
- separa responsabilidades
- evita sobreingeniería
- deja comentarios donde aporten claridad
- entrega una primera versión funcional de punta a punta
```
---
## 17. Prompt por etapas para Claude Code
### Etapa 1 — Setup base
```txt
Crea el proyecto base con Next.js 15, TypeScript, Tailwind, Prisma y SQLite. Configura la estructura de carpetas, instala dependencias, crea el schema inicial de Prisma para Note, Tag y NoteTag, genera migración y deja una app corriendo con una página home básica.
```
### Etapa 2 — CRUD
```txt
Implementa el CRUD completo de notas. Debe existir listado, detalle, creación, edición y borrado. Cada nota debe tener title, content, type, tags opcionales, isFavorite e isPinned. Usa validación con Zod.
```
### Etapa 3 — Búsqueda y filtros
```txt
Implementa búsqueda por texto sobre título, contenido y tags. Agrega filtros por tipo y tags. Crea una home con notas recientes, favoritas y acceso rápido a crear nota.
```
### Etapa 4 — Relacionadas
```txt
Implementa la lógica de notas relacionadas usando reglas heurísticas simples. Muestra hasta 5 notas relacionadas en la vista de detalle.
```
### Etapa 5 — Templates y export/import
```txt
Agrega templates por tipo de nota y funciones para exportar e importar datos en JSON. Incluye manejo básico de errores y estados vacíos.
```
---
## 18. Checklist de validación manual
- [ ] La app inicia sin errores.
- [ ] Se puede crear una nota.
- [ ] Se puede editar una nota.
- [ ] Se puede eliminar una nota.
- [ ] La búsqueda encuentra texto por título.
- [ ] La búsqueda encuentra texto por contenido.
- [ ] Se puede filtrar por tipo.
- [ ] Las tags se guardan y se pueden usar como filtro.
- [ ] El detalle muestra relacionadas.
- [ ] Se puede exportar JSON.
- [ ] Se puede importar JSON.
- [ ] Hay seed de ejemplo útil para probar.
---
## 19. Criterio de éxito del MVP
El MVP será exitoso si una persona puede usarlo durante una semana para guardar y recuperar conocimiento práctico sin sentir que necesita otra herramienta para:
- recordar comandos
- reutilizar snippets
- revisar decisiones
- seguir procedimientos
- consultar inventario o recetas
---
## 20. Recomendación final
Para este MVP conviene optimizar en este orden:
1. velocidad de captura
2. calidad de búsqueda
3. utilidad de relacionadas
4. claridad visual
5. exportabilidad
No priorizar IA antes de demostrar que la base manual + heurística ya resuelve valor real.
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+11075
View File
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
{
"name": "recall",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"db:seed": "npx tsx prisma/seed.ts"
},
"prisma": {
"seed": "npx tsx prisma/seed.ts"
},
"dependencies": {
"@base-ui/react": "^1.3.0",
"@prisma/client": "^5.22.0",
"@tailwindcss/typography": "^0.5.19",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dotenv": "^17.3.1",
"lucide-react": "^0.577.0",
"next": "16.2.1",
"next-themes": "^0.4.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"shadcn": "^4.1.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20.19.37",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.1",
"prisma": "^5.22.0",
"tailwindcss": "^4",
"tsx": "^4.21.0",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
BIN
View File
Binary file not shown.
@@ -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");
+3
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
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
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())
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+165
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`
+121
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 })
}
+60
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 })
}
+43
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 })
}
+41
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)
}
+31
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>
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+131
View File
@@ -0,0 +1,131 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
+25
View File
@@ -0,0 +1,25 @@
import type { Metadata } from 'next'
import './globals.css'
import { Toaster } from '@/components/ui/sonner'
import { Header } from '@/components/header'
export const metadata: Metadata = {
title: 'Recall - Gestor de Conocimiento Personal',
description: 'Captura rápido, relaciona solo, encuentra cuando importa',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="es">
<body className="min-h-screen bg-white">
<Header />
{children}
<Toaster />
</body>
</html>
)
}
+10
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
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>
)
}
+93
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
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>
)
}
+37
View File
@@ -0,0 +1,37 @@
import { prisma } from '@/lib/prisma'
import { Dashboard } from '@/components/dashboard'
import { NoteType } from '@/types/note'
async function getNotes() {
const notes = await prisma.note.findMany({
include: { tags: { include: { tag: true } } },
orderBy: { updatedAt: 'desc' },
})
return notes
}
export default async function HomePage() {
const allNotes = await getNotes()
const notesWithTags = allNotes.map(note => ({
...note,
createdAt: note.createdAt.toISOString(),
updatedAt: note.updatedAt.toISOString(),
type: note.type as NoteType,
tags: note.tags.map(nt => ({ tag: nt.tag })),
}))
const recentNotes = notesWithTags.slice(0, 6)
const favoriteNotes = notesWithTags.filter(n => n.isFavorite)
const pinnedNotes = notesWithTags.filter(n => n.isPinned)
return (
<main className="container mx-auto pt-8 px-4">
<Dashboard
recentNotes={recentNotes}
favoriteNotes={favoriteNotes}
pinnedNotes={pinnedNotes}
/>
</main>
)
}
+150
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>
)
}
+63
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>
</>
)
}
+68
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
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>
)
}
+19
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>
)
}
+56
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>
)
}
+160
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>
)
}
+23
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>
)
}
+50
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>
)
}
+34
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>
)
}
+74
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>
)
}
+109
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,
}
+52
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 }
+60
View File
@@ -0,0 +1,60 @@
"use client"
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 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",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+103
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,
}
+160
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,
}
+268
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,
}
+20
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 }
+201
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,
}
+49
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 }
+82
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 }
+18
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
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
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
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
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
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+26
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
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[]
}
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules", "prisma/seed.ts"]
}