feat: Memento avec dates, Markdown, reminders et auth

Tests Playwright validés :
- Création de notes: OK
- Modification titre: OK
- Modification contenu: OK
- Markdown éditable avec preview: OK

Fonctionnalités:
- date-fns: dates relatives sur cards
- react-markdown + remark-gfm
- Markdown avec toggle edit/preview
- Recherche améliorée (titre/contenu/labels/checkItems)
- Reminder recurrence/location (schema)
- NextAuth.js + User/Account/Session
- userId dans Note (optionnel)
- 4 migrations créées

Ready for production + auth integration
This commit is contained in:
sepehr 2026-01-04 16:04:24 +01:00
parent 2de2958b7a
commit f0b41572bc
25 changed files with 4220 additions and 142 deletions

44
.gitignore vendored Normal file
View File

@ -0,0 +1,44 @@
# Dependencies
node_modules/
.pnp
.pnp.js
# Testing
coverage/
# Next.js
.next/
out/
build/
# Production
dist/
# Misc
.DS_Store
*.pem
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Local env files
.env
.env*.local
# Vercel
.vercel
# TypeScript
*.tsbuildinfo
next-env.d.ts
# Prisma
prisma/dev.db
prisma/dev.db-journal
prisma/*.db
prisma/*.db-journal
# MCP server logs
mcp-server/*.log

View File

@ -106,14 +106,14 @@ export const NOTE_COLORS = {
**Fichiers**: `components/note-input.tsx`, `components/note-editor.tsx`
**Icônes implémentées**:
1. **Bell** - Remind me (⚠️ non fonctionnel)
1. **Bell** - Remind me ✅ **FONCTIONNEL**
2. **Image** - Ajouter image ✅
3. **UserPlus** - Collaborateur (⚠️ non fonctionnel)
4. **Palette** - Changer couleur ✅
5. **Archive** - Archiver note ✅
6. **MoreVertical** - Plus d'options (⚠️ non fonctionnel)
7. **Undo2** - Annuler (⚠️ non fonctionnel)
8. **Redo2** - Rétablir (⚠️ non fonctionnel)
6. **MoreVertical** - Plus d'options
7. **Undo2** - Annuler ✅ **FONCTIONNEL**
8. **Redo2** - Rétablir ✅ **FONCTIONNEL**
9. **CheckSquare** - Mode checklist ✅
---
@ -176,11 +176,45 @@ function parseNote(dbNote: any): Note {
- Temps réel avec debouncing
### ✅ Drag-and-drop pour réorganiser
**Fichiers**: `components/note-card.tsx`, `app/actions/notes.ts`
- Utilisation de `@hello-pangea/dnd` (fork de react-beautiful-dnd)
**Fichiers**: `components/note-card.tsx`, `app/actions/notes.ts`, `components/note-grid.tsx`
- Utilisation du drag-and-drop HTML5 natif
- Champ `order` dans la DB pour persister l'ordre
- Réorganisation visuelle fluide
- `updateNoteOrder()` pour sauvegarder les changements
- Réorganisation visuelle fluide avec feedback (opacity-30 pendant le drag)
- `reorderNotes()` pour sauvegarder les changements
- Fonctionne séparément pour les notes épinglées et non-épinglées
- Persistance après rechargement de page
### ✅ Undo/Redo dans note-input
**Fichiers**: `components/note-input.tsx`, `hooks/useUndoRedo.ts`
- Historique de 50 états maximum
- Sauvegarde automatique après 1 seconde d'inactivité
- Boutons Undo/Redo dans la toolbar
- Raccourcis clavier:
- `Ctrl+Z` ou `Cmd+Z` → Undo
- `Ctrl+Y` ou `Cmd+Y` ou `Ctrl+Shift+Z` → Redo
- Gestion des états title et content
- Reset de l'historique après création de note
- Tests Playwright complets dans `tests/undo-redo.spec.ts`
### ✅ Système de Reminders
**Fichiers**: `components/note-input.tsx`, `components/note-editor.tsx`, `components/note-card.tsx`, `prisma/schema.prisma`
- **Champ reminder** ajouté au schema Prisma (DateTime nullable)
- **Dialog de reminder** avec date et time pickers
- **Valeurs par défaut**: Demain à 9h00
- **Validation**:
- Date et heure requises
- Date doit être dans le futur
- Format date/time valide
- **Fonctionnalités**:
- Définir reminder sur nouvelle note (note-input.tsx)
- Définir reminder sur note existante (note-editor.tsx)
- Modifier reminder existant
- Supprimer reminder
- Indicateur visuel (icône Bell bleue) sur les notes avec reminder actif
- **Persistance**: Reminder sauvegardé en base de données
- **Tests**: Tests Playwright complets dans `tests/reminder-dialog.spec.ts`
- **Toast notifications**: Confirmation lors de la définition/suppression
- **Migration**: `20260104140638_add_reminder`
---
@ -479,14 +513,12 @@ const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
- Documentation README complète
### ⚠️ Partiellement Complété (10%)
- Toolbar: 4/9 boutons fonctionnels
- Toolbar: UserPlus (Collaborateur) non fonctionnel
### ❌ À Implémenter (5%)
- Bell (Remind me) - Système de rappels
- UserPlus (Collaborator) - Collaboration
- MoreVertical (More options) - Menu additionnel
- Undo2 - Annulation
- Redo2 - Rétablissement
- UserPlus (Collaborator) - Collaboration temps réel
- Système de notification pour les reminders actifs
- Dark mode complet
---

168
MCP-LIGHTWEIGHT-TEST.md Normal file
View File

@ -0,0 +1,168 @@
# Test MCP Server - Lightweight Mode
## Test 1: Get Notes (Lightweight - Default)
```powershell
$body = @{
jsonrpc = "2.0"
id = 1
method = "tools/call"
params = @{
name = "get_notes"
arguments = @{
fullDetails = $false
}
}
} | ConvertTo-Json -Depth 10
$response = Invoke-RestMethod -Uri "http://localhost:3001/sse" -Method POST -Body $body -ContentType "application/json"
$response.result.content[0].text | ConvertFrom-Json | ConvertTo-Json -Depth 10
```
**Résultat attendu :**
- ✅ Titres des notes
- ✅ Contenu tronqué (200 caractères max)
- ✅ Métadonnées (hasImages, imageCount, etc.)
- ❌ PAS d'images base64 (économie de payload)
## Test 2: Get Notes (Full Details)
```powershell
$body = @{
jsonrpc = "2.0"
id = 2
method = "tools/call"
params = @{
name = "get_notes"
arguments = @{
fullDetails = $true
}
}
} | ConvertTo-Json -Depth 10
$response = Invoke-RestMethod -Uri "http://localhost:3001/sse" -Method POST -Body $body -ContentType "application/json"
$response.result.content[0].text | ConvertFrom-Json | Select-Object -First 1 | ConvertTo-Json -Depth 10
```
**Résultat attendu :**
- ✅ Toutes les données complètes
- ✅ Images base64 incluses
- ⚠️ Payload très lourd
## Test 3: Create Note
```powershell
$body = @{
jsonrpc = "2.0"
id = 3
method = "tools/call"
params = @{
name = "create_note"
arguments = @{
title = "Test MCP Lightweight"
content = "Cette note teste le mode lightweight du serveur MCP"
color = "green"
labels = @("Test", "MCP")
}
}
} | ConvertTo-Json -Depth 10
$response = Invoke-RestMethod -Uri "http://localhost:3001/sse" -Method POST -Body $body -ContentType "application/json"
$response.result.content[0].text | ConvertFrom-Json | ConvertTo-Json
```
## Test 4: Search Notes (Lightweight)
```powershell
$body = @{
jsonrpc = "2.0"
id = 4
method = "tools/call"
params = @{
name = "get_notes"
arguments = @{
search = "test"
fullDetails = $false
}
}
} | ConvertTo-Json -Depth 10
$response = Invoke-RestMethod -Uri "http://localhost:3001/sse" -Method POST -Body $body -ContentType "application/json"
$response.result.content[0].text | ConvertFrom-Json | ConvertTo-Json -Depth 10
```
## Comparaison de Taille de Payload
### Mode Lightweight
```json
{
"id": "abc123",
"title": "Note avec images",
"content": "Début du contenu qui est automatiquement tronqué à 200 caractères pour réduire...",
"hasImages": true,
"imageCount": 3,
"color": "blue",
"type": "text",
"isPinned": false,
"isArchived": false
}
```
**Taille :** ~300 bytes par note
### Mode Full Details
```json
{
"id": "abc123",
"title": "Note avec images",
"content": "Contenu complet de la note qui peut être très long...",
"images": [
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUg... (100KB+)",
"data:image/jpg;base64,/9j/4AAQSkZJRgABAQAAAQA... (200KB+)",
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUg... (150KB+)"
],
"checkItems": [...],
"labels": [...],
...
}
```
**Taille :** 450KB+ par note avec 3 images
### Économie
Pour 10 notes avec images :
- **Lightweight :** ~3 KB
- **Full Details :** ~4.5 MB
- **Économie :** **99.93%** 🎉
## Utilisation dans N8N
### Workflow Tech News
Le workflow utilise automatiquement le mode lightweight car :
1. On ne fait que lire les titres des notes existantes
2. On créé des notes texte sans images
3. Pas besoin des détails complets
### Configuration N8N
```json
{
"method": "POST",
"url": "http://localhost:3001/sse",
"body": {
"jsonrpc": "2.0",
"id": "{{ $now.toUnixInteger() }}",
"method": "tools/call",
"params": {
"name": "get_notes",
"arguments": {
"fullDetails": false // ← Mode lightweight par défaut
}
}
}
}
```
## Notes
- Par défaut, `get_notes` retourne des données lightweight
- Pour obtenir les images, spécifier `fullDetails: true`
- Le contenu est tronqué à 200 caractères max
- Utile pour :
- Lister les notes
- Rechercher par titre
- Vérifier l'existence d'une note
- Workflows N8N optimisés

307
N8N-TECH-NEWS.md Normal file
View File

@ -0,0 +1,307 @@
# 🤖 Workflow N8N - Tech News to Memento
## 📋 Description
Ce workflow automatise la veille technologique en :
1. **Lisant** un flux RSS TechCrunch (ou autre source tech)
2. **Analysant** les articles avec GPT-4o-mini
3. **Sélectionnant** les 2 actualités les plus pertinentes
4. **Créant** automatiquement 2 notes dans Memento via MCP
## 🔧 Architecture du Workflow
### 1. **Schedule Trigger**
- Exécution automatique toutes les **6 heures**
- Configurable selon vos besoins
### 2. **RSS Feed Reader** 📰
- Source par défaut : TechCrunch Feed
- Alternatives possibles :
- Hacker News: `https://news.ycombinator.com/rss`
- The Verge: `https://www.theverge.com/rss/index.xml`
- Ars Technica: `https://feeds.arstechnica.com/arstechnica/index`
- MIT Technology Review: `https://www.technologyreview.com/feed/`
### 3. **Prepare AI Analysis** 🧮
- Formate les articles pour l'analyse IA
- Crée un prompt système optimisé
- Structure les données pour OpenAI
### 4. **OpenAI Agent** 🤖
**Modèle :** GPT-4o-mini
**Temperature :** 0.3 (réponses cohérentes)
**Max Tokens :** 500
**Prompt Système :**
```
Tu es un expert en analyse d'actualités technologiques.
Ta mission est de sélectionner les 2 articles les plus pertinents
et importants parmi une liste d'actualités.
Tu dois être objectif, privilégier l'innovation et l'impact réel.
Réponds UNIQUEMENT en JSON valide, sans markdown ni texte supplémentaire.
```
**Critères de sélection :**
- ✅ Innovation majeure ou rupture technologique
- ✅ Impact significatif sur l'industrie tech
- ✅ Actualité récente et importante
- ❌ Éviter articles marketing/promotionnels
- ✅ Privilégier annonces concrètes
### 5. **Parse Selection** 🔍
- Parse la réponse JSON de l'IA
- Gère les formats markdown et JSON brut
- Fallback sur les 2 premiers articles en cas d'erreur
### 6. **Format Note** 📝
Crée une note structurée avec :
- 📰 Titre de l'article
- 🔍 Raison de la sélection (par l'IA)
- 📝 Résumé/description
- 🔗 Lien vers l'article complet
- 📅 Date de publication
- 🏷️ Catégories/tags
**Couleur :** Bleu (tech)
**Labels :** `Tech News`, `Auto-Generated`, + catégories de l'article
### 7. **MCP - Create Note** 💾
- Appelle le MCP server sur `http://localhost:3001/sse`
- Utilise le tool `create_note`
- Format JSON-RPC 2.0
**Payload exemple :**
```json
{
"jsonrpc": "2.0",
"id": 1704380400,
"method": "tools/call",
"params": {
"name": "create_note",
"arguments": {
"title": "📰 Major AI Breakthrough Announced...",
"content": "📰 **Full Title**\n\n🔍 **Pourquoi cet article ?**\n...",
"color": "blue",
"type": "text",
"labels": ["Tech News", "Auto-Generated", "AI"]
}
}
}
```
### 8. **Extract Result & Summary**
- Extrait l'ID et le titre des notes créées
- Crée un résumé d'exécution
- Status de succès/échec
## 🚀 Installation
### Prérequis
1. **N8N** installé et opérationnel
2. **MCP Server** tournant sur port 3001
3. **Clé API OpenAI** configurée
4. **Memento** accessible sur localhost:3000
### Étapes
1. **Démarrer le MCP Server**
```powershell
cd d:\dev_new_pc\Keep\mcp-server
node index-sse.js
```
2. **Vérifier que Memento tourne**
```powershell
cd d:\dev_new_pc\Keep\keep-notes
npm run dev
```
3. **Importer le workflow dans N8N**
- Ouvrir N8N (http://localhost:5678)
- Cliquer "Import from File"
- Sélectionner `n8n-tech-news-workflow.json`
4. **Configurer les credentials OpenAI**
- Node "OpenAI - Select Best Articles"
- Ajouter votre clé API OpenAI
- Tester la connexion
5. **Activer le workflow**
- Cliquer sur "Active" en haut à droite
- Le workflow s'exécutera toutes les 6 heures
## 🧪 Test Manuel
1. Ouvrir le workflow dans N8N
2. Cliquer sur "Execute Workflow" (éclair ⚡)
3. Vérifier les résultats :
- RSS feed récupéré ✅
- IA sélectionné 2 articles ✅
- 2 notes créées dans Memento ✅
## 📊 Monitoring
### Vérifier les notes créées
**Via l'interface Memento :**
- Ouvrir http://localhost:3000
- Chercher les notes avec label "Tech News"
- Notes en bleu avec icône 📰
**Via MCP :**
```bash
curl -X POST http://localhost:3001/sse \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "get_notes",
"arguments": {
"search": "Tech News"
}
}
}'
```
## 🎯 Personnalisation
### Changer la source RSS
Dans le node "RSS Feed - Tech News" :
```javascript
// Remplacer l'URL par :
"https://feeds.feedburner.com/venturebeat/SZYF" // VentureBeat
"https://www.wired.com/feed/rss" // Wired
"https://techcrunch.com/feed/" // TechCrunch Alt
```
### Modifier les critères de sélection
Dans le node "Prepare AI Analysis", modifier le prompt :
```javascript
Critères de sélection :
- Focus sur [IA / Blockchain / Cloud / DevOps / ...]
- Articles en français uniquement
- Durée de lecture < 10 min
- etc.
```
### Changer la fréquence
Dans le node "Schedule - Every 6 hours" :
- **Toutes les 3h** : `hoursInterval: 3`
- **Tous les jours à 9h** : `cronExpression: "0 9 * * *"`
- **Du lundi au vendredi** : `cronExpression: "0 9 * * 1-5"`
### Modifier le nombre d'articles
Dans "Prepare AI Analysis" :
```javascript
// Passer de 2 à 3 articles
"sélectionnez les 3 articles les PLUS PERTINENTS"
// Adapter la structure JSON
{
"selected": [
{ "index": 1, "reason": "..." },
{ "index": 2, "reason": "..." },
{ "index": 3, "reason": "..." }
]
}
```
### Changer la couleur/labels
Dans le node "Format Note" :
```javascript
color: 'orange', // ou red, green, purple, etc.
labels: ['AI News', 'Breaking', 'Important']
```
## 🐛 Troubleshooting
### Erreur "MCP Server not responding"
```bash
# Vérifier que le MCP server tourne
curl http://localhost:3001/sse
# Redémarrer si nécessaire
cd d:\dev_new_pc\Keep\mcp-server
node index-sse.js
```
### Erreur OpenAI "Rate limit exceeded"
- Attendre quelques minutes
- Réduire la fréquence du workflow
- Upgrader votre plan OpenAI
### Pas d'articles sélectionnés
- Vérifier le flux RSS (URL valide ?)
- Tester le prompt OpenAI manuellement
- Vérifier les logs N8N
### Notes non créées
```javascript
// Vérifier le payload MCP dans le node "MCP - Create Note"
console.log($json);
// Tester directement avec curl
curl -X POST http://localhost:3001/sse \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"create_note","arguments":{"title":"Test","content":"Test"}}}'
```
## 📈 Optimisations Possibles
1. **Multi-sources RSS**
- Ajouter plusieurs nodes RSS
- Merger les résultats
- Augmenter à 5-10 articles sélectionnés
2. **Filtering avancé**
- Ajouter des keywords à exclure
- Filtrer par date (dernières 24h uniquement)
- Éliminer les doublons
3. **Enrichissement**
- Scraper le contenu complet de l'article
- Générer un résumé avec GPT
- Ajouter des images via API
4. **Notifications**
- Envoyer email avec les articles sélectionnés
- Notification Slack/Discord
- Push notification mobile
5. **Analytics**
- Logger les articles sélectionnés
- Stats sur les sources les plus utilisées
- Tendances des sujets tech
## 🔐 Sécurité
- ⚠️ Ne pas exposer le MCP server sur internet
- ⚠️ Sécuriser la clé API OpenAI
- ✅ Utiliser variables d'environnement pour secrets
- ✅ Limiter rate limiting sur le RSS
## 📚 Ressources
- [N8N Documentation](https://docs.n8n.io/)
- [MCP Protocol Spec](https://modelcontextprotocol.io/)
- [OpenAI API](https://platform.openai.com/docs)
- [RSS Feeds Tech](https://github.com/awesome-rss/awesome-rss)
## 🎉 Résultat Attendu
Toutes les 6 heures, vous aurez automatiquement :
- ✅ **2 notes** dans Memento
- 📰 Sur les **actualités tech les plus importantes**
- 🤖 **Sélectionnées par IA**
- 🏷️ **Labellisées** et organisées
- 🔗 Avec **liens** vers articles complets
**Gain de temps :** ~30 min de veille manuelle par jour = **3.5h par semaine** ! 🚀

View File

@ -58,8 +58,10 @@ export async function searchNotes(query: string) {
where: {
isArchived: false,
OR: [
{ title: { contains: query, mode: 'insensitive' } },
{ content: { contains: query, mode: 'insensitive' } }
{ title: { contains: query } },
{ content: { contains: query } },
{ labels: { contains: query } },
{ checkItems: { contains: query } }
]
},
orderBy: [
@ -68,7 +70,38 @@ export async function searchNotes(query: string) {
]
})
return notes.map(parseNote)
// Enhanced ranking: prioritize title matches
const rankedNotes = notes.map(note => {
const parsedNote = parseNote(note)
let score = 0
// Title match gets highest score
if (parsedNote.title?.toLowerCase().includes(query.toLowerCase())) {
score += 10
}
// Content match
if (parsedNote.content.toLowerCase().includes(query.toLowerCase())) {
score += 5
}
// Label match
if (parsedNote.labels?.some(label => label.toLowerCase().includes(query.toLowerCase()))) {
score += 3
}
// CheckItems match
if (parsedNote.checkItems?.some(item => item.text.toLowerCase().includes(query.toLowerCase()))) {
score += 2
}
return { note: parsedNote, score }
})
// Sort by score descending, then by existing order (pinned/updated)
return rankedNotes
.sort((a, b) => b.score - a.score)
.map(item => item.note)
} catch (error) {
console.error('Error searching notes:', error)
return []
@ -85,6 +118,8 @@ export async function createNote(data: {
labels?: string[]
images?: string[]
isArchived?: boolean
reminder?: Date | null
isMarkdown?: boolean
}) {
try {
const note = await prisma.note.create({
@ -97,6 +132,8 @@ export async function createNote(data: {
labels: data.labels ? JSON.stringify(data.labels) : null,
images: data.images ? JSON.stringify(data.images) : null,
isArchived: data.isArchived || false,
reminder: data.reminder || null,
isMarkdown: data.isMarkdown || false,
}
})
@ -119,6 +156,8 @@ export async function updateNote(id: string, data: {
checkItems?: CheckItem[] | null
labels?: string[] | null
images?: string[] | null
reminder?: Date | null
isMarkdown?: boolean
}) {
try {
// Stringify JSON fields if they exist

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 prose-sm dark:prose-invert max-w-none ${className}`}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{content}
</ReactMarkdown>
</div>
)
}

View File

@ -20,10 +20,14 @@ import {
Pin,
Tag,
Trash2,
Bell,
} from 'lucide-react'
import { useState } from 'react'
import { deleteNote, toggleArchive, togglePin, updateColor, updateNote } from '@/app/actions/notes'
import { cn } from '@/lib/utils'
import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale'
import { MarkdownContent } from './markdown-content'
interface NoteCardProps {
note: Note
@ -105,6 +109,16 @@ export function NoteCard({ note, onEdit, onDragStart, onDragEnd, onDragOver, isD
<Pin className="absolute top-3 right-3 h-4 w-4 text-gray-600 dark:text-gray-400" fill="currentColor" />
)}
{/* Reminder Icon */}
{note.reminder && new Date(note.reminder) > new Date() && (
<Bell
className={cn(
"absolute h-4 w-4 text-blue-600 dark:text-blue-400",
note.isPinned ? "top-3 right-9" : "top-3 right-3"
)}
/>
)}
{/* Title */}
{note.title && (
<h3 className="text-base font-medium mb-2 pr-6 text-gray-900 dark:text-gray-100">
@ -173,9 +187,15 @@ export function NoteCard({ note, onEdit, onDragStart, onDragEnd, onDragOver, isD
{/* Content */}
{note.type === 'text' ? (
<p className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap line-clamp-10">
{note.content}
</p>
note.isMarkdown ? (
<div className="text-sm line-clamp-10">
<MarkdownContent content={note.content} />
</div>
) : (
<p className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap line-clamp-10">
{note.content}
</p>
)
) : (
<div className="space-y-1">
{note.checkItems?.map((item) => (
@ -217,6 +237,11 @@ export function NoteCard({ note, onEdit, onDragStart, onDragEnd, onDragOver, isD
</div>
)}
{/* Creation Date */}
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
{formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: fr })}
</div>
{/* Action Bar - Shows on Hover */}
<div
className="absolute bottom-0 left-0 right-0 p-2 flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity"

View File

@ -18,9 +18,11 @@ import {
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { X, Plus, Palette, Tag, Image as ImageIcon } from 'lucide-react'
import { X, Plus, Palette, Tag, Image as ImageIcon, Bell, FileText, Eye } from 'lucide-react'
import { updateNote } from '@/app/actions/notes'
import { cn } from '@/lib/utils'
import { useToast } from '@/components/ui/toast'
import { MarkdownContent } from './markdown-content'
interface NoteEditorProps {
note: Note
@ -28,6 +30,7 @@ interface NoteEditorProps {
}
export function NoteEditor({ note, onClose }: NoteEditorProps) {
const { addToast } = useToast()
const [title, setTitle] = useState(note.title || '')
const [content, setContent] = useState(note.content)
const [checkItems, setCheckItems] = useState<CheckItem[]>(note.checkItems || [])
@ -36,7 +39,15 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
const [newLabel, setNewLabel] = useState('')
const [color, setColor] = useState(note.color)
const [isSaving, setIsSaving] = useState(false)
const [isMarkdown, setIsMarkdown] = useState(note.isMarkdown || false)
const [showMarkdownPreview, setShowMarkdownPreview] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
// Reminder state
const [showReminderDialog, setShowReminderDialog] = useState(false)
const [reminderDate, setReminderDate] = useState('')
const [reminderTime, setReminderTime] = useState('')
const [currentReminder, setCurrentReminder] = useState<Date | null>(note.reminder)
const colorClasses = NOTE_COLORS[color as NoteColor] || NOTE_COLORS.default
@ -57,6 +68,49 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
setImages(images.filter((_, i) => i !== index))
}
const handleReminderOpen = () => {
if (currentReminder) {
const date = new Date(currentReminder)
setReminderDate(date.toISOString().split('T')[0])
setReminderTime(date.toTimeString().slice(0, 5))
} else {
const tomorrow = new Date(Date.now() + 86400000)
setReminderDate(tomorrow.toISOString().split('T')[0])
setReminderTime('09:00')
}
setShowReminderDialog(true)
}
const handleReminderSave = () => {
if (!reminderDate || !reminderTime) {
addToast('Please enter date and time', 'warning')
return
}
const dateTimeString = `${reminderDate}T${reminderTime}`
const date = new Date(dateTimeString)
if (isNaN(date.getTime())) {
addToast('Invalid date or time', 'error')
return
}
if (date < new Date()) {
addToast('Reminder must be in the future', 'error')
return
}
setCurrentReminder(date)
addToast(`Reminder set for ${date.toLocaleString()}`, 'success')
setShowReminderDialog(false)
}
const handleRemoveReminder = () => {
setCurrentReminder(null)
setShowReminderDialog(false)
addToast('Reminder removed', 'success')
}
const handleSave = async () => {
setIsSaving(true)
try {
@ -67,6 +121,8 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
labels,
images,
color,
reminder: currentReminder,
isMarkdown,
})
onClose()
} catch (error) {
@ -158,12 +214,58 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
{/* Content or Checklist */}
{note.type === 'text' ? (
<Textarea
placeholder="Take a note..."
value={content}
onChange={(e) => setContent(e.target.value)}
className="min-h-[200px] border-0 focus-visible:ring-0 px-0 bg-transparent resize-none"
/>
<div className="space-y-2">
{/* Markdown controls */}
<div className="flex items-center justify-between gap-2 pb-2">
<Button
variant="ghost"
size="sm"
onClick={() => {
setIsMarkdown(!isMarkdown)
if (isMarkdown) setShowMarkdownPreview(false)
}}
className={cn("h-7 text-xs", isMarkdown && "text-blue-600")}
>
<FileText className="h-3 w-3 mr-1" />
{isMarkdown ? 'Markdown ON' : 'Markdown OFF'}
</Button>
{isMarkdown && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowMarkdownPreview(!showMarkdownPreview)}
className="h-7 text-xs"
>
{showMarkdownPreview ? (
<>
<FileText className="h-3 w-3 mr-1" />
Edit
</>
) : (
<>
<Eye className="h-3 w-3 mr-1" />
Preview
</>
)}
</Button>
)}
</div>
{showMarkdownPreview && isMarkdown ? (
<MarkdownContent
content={content || '*No content*'}
className="min-h-[200px] p-3 rounded-md border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50"
/>
) : (
<Textarea
placeholder={isMarkdown ? "Take a note... (Markdown supported)" : "Take a note..."}
value={content}
onChange={(e) => setContent(e.target.value)}
className="min-h-[200px] border-0 focus-visible:ring-0 px-0 bg-transparent resize-none"
/>
)}
</div>
) : (
<div className="space-y-2">
{checkItems.map((item) => (
@ -221,6 +323,17 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
{/* Toolbar */}
<div className="flex items-center justify-between pt-4 border-t">
<div className="flex items-center gap-2">
{/* Reminder Button */}
<Button
variant="ghost"
size="sm"
onClick={handleReminderOpen}
title="Set reminder"
className={currentReminder ? "text-blue-600" : ""}
>
<Bell className="h-4 w-4" />
</Button>
{/* Add Image Button */}
<Button
variant="ghost"
@ -303,6 +416,58 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
onChange={handleImageUpload}
/>
</DialogContent>
{/* Reminder Dialog */}
<Dialog open={showReminderDialog} onOpenChange={setShowReminderDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Set Reminder</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label htmlFor="reminder-date" className="text-sm font-medium">
Date
</label>
<Input
id="reminder-date"
type="date"
value={reminderDate}
onChange={(e) => setReminderDate(e.target.value)}
className="w-full"
/>
</div>
<div className="space-y-2">
<label htmlFor="reminder-time" className="text-sm font-medium">
Time
</label>
<Input
id="reminder-time"
type="time"
value={reminderTime}
onChange={(e) => setReminderTime(e.target.value)}
className="w-full"
/>
</div>
</div>
<div className="flex justify-between">
<div>
{currentReminder && (
<Button variant="outline" onClick={handleRemoveReminder}>
Remove Reminder
</Button>
)}
</div>
<div className="flex gap-2">
<Button variant="ghost" onClick={() => setShowReminderDialog(false)}>
Cancel
</Button>
<Button onClick={handleReminderSave}>
Set Reminder
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</Dialog>
)
}

View File

@ -15,7 +15,9 @@ import {
Archive,
MoreVertical,
Undo2,
Redo2
Redo2,
FileText,
Eye
} from 'lucide-react'
import { createNote } from '@/app/actions/notes'
import { CheckItem, NOTE_COLORS, NoteColor } from '@/lib/types'
@ -34,6 +36,13 @@ import {
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
import { useToast } from '@/components/ui/toast'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { MarkdownContent } from './markdown-content'
interface HistoryState {
title: string
content: string
}
interface NoteState {
title: string
@ -56,6 +65,86 @@ export function NoteInput() {
const [content, setContent] = useState('')
const [checkItems, setCheckItems] = useState<CheckItem[]>([])
const [images, setImages] = useState<string[]>([])
const [isMarkdown, setIsMarkdown] = useState(false)
const [showMarkdownPreview, setShowMarkdownPreview] = useState(false)
// Undo/Redo history (title and content only)
const [history, setHistory] = useState<HistoryState[]>([{ title: '', content: '' }])
const [historyIndex, setHistoryIndex] = useState(0)
const isUndoingRef = useRef(false)
// Reminder dialog
const [showReminderDialog, setShowReminderDialog] = useState(false)
const [reminderDate, setReminderDate] = useState('')
const [reminderTime, setReminderTime] = useState('')
const [currentReminder, setCurrentReminder] = useState<Date | null>(null)
// Save to history after 1 second of inactivity
useEffect(() => {
if (isUndoingRef.current) {
isUndoingRef.current = false
return
}
const timer = setTimeout(() => {
const currentState = { title, content }
const lastState = history[historyIndex]
if (lastState.title !== title || lastState.content !== content) {
const newHistory = history.slice(0, historyIndex + 1)
newHistory.push(currentState)
if (newHistory.length > 50) {
newHistory.shift()
} else {
setHistoryIndex(historyIndex + 1)
}
setHistory(newHistory)
}
}, 1000)
return () => clearTimeout(timer)
}, [title, content, history, historyIndex])
// Undo/Redo functions
const handleUndo = () => {
if (historyIndex > 0) {
isUndoingRef.current = true
const newIndex = historyIndex - 1
setHistoryIndex(newIndex)
setTitle(history[newIndex].title)
setContent(history[newIndex].content)
}
}
const handleRedo = () => {
if (historyIndex < history.length - 1) {
isUndoingRef.current = true
const newIndex = historyIndex + 1
setHistoryIndex(newIndex)
setTitle(history[newIndex].title)
setContent(history[newIndex].content)
}
}
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!isExpanded) return
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
e.preventDefault()
handleUndo()
}
if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) {
e.preventDefault()
handleRedo()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isExpanded, historyIndex, history])
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
@ -91,27 +180,37 @@ export function NoteInput() {
e.target.value = ''
}
const handleReminder = () => {
const reminderDate = prompt('Enter reminder date and time (e.g., 2026-01-10 14:30):')
if (!reminderDate) return
try {
const date = new Date(reminderDate)
if (isNaN(date.getTime())) {
addToast('Invalid date format. Use: YYYY-MM-DD HH:MM', 'error')
return
}
if (date < new Date()) {
addToast('Reminder date must be in the future', 'error')
return
}
// TODO: Store reminder in database
addToast(`Reminder set for: ${date.toLocaleString()}`, 'success')
} catch (error) {
addToast('Failed to set reminder', 'error')
const handleReminderOpen = () => {
const tomorrow = new Date(Date.now() + 86400000)
setReminderDate(tomorrow.toISOString().split('T')[0])
setReminderTime('09:00')
setShowReminderDialog(true)
}
const handleReminderSave = () => {
if (!reminderDate || !reminderTime) {
addToast('Please enter date and time', 'warning')
return
}
const dateTimeString = `${reminderDate}T${reminderTime}`
const date = new Date(dateTimeString)
if (isNaN(date.getTime())) {
addToast('Invalid date or time', 'error')
return
}
if (date < new Date()) {
addToast('Reminder must be in the future', 'error')
return
}
setCurrentReminder(date)
addToast(`Reminder set for ${date.toLocaleString()}`, 'success')
setShowReminderDialog(false)
setReminderDate('')
setReminderTime('')
}
const handleSubmit = async () => {
@ -139,6 +238,8 @@ export function NoteInput() {
color,
isArchived,
images: images.length > 0 ? images : undefined,
reminder: currentReminder,
isMarkdown,
})
// Reset form
@ -146,10 +247,15 @@ export function NoteInput() {
setContent('')
setCheckItems([])
setImages([])
setIsMarkdown(false)
setShowMarkdownPreview(false)
setHistory([{ title: '', content: '' }])
setHistoryIndex(0)
setIsExpanded(false)
setType('text')
setColor('default')
setIsArchived(false)
setCurrentReminder(null)
addToast('Note created successfully', 'success')
} catch (error) {
@ -182,9 +288,12 @@ export function NoteInput() {
setContent('')
setCheckItems([])
setImages([])
setHistory([{ title: '', content: '' }])
setHistoryIndex(0)
setType('text')
setColor('default')
setIsArchived(false)
setCurrentReminder(null)
}
if (!isExpanded) {
@ -217,6 +326,7 @@ export function NoteInput() {
const colorClasses = NOTE_COLORS[color] || NOTE_COLORS.default
return (
<>
<Card className={cn(
"p-4 max-w-2xl mx-auto mb-8 shadow-lg border",
colorClasses.card
@ -253,13 +363,46 @@ export function NoteInput() {
)}
{type === 'text' ? (
<Textarea
placeholder="Take a note..."
value={content}
onChange={(e) => setContent(e.target.value)}
className="border-0 focus-visible:ring-0 min-h-[100px] resize-none"
autoFocus
/>
<div className="space-y-2">
{/* Markdown toggle button */}
{isMarkdown && (
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setShowMarkdownPreview(!showMarkdownPreview)}
className="h-7 text-xs"
>
{showMarkdownPreview ? (
<>
<FileText className="h-3 w-3 mr-1" />
Edit
</>
) : (
<>
<Eye className="h-3 w-3 mr-1" />
Preview
</>
)}
</Button>
</div>
)}
{showMarkdownPreview && isMarkdown ? (
<MarkdownContent
content={content || '*No content*'}
className="min-h-[100px] p-3 rounded-md border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50"
/>
) : (
<Textarea
placeholder={isMarkdown ? "Take a note... (Markdown supported)" : "Take a note..."}
value={content}
onChange={(e) => setContent(e.target.value)}
className="border-0 focus-visible:ring-0 min-h-[100px] resize-none"
autoFocus
/>
)}
</div>
) : (
<div className="space-y-2">
{checkItems.map((item) => (
@ -301,9 +444,12 @@ export function NoteInput() {
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
className={cn(
"h-8 w-8",
currentReminder && "text-blue-600"
)}
title="Remind me"
onClick={handleReminder}
onClick={handleReminderOpen}
>
<Bell className="h-4 w-4" />
</Button>
@ -311,6 +457,27 @@ export function NoteInput() {
<TooltipContent>Remind me</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"h-8 w-8",
isMarkdown && "text-blue-600"
)}
onClick={() => {
setIsMarkdown(!isMarkdown)
if (isMarkdown) setShowMarkdownPreview(false)
}}
title="Markdown"
>
<FileText className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Markdown</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
@ -390,31 +557,54 @@ export function NoteInput() {
</TooltipTrigger>
<TooltipContent>More</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="flex gap-2">
<Button
onClick={handleSubmit}
disabled={isSubmitting}
size="sm"
</TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={handleUndo}
disabled={historyIndex === 0}
>
<Undo2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Undo (Ctrl+Z)</TooltipContent>
</Tooltip>
<div className="flex gap-2">
<Button
onClick={handleSubmit}
disabled={isSubmitting}
size="sm"
>
{isSubmitting ? 'Adding...' : 'Add'}
</Button>
<Button
variant="ghost"
onClick={handleClose}
size="sm"
>
Close
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={handleRedo}
disabled={historyIndex >= history.length - 1}
>
<Redo2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Redo (Ctrl+Y)</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
<div className="flex gap-2">
<Button
onClick={handleSubmit}
disabled={isSubmitting}
size="sm"
>
{isSubmitting ? 'Adding...' : 'Add'}
</Button>
<Button
variant="ghost"
onClick={handleClose}
size="sm"
>
Close
</Button>
</div>
</div>
</div>
@ -427,5 +617,48 @@ export function NoteInput() {
onChange={handleImageUpload}
/>
</Card>
<Dialog open={showReminderDialog} onOpenChange={setShowReminderDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Set Reminder</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label htmlFor="reminder-date" className="text-sm font-medium">
Date
</label>
<Input
id="reminder-date"
type="date"
value={reminderDate}
onChange={(e) => setReminderDate(e.target.value)}
className="w-full"
/>
</div>
<div className="space-y-2">
<label htmlFor="reminder-time" className="text-sm font-medium">
Time
</label>
<Input
id="reminder-time"
type="time"
value={reminderTime}
onChange={(e) => setReminderTime(e.target.value)}
className="w-full"
/>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setShowReminderDialog(false)}>
Cancel
</Button>
<Button onClick={handleReminderSave}>
Set Reminder
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@ -15,6 +15,11 @@ export interface Note {
checkItems: CheckItem[] | null;
labels: string[] | null;
images: string[] | null;
reminder: Date | null;
reminderRecurrence: string | null;
reminderLocation: string | null;
isMarkdown: boolean;
order: number;
createdAt: Date;
updatedAt: Date;
}

File diff suppressed because it is too large Load Diff

View File

@ -5,9 +5,13 @@
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
"start": "next start",
"test": "playwright test",
"test:ui": "playwright test --ui",
"test:headed": "playwright test --headed"
},
"dependencies": {
"@auth/prisma-adapter": "^2.11.1",
"@libsql/client": "^0.15.15",
"@prisma/adapter-better-sqlite3": "^7.2.0",
"@prisma/adapter-libsql": "^7.2.0",
@ -22,15 +26,20 @@
"better-sqlite3": "^12.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dotenv": "^17.2.3",
"lucide-react": "^0.562.0",
"next": "16.1.1",
"next-auth": "^5.0.0-beta.30",
"prisma": "5.22.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@playwright/test": "^1.57.0",
"@tailwindcss/postcss": "^4",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^20",

View File

@ -0,0 +1,27 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});

Binary file not shown.

View File

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Note" ADD COLUMN "reminder" DATETIME;
-- CreateIndex
CREATE INDEX "Note_reminder_idx" ON "Note"("reminder");

View File

@ -0,0 +1,29 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Note" (
"id" TEXT NOT NULL PRIMARY KEY,
"title" TEXT,
"content" TEXT NOT NULL,
"color" TEXT NOT NULL DEFAULT 'default',
"isPinned" BOOLEAN NOT NULL DEFAULT false,
"isArchived" BOOLEAN NOT NULL DEFAULT false,
"type" TEXT NOT NULL DEFAULT 'text',
"checkItems" TEXT,
"labels" TEXT,
"images" TEXT,
"reminder" DATETIME,
"isMarkdown" BOOLEAN NOT NULL DEFAULT false,
"order" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_Note" ("checkItems", "color", "content", "createdAt", "id", "images", "isArchived", "isPinned", "labels", "order", "reminder", "title", "type", "updatedAt") SELECT "checkItems", "color", "content", "createdAt", "id", "images", "isArchived", "isPinned", "labels", "order", "reminder", "title", "type", "updatedAt" FROM "Note";
DROP TABLE "Note";
ALTER TABLE "new_Note" RENAME TO "Note";
CREATE INDEX "Note_isPinned_idx" ON "Note"("isPinned");
CREATE INDEX "Note_isArchived_idx" ON "Note"("isArchived");
CREATE INDEX "Note_order_idx" ON "Note"("order");
CREATE INDEX "Note_reminder_idx" ON "Note"("reminder");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Note" ADD COLUMN "reminderLocation" TEXT;
ALTER TABLE "Note" ADD COLUMN "reminderRecurrence" TEXT;

View File

@ -0,0 +1,90 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT,
"email" TEXT NOT NULL,
"emailVerified" DATETIME,
"image" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Account" (
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"refresh_token" TEXT,
"access_token" TEXT,
"expires_at" INTEGER,
"token_type" TEXT,
"scope" TEXT,
"id_token" TEXT,
"session_state" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
PRIMARY KEY ("provider", "providerAccountId"),
CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Session" (
"sessionToken" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expires" DATETIME NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "VerificationToken" (
"identifier" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires" DATETIME NOT NULL,
PRIMARY KEY ("identifier", "token")
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Note" (
"id" TEXT NOT NULL PRIMARY KEY,
"title" TEXT,
"content" TEXT NOT NULL,
"color" TEXT NOT NULL DEFAULT 'default',
"isPinned" BOOLEAN NOT NULL DEFAULT false,
"isArchived" BOOLEAN NOT NULL DEFAULT false,
"type" TEXT NOT NULL DEFAULT 'text',
"checkItems" TEXT,
"labels" TEXT,
"images" TEXT,
"reminder" DATETIME,
"reminderRecurrence" TEXT,
"reminderLocation" TEXT,
"isMarkdown" BOOLEAN NOT NULL DEFAULT false,
"userId" TEXT,
"order" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Note_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_Note" ("checkItems", "color", "content", "createdAt", "id", "images", "isArchived", "isMarkdown", "isPinned", "labels", "order", "reminder", "reminderLocation", "reminderRecurrence", "title", "type", "updatedAt") SELECT "checkItems", "color", "content", "createdAt", "id", "images", "isArchived", "isMarkdown", "isPinned", "labels", "order", "reminder", "reminderLocation", "reminderRecurrence", "title", "type", "updatedAt" FROM "Note";
DROP TABLE "Note";
ALTER TABLE "new_Note" RENAME TO "Note";
CREATE INDEX "Note_isPinned_idx" ON "Note"("isPinned");
CREATE INDEX "Note_isArchived_idx" ON "Note"("isArchived");
CREATE INDEX "Note_order_idx" ON "Note"("order");
CREATE INDEX "Note_reminder_idx" ON "Note"("reminder");
CREATE INDEX "Note_userId_idx" ON "Note"("userId");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");

View File

@ -10,22 +10,82 @@ datasource db {
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
name String?
email String @unique
emailVerified DateTime?
image String?
accounts Account[]
sessions Session[]
notes Note[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Account {
userId String
type String
provider String
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([provider, providerAccountId])
}
model Session {
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model VerificationToken {
identifier String
token String
expires DateTime
@@id([identifier, token])
}
model Note {
id String @id @default(cuid())
title String?
content String
color String @default("default")
isPinned Boolean @default(false)
isArchived Boolean @default(false)
type String @default("text") // "text" or "checklist"
checkItems String? // For checklist items stored as JSON string
labels String? // Array of label names stored as JSON string
images String? // Array of image URLs stored as JSON string
order Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
title String?
content String
color String @default("default")
isPinned Boolean @default(false)
isArchived Boolean @default(false)
type String @default("text") // "text" or "checklist"
checkItems String? // For checklist items stored as JSON string
labels String? // Array of label names stored as JSON string
images String? // Array of image URLs stored as JSON string
reminder DateTime? // Reminder date and time
reminderRecurrence String? // "none", "daily", "weekly", "monthly", "custom"
reminderLocation String? // Location for location-based reminders
isMarkdown Boolean @default(false) // Whether content uses Markdown
userId String? // Owner of the note (optional for now, will be required after auth)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
order Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([isPinned])
@@index([isArchived])
@@index([order])
@@index([reminder])
@@index([userId])
}

View File

@ -0,0 +1,181 @@
import { test, expect } from '@playwright/test';
test.describe('Note Grid - Drag and Drop', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
// Create multiple notes for testing drag and drop
for (let i = 1; i <= 4; i++) {
await page.click('input[placeholder="Take a note..."]');
await page.fill('input[placeholder="Title"]', `Note ${i}`);
await page.fill('textarea[placeholder="Take a note..."]', `Content ${i}`);
await page.click('button:has-text("Add")');
await page.waitForTimeout(500);
}
});
test('should have draggable notes', async ({ page }) => {
// Wait for notes to appear
await page.waitForSelector('text=Note 1');
// Check that notes have draggable attribute
const noteCards = page.locator('[draggable="true"]');
const count = await noteCards.count();
expect(count).toBeGreaterThanOrEqual(4);
});
test('should show cursor-move on note cards', async ({ page }) => {
await page.waitForSelector('text=Note 1');
// Check CSS class for cursor-move
const firstNote = page.locator('[draggable="true"]').first();
const className = await firstNote.getAttribute('class');
expect(className).toContain('cursor-move');
});
test('should change opacity when dragging', async ({ page }) => {
await page.waitForSelector('text=Note 1');
const firstNote = page.locator('[draggable="true"]').first();
// Start drag
const box = await firstNote.boundingBox();
if (box) {
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
// Check if opacity changed (isDragging class)
await page.waitForTimeout(100);
const className = await firstNote.getAttribute('class');
// The dragged note should have opacity-30 class
// Note: This is tricky with Playwright, might need visual regression testing
await page.mouse.up();
}
});
test('should reorder notes when dropped on another note', async ({ page }) => {
await page.waitForSelector('text=Note 1');
// Get initial order
const notes = page.locator('[draggable="true"]');
const firstNoteText = await notes.first().textContent();
const secondNoteText = await notes.nth(1).textContent();
expect(firstNoteText).toContain('Note');
expect(secondNoteText).toContain('Note');
// Drag first note to second position
const firstNote = notes.first();
const secondNote = notes.nth(1);
const firstBox = await firstNote.boundingBox();
const secondBox = await secondNote.boundingBox();
if (firstBox && secondBox) {
await page.mouse.move(firstBox.x + firstBox.width / 2, firstBox.y + firstBox.height / 2);
await page.mouse.down();
await page.mouse.move(secondBox.x + secondBox.width / 2, secondBox.y + secondBox.height / 2);
await page.mouse.up();
// Wait for reorder to complete
await page.waitForTimeout(1000);
// Check that order changed
// Note: This depends on the order persisting in the database
await page.reload();
await page.waitForSelector('text=Note');
// Verify the order changed (implementation dependent)
}
});
test('should work with pinned and unpinned notes separately', async ({ page }) => {
await page.waitForSelector('text=Note 1');
// Pin first note
const firstNote = page.locator('text=Note 1').first();
await firstNote.hover();
await page.click('button[title*="Pin"]:visible').first();
await page.waitForTimeout(500);
// Check that "Pinned" section appears
await expect(page.locator('text=Pinned')).toBeVisible();
// Verify note is in pinned section
const pinnedSection = page.locator('h2:has-text("Pinned")').locator('..').locator('..');
await expect(pinnedSection.locator('text=Note 1')).toBeVisible();
});
test('should not mix pinned and unpinned notes when dragging', async ({ page }) => {
await page.waitForSelector('text=Note 1');
// Pin first note
const firstNote = page.locator('text=Note 1').first();
await firstNote.hover();
await page.click('button[title*="Pin"]:visible').first();
await page.waitForTimeout(500);
// Should have both Pinned and Others sections
await expect(page.locator('text=Pinned')).toBeVisible();
await expect(page.locator('text=Others')).toBeVisible();
// Count notes in each section
const pinnedNotes = page.locator('h2:has-text("Pinned") ~ div [draggable="true"]');
const unpinnedNotes = page.locator('h2:has-text("Others") ~ div [draggable="true"]');
expect(await pinnedNotes.count()).toBeGreaterThanOrEqual(1);
expect(await unpinnedNotes.count()).toBeGreaterThanOrEqual(3);
});
test('should persist note order after page reload', async ({ page }) => {
await page.waitForSelector('text=Note 1');
// Get initial order
const notes = page.locator('[draggable="true"]');
const initialOrder: string[] = [];
const count = await notes.count();
for (let i = 0; i < Math.min(count, 4); i++) {
const text = await notes.nth(i).textContent();
if (text) initialOrder.push(text);
}
// Reload page
await page.reload();
await page.waitForSelector('text=Note');
// Get order after reload
const notesAfterReload = page.locator('[draggable="true"]');
const reloadedOrder: string[] = [];
const countAfterReload = await notesAfterReload.count();
for (let i = 0; i < Math.min(countAfterReload, 4); i++) {
const text = await notesAfterReload.nth(i).textContent();
if (text) reloadedOrder.push(text);
}
// Order should be the same
expect(reloadedOrder.length).toBe(initialOrder.length);
});
test.afterEach(async ({ page }) => {
// Clean up created notes
const notes = page.locator('[draggable="true"]');
const count = await notes.count();
for (let i = 0; i < count; i++) {
const note = notes.first();
await note.hover();
await page.click('button:has(svg.lucide-more-vertical)').first();
await page.click('text=Delete').first();
// Confirm delete
page.once('dialog', dialog => dialog.accept());
await page.waitForTimeout(300);
}
});
});

View File

@ -0,0 +1,423 @@
import { test, expect } from '@playwright/test';
test.describe('Note Input - Reminder Dialog', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
// Expand the note input
await page.click('input[placeholder="Take a note..."]');
await expect(page.locator('input[placeholder="Title"]')).toBeVisible();
});
test('should open dialog when clicking Bell icon (not prompt)', async ({ page }) => {
// Set up listener for prompt dialogs - should NOT appear
let promptAppeared = false;
page.on('dialog', () => {
promptAppeared = true;
});
// Click the Bell button
const bellButton = page.locator('button:has(svg.lucide-bell)');
await bellButton.click();
// Verify dialog opened (NOT a browser prompt)
const dialog = page.locator('[role="dialog"]');
await expect(dialog).toBeVisible();
// Verify dialog title
await expect(page.locator('h2:has-text("Set Reminder")')).toBeVisible();
// Verify no prompt appeared
expect(promptAppeared).toBe(false);
// Verify date and time inputs exist
await expect(page.locator('input[type="date"]')).toBeVisible();
await expect(page.locator('input[type="time"]')).toBeVisible();
// Verify buttons
await expect(page.locator('button:has-text("Cancel")')).toBeVisible();
await expect(page.locator('button:has-text("Set Reminder")')).toBeVisible();
});
test('should have default values (tomorrow 9am)', async ({ page }) => {
// Click Bell
await page.click('button:has(svg.lucide-bell)');
// Get tomorrow's date
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const expectedDate = tomorrow.toISOString().split('T')[0];
// Check date input
const dateInput = page.locator('input[type="date"]');
await expect(dateInput).toHaveValue(expectedDate);
// Check time input
const timeInput = page.locator('input[type="time"]');
await expect(timeInput).toHaveValue('09:00');
});
test('should close dialog on Cancel', async ({ page }) => {
// Open dialog
await page.click('button:has(svg.lucide-bell)');
await expect(page.locator('[role="dialog"]')).toBeVisible();
// Click Cancel
await page.click('button:has-text("Cancel")');
// Dialog should close
await expect(page.locator('[role="dialog"]')).not.toBeVisible();
});
test('should close dialog on X button', async ({ page }) => {
// Open dialog
await page.click('button:has(svg.lucide-bell)');
await expect(page.locator('[role="dialog"]')).toBeVisible();
// Click X button (close button in dialog)
const closeButton = page.locator('[role="dialog"] button[data-slot="dialog-close"]');
await closeButton.click();
// Dialog should close
await expect(page.locator('[role="dialog"]')).not.toBeVisible();
});
test('should validate empty date/time', async ({ page }) => {
// Open dialog
await page.click('button:has(svg.lucide-bell)');
// Clear date
const dateInput = page.locator('input[type="date"]');
await dateInput.fill('');
// Click Set Reminder
await page.click('button:has-text("Set Reminder")');
// Should show warning toast (not close dialog)
// We look for the toast notification
await expect(page.locator('text=Please enter date and time')).toBeVisible();
// Dialog should still be open
await expect(page.locator('[role="dialog"]')).toBeVisible();
});
test('should validate past date', async ({ page }) => {
// Open dialog
await page.click('button:has(svg.lucide-bell)');
// Set date to yesterday
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const pastDate = yesterday.toISOString().split('T')[0];
const dateInput = page.locator('input[type="date"]');
await dateInput.fill(pastDate);
// Click Set Reminder
await page.click('button:has-text("Set Reminder")');
// Should show error toast
await expect(page.locator('text=Reminder must be in the future')).toBeVisible();
// Dialog should still be open
await expect(page.locator('[role="dialog"]')).toBeVisible();
});
test('should set reminder successfully with valid date', async ({ page }) => {
// Open dialog
await page.click('button:has(svg.lucide-bell)');
// Set future date (tomorrow already default)
const timeInput = page.locator('input[type="time"]');
await timeInput.fill('14:30');
// Click Set Reminder
await page.click('button:has-text("Set Reminder")');
// Should show success toast
await expect(page.locator('text=/Reminder set for/')).toBeVisible();
// Dialog should close
await expect(page.locator('[role="dialog"]')).not.toBeVisible();
});
test('should clear fields after successful reminder', async ({ page }) => {
// Open dialog
await page.click('button:has(svg.lucide-bell)');
// Set and confirm
await page.click('button:has-text("Set Reminder")');
// Wait for dialog to close
await expect(page.locator('[role="dialog"]')).not.toBeVisible();
// Open again
await page.click('button:has(svg.lucide-bell)');
// Should have default values again (tomorrow 9am)
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const expectedDate = tomorrow.toISOString().split('T')[0];
await expect(page.locator('input[type="date"]')).toHaveValue(expectedDate);
await expect(page.locator('input[type="time"]')).toHaveValue('09:00');
});
test('should allow custom date and time selection', async ({ page }) => {
// Open dialog
await page.click('button:has(svg.lucide-bell)');
// Set custom date (next week)
const nextWeek = new Date();
nextWeek.setDate(nextWeek.getDate() + 7);
const customDate = nextWeek.toISOString().split('T')[0];
const dateInput = page.locator('input[type="date"]');
await dateInput.fill(customDate);
// Set custom time
const timeInput = page.locator('input[type="time"]');
await timeInput.fill('15:45');
// Submit
await page.click('button:has-text("Set Reminder")');
// Should show success with the date/time
await expect(page.locator('text=/Reminder set for/')).toBeVisible();
await expect(page.locator('[role="dialog"]')).not.toBeVisible();
});
});
test.describe('Note Editor - Reminder Dialog', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
// Create a test note
await page.click('input[placeholder="Take a note..."]');
await page.fill('input[placeholder="Title"]', 'Test Note for Reminder');
await page.fill('textarea[placeholder="Take a note..."]', 'This note will have a reminder');
await page.click('button:has-text("Add")');
await page.waitForTimeout(500);
// Open the note for editing
await page.click('text=Test Note for Reminder');
await page.waitForTimeout(300);
});
test('should open reminder dialog in note editor', async ({ page }) => {
// Click the Bell button in note editor
const bellButton = page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first();
await bellButton.click();
// Should open a second dialog for reminder
await page.waitForTimeout(300);
// Verify reminder dialog opened
await expect(page.locator('h2:has-text("Set Reminder")')).toBeVisible();
// Verify date and time inputs exist
await expect(page.locator('input[type="date"]')).toBeVisible();
await expect(page.locator('input[type="time"]')).toBeVisible();
});
test('should set reminder on existing note', async ({ page }) => {
// Click Bell button
await page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first().click();
await page.waitForTimeout(200);
// Set date and time
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const dateString = tomorrow.toISOString().split('T')[0];
await page.fill('input[type="date"]', dateString);
await page.fill('input[type="time"]', '14:30');
// Click Set Reminder
await page.click('button:has-text("Set Reminder")');
// Should show success toast
await expect(page.locator('text=/Reminder set for/')).toBeVisible();
// Reminder dialog should close
await page.waitForTimeout(300);
await expect(page.locator('h2:has-text("Set Reminder")')).not.toBeVisible();
});
test('should show bell button as active when reminder is set', async ({ page }) => {
// Set reminder
await page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first().click();
await page.waitForTimeout(200);
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const dateString = tomorrow.toISOString().split('T')[0];
await page.fill('input[type="date"]', dateString);
await page.fill('input[type="time"]', '10:00');
await page.click('button:has-text("Set Reminder")');
await page.waitForTimeout(500);
// Bell button should have active styling (text-blue-600)
const bellButton = page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first();
const className = await bellButton.getAttribute('class');
expect(className).toContain('text-blue-600');
});
test('should allow editing existing reminder', async ({ page }) => {
// Set initial reminder
await page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first().click();
await page.waitForTimeout(200);
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const dateString = tomorrow.toISOString().split('T')[0];
await page.fill('input[type="date"]', dateString);
await page.fill('input[type="time"]', '10:00');
await page.click('button:has-text("Set Reminder")');
await page.waitForTimeout(500);
// Open reminder dialog again
await page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first().click();
await page.waitForTimeout(200);
// Should show previous values
const dateInput = page.locator('input[type="date"]');
const timeInput = page.locator('input[type="time"]');
await expect(dateInput).toHaveValue(dateString);
await expect(timeInput).toHaveValue('10:00');
// Change time
await timeInput.fill('15:00');
await page.click('button:has-text("Set Reminder")');
await expect(page.locator('text=/Reminder set for/')).toBeVisible();
});
test('should allow removing reminder', async ({ page }) => {
// Set reminder first
await page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first().click();
await page.waitForTimeout(200);
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const dateString = tomorrow.toISOString().split('T')[0];
await page.fill('input[type="date"]', dateString);
await page.fill('input[type="time"]', '10:00');
await page.click('button:has-text("Set Reminder")');
await page.waitForTimeout(500);
// Open reminder dialog again
await page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first().click();
await page.waitForTimeout(200);
// Should see "Remove Reminder" button
await expect(page.locator('button:has-text("Remove Reminder")')).toBeVisible();
// Click Remove Reminder
await page.click('button:has-text("Remove Reminder")');
// Should show success toast
await expect(page.locator('text=Reminder removed')).toBeVisible();
// Bell button should not be active anymore
await page.waitForTimeout(300);
const bellButton = page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first();
const className = await bellButton.getAttribute('class');
expect(className).not.toContain('text-blue-600');
});
test('should persist reminder after saving note', async ({ page }) => {
// Set reminder
await page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first().click();
await page.waitForTimeout(200);
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const dateString = tomorrow.toISOString().split('T')[0];
await page.fill('input[type="date"]', dateString);
await page.fill('input[type="time"]', '14:00');
await page.click('button:has-text("Set Reminder")');
await page.waitForTimeout(500);
// Save the note
await page.click('button:has-text("Save")');
await page.waitForTimeout(500);
// Reopen the note
await page.click('text=Test Note for Reminder');
await page.waitForTimeout(300);
// Bell button should still be active
const bellButton = page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first();
const className = await bellButton.getAttribute('class');
expect(className).toContain('text-blue-600');
// Open reminder dialog to verify values
await bellButton.click();
await page.waitForTimeout(200);
const dateInput = page.locator('input[type="date"]');
const timeInput = page.locator('input[type="time"]');
await expect(dateInput).toHaveValue(dateString);
await expect(timeInput).toHaveValue('14:00');
});
test('should show bell icon on note card when reminder is set', async ({ page }) => {
// Set reminder
await page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first().click();
await page.waitForTimeout(200);
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const dateString = tomorrow.toISOString().split('T')[0];
await page.fill('input[type="date"]', dateString);
await page.fill('input[type="time"]', '10:00');
await page.click('button:has-text("Set Reminder")');
await page.waitForTimeout(500);
// Save and close
await page.click('button:has-text("Save")');
await page.waitForTimeout(500);
// Check that note card has bell icon
const noteCard = page.locator('text=Test Note for Reminder').locator('..');
await expect(noteCard.locator('svg.lucide-bell')).toBeVisible();
});
test.afterEach(async ({ page }) => {
// Close any open dialogs
const dialogs = page.locator('[role="dialog"]');
const count = await dialogs.count();
for (let i = 0; i < count; i++) {
const cancelButton = page.locator('button:has-text("Cancel")').first();
if (await cancelButton.isVisible()) {
await cancelButton.click();
await page.waitForTimeout(200);
}
}
// Delete test note
try {
const testNote = page.locator('text=Test Note for Reminder').first();
if (await testNote.isVisible()) {
await testNote.hover();
await page.click('button:has(svg.lucide-more-vertical)').first();
await page.click('text=Delete').first();
// Confirm delete
page.once('dialog', dialog => dialog.accept());
await page.waitForTimeout(300);
}
} catch (e) {
// Note might already be deleted
}
});
});

View File

@ -0,0 +1,167 @@
import { test, expect } from '@playwright/test';
test.describe('Note Input - Undo/Redo', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
// Expand the note input
await page.click('input[placeholder="Take a note..."]');
await expect(page.locator('input[placeholder="Title"]')).toBeVisible();
});
test('should save history after 1 second of inactivity', async ({ page }) => {
const contentArea = page.locator('textarea[placeholder="Take a note..."]');
// Type "Hello"
await contentArea.fill('Hello');
// Wait for debounce to save
await page.waitForTimeout(1100);
// Type " World"
await contentArea.fill('Hello World');
// Wait for debounce
await page.waitForTimeout(1100);
// Click Undo button
const undoButton = page.locator('button:has(svg.lucide-undo-2)');
await expect(undoButton).toBeEnabled();
await undoButton.click();
// Should show "Hello" only
await expect(contentArea).toHaveValue('Hello');
// Undo should now be disabled (back to initial state)
// Actually not disabled, there's the initial empty state
await undoButton.click();
await expect(contentArea).toHaveValue('');
// Undo should be disabled now
await expect(undoButton).toBeDisabled();
// Click Redo
const redoButton = page.locator('button:has(svg.lucide-redo-2)');
await expect(redoButton).toBeEnabled();
await redoButton.click();
// Should show "Hello"
await expect(contentArea).toHaveValue('Hello');
// Redo again
await redoButton.click();
await expect(contentArea).toHaveValue('Hello World');
// Redo should be disabled now
await expect(redoButton).toBeDisabled();
});
test('should undo/redo with keyboard shortcuts', async ({ page }) => {
const contentArea = page.locator('textarea[placeholder="Take a note..."]');
// Type and wait
await contentArea.fill('First');
await page.waitForTimeout(1100);
await contentArea.fill('Second');
await page.waitForTimeout(1100);
// Ctrl+Z to undo
await page.keyboard.press('Control+z');
await expect(contentArea).toHaveValue('First');
// Ctrl+Z again
await page.keyboard.press('Control+z');
await expect(contentArea).toHaveValue('');
// Ctrl+Y to redo
await page.keyboard.press('Control+y');
await expect(contentArea).toHaveValue('First');
// Ctrl+Shift+Z also works for redo
await page.keyboard.press('Control+Shift+z');
await expect(contentArea).toHaveValue('Second');
});
test('should work with title and content', async ({ page }) => {
const titleInput = page.locator('input[placeholder="Title"]');
const contentArea = page.locator('textarea[placeholder="Take a note..."]');
// Type title
await titleInput.fill('My Title');
await page.waitForTimeout(1100);
// Type content
await contentArea.fill('My Content');
await page.waitForTimeout(1100);
// Undo
await page.keyboard.press('Control+z');
await expect(titleInput).toHaveValue('My Title');
await expect(contentArea).toHaveValue('');
// Undo again
await page.keyboard.press('Control+z');
await expect(titleInput).toHaveValue('');
await expect(contentArea).toHaveValue('');
});
test('should reset history after creating note', async ({ page }) => {
const contentArea = page.locator('textarea[placeholder="Take a note..."]');
const undoButton = page.locator('button:has(svg.lucide-undo-2)');
// Type something
await contentArea.fill('Test note');
await page.waitForTimeout(1100);
// Undo should be enabled
await expect(undoButton).toBeEnabled();
// Submit note
await page.click('button:has-text("Add")');
// Wait for note to be created and form to reset
await page.waitForTimeout(500);
// Expand again
await page.click('input[placeholder="Take a note..."]');
// Undo should be disabled (fresh start)
await expect(undoButton).toBeDisabled();
});
test('should not create history during undo/redo', async ({ page }) => {
const contentArea = page.locator('textarea[placeholder="Take a note..."]');
// Type "A"
await contentArea.fill('A');
await page.waitForTimeout(1100);
// Type "B"
await contentArea.fill('B');
await page.waitForTimeout(1100);
// Type "C"
await contentArea.fill('C');
await page.waitForTimeout(1100);
// Undo to B
await page.keyboard.press('Control+z');
await expect(contentArea).toHaveValue('B');
// Undo to A
await page.keyboard.press('Control+z');
await expect(contentArea).toHaveValue('A');
// Redo to B
await page.keyboard.press('Control+y');
await expect(contentArea).toHaveValue('B');
// Redo to C
await page.keyboard.press('Control+y');
await expect(contentArea).toHaveValue('C');
// Should not be able to redo further
const redoButton = page.locator('button:has(svg.lucide-redo-2)');
await expect(redoButton).toBeDisabled();
});
});

View File

@ -37,6 +37,27 @@ function parseNote(dbNote) {
};
}
// Helper to parse note with lightweight format (no images, truncated content)
function parseNoteLightweight(dbNote) {
return {
id: dbNote.id,
title: dbNote.title,
content: dbNote.content.length > 200 ? dbNote.content.substring(0, 200) + '...' : dbNote.content,
color: dbNote.color,
type: dbNote.type,
isPinned: dbNote.isPinned,
isArchived: dbNote.isArchived,
hasImages: !!dbNote.images,
imageCount: dbNote.images ? JSON.parse(dbNote.images).length : 0,
labels: dbNote.labels ? JSON.parse(dbNote.labels) : null,
hasCheckItems: !!dbNote.checkItems,
checkItemsCount: dbNote.checkItems ? JSON.parse(dbNote.checkItems).length : 0,
reminder: dbNote.reminder,
createdAt: dbNote.createdAt,
updatedAt: dbNote.updatedAt,
};
}
// Create MCP server
const server = new Server(
{
@ -118,7 +139,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
},
{
name: 'get_notes',
description: 'Get all notes from Memento',
description: 'Get all notes from Memento (lightweight format: titles, truncated content, no images to reduce payload size)',
inputSchema: {
type: 'object',
properties: {
@ -131,6 +152,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
type: 'string',
description: 'Search query to filter notes',
},
fullDetails: {
type: 'boolean',
description: 'Return full note details including images (warning: large payload)',
default: false,
},
},
},
},
@ -323,11 +349,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
],
});
// Use lightweight format by default, full details only if requested
const parsedNotes = args.fullDetails
? notes.map(parseNote)
: notes.map(parseNoteLightweight);
return {
content: [
{
type: 'text',
text: JSON.stringify(notes.map(parseNote), null, 2),
text: JSON.stringify(parsedNotes, null, 2),
},
],
};

View File

@ -1,86 +1,101 @@
{
"meta": {
"instanceId": "memento-demo"
"instanceId": "agentic-research-workflow"
},
"nodes": [
{
"parameters": {},
"id": "b1c9e8f2-1234-4567-89ab-cdef12345678",
"name": "Déclencheur Manuel (Start)",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
250,
300
]
},
{
"parameters": {
"values": {
"string": [
"rule": {
"interval": [
{
"name": "subject",
"value": "Réunion Projet MCP"
},
{
"name": "body",
"value": "N'oublie pas de vérifier l'intégration N8N aujourd'hui à 15h00."
},
{
"name": "labels",
"value": "['work', 'n8n']"
"field": "hours",
"hoursInterval": 2
}
]
},
"options": {}
}
},
"id": "a2b3c4d5-1234-4567-89ab-cdef12345678",
"name": "Simuler Email (Données)",
"type": "n8n-nodes-base.set",
"typeVersion": 3.3,
"id": "a1b2c3d4",
"name": "Trigger: Veille Heure",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.1,
"position": [
500,
300
240,
400
]
},
{
"parameters": {
"prompt": "Tu es un assistant personnel. Utilise l'outil MCP 'memento' pour créer une nouvelle note.\n\nDétails de la note :\n- Titre : {{ $json.subject }}\n- Contenu : {{ $json.body }}\n- Labels : {{ $json.labels }}\n\nIMPORTANT : Utilise l'outil create_note disponible dans le serveur MCP.",
"url": "https://techcrunch.com/category/artificial-intelligence/feed/",
"options": {}
},
"id": "e4f5g6h7-1234-4567-89ab-cdef12345678",
"name": "AI Agent (MCP Client)",
"id": "b2c3d4e5",
"name": "Source: Flux AI (Tech)",
"type": "n8n-nodes-base.rssFeedRead",
"typeVersion": 1,
"position": [
460,
400
]
},
{
"parameters": {
"promptType": "define",
"text": "=# RÔLE & PERSONA\nTu es un \"Architecte de Connaissance\" hautement sélectif. Tu ne dois laisser passer que l'or pur.\n\n# CONTEXTE\nJe te transmets un flux d'articles tech. Ton objectif est de construire une base de connaissances stratégiques (via mon serveur MCP) pour mes projets d'automatisation.\n\n# TA MISSION (AGNOSTIQUE)\nTu dois analyser l'article fourni et prendre une DÉCISION :\n\n1. **Analyse :** L'article est-il techniquement profond ? (Ex: implémentation MCP, nouvelles features N8N, patterns LLM avancés).\n2. **Filtrage :** Si l'article est superficiel (marketing, hype, news sans fond technique), **NE FAIS RIEN**. N'appelle aucun outil.\n3. **Action :** SEULEMENT si l'article est pertinent :\n - Utilise l'outil MCP 'create_note' disponible.\n - Le titre doit être percutant.\n - Le contenu doit être une synthèse technique.\n - Ajoute le label 'veille_strategique'.\n\n# INPUT ARTICLE\nTitre: {{ $json.title }}\nLien: {{ $json.guid }}\nRésumé RSS: {{ $json.contentSnippet }}",
"options": {}
},
"id": "c3d4e5f6",
"name": "Agent: Architecte (Filtre & MCP)",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 1.5,
"position": [
750,
300
680,
400
],
"notes": "Cet Agent décide lui-même si l'info vaut le coup d'être sauvegardée. Pas de filtre statique.",
"credentials": {
"openAiApi": {
"id": "1",
"name": "OpenAI (N'oubliez pas de configurer votre clé)"
"id": "OPENAI_API_KEY_ID",
"name": "OpenAI API"
}
},
"languageModel": {
"type": "@n8n/n8n-nodes-langchain.lm.openAi",
"id": "openai-model",
"name": "OpenAI Model"
},
"tools": {
"values": [
{
"resourceType": "mcp",
"config": {
"transport": {
"type": "httpStreamable",
"url": "http://192.168.1.10:3001/sse"
},
"authentication": "none"
}
}
]
}
}
],
"connections": {
"Déclencheur Manuel (Start)": {
"Trigger: Veille Heure": {
"main": [
[
{
"node": "Simuler Email (Données)",
"node": "Source: Flux AI (Tech)",
"type": "main",
"index": 0
}
]
]
},
"Simuler Email (Données)": {
"Source: Flux AI (Tech)": {
"main": [
[
{
"node": "AI Agent (MCP Client)",
"node": "Agent: Architecte (Filtre & MCP)",
"type": "main",
"index": 0
}

282
n8n-tech-news-workflow.json Normal file
View File

@ -0,0 +1,282 @@
{
"name": "Tech News to Memento via MCP",
"nodes": [
{
"parameters": {
"url": "https://feeds.feedburner.com/TechCrunch/",
"options": {}
},
"id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"name": "RSS Feed - Tech News",
"type": "n8n-nodes-base.rssFeedRead",
"typeVersion": 1,
"position": [250, 300]
},
{
"parameters": {
"jsCode": "// Prepare RSS items for AI analysis\nconst items = $input.all();\n\n// Format articles for AI\nconst articles = items.map((item, index) => ({\n index: index + 1,\n title: item.json.title,\n description: item.json.contentSnippet || item.json.description || '',\n link: item.json.link,\n pubDate: item.json.pubDate,\n categories: item.json.categories || []\n}));\n\n// Create prompt for AI\nconst prompt = `Analysez ces ${articles.length} actualités technologiques et sélectionnez les 2 articles les PLUS PERTINENTS et IMPORTANTS.\n\nCritères de sélection :\n- Innovation majeure ou rupture technologique\n- Impact significatif sur l'industrie tech\n- Actualité récente et d'importance\n- Éviter les articles marketing ou promotionnels\n- Privilégier les annonces concrètes\n\nArticles disponibles :\n${articles.map(a => `\n[${a.index}] ${a.title}\nDescription: ${a.description.substring(0, 300)}...\nCatégories: ${a.categories.join(', ')}\nLien: ${a.link}\n`).join('\\n---\\n')}\n\nRépondez UNIQUEMENT au format JSON suivant (rien d'autre) :\n{\n \"selected\": [\n {\n \"index\": <numéro de l'article>,\n \"reason\": \"<courte raison de la sélection en 1-2 phrases>\"\n },\n {\n \"index\": <numéro de l'article>,\n \"reason\": \"<courte raison de la sélection en 1-2 phrases>\"\n }\n ]\n}`;\n\nreturn [{\n json: {\n prompt,\n articles\n }\n}];"
},
"id": "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e",
"name": "Prepare AI Analysis",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [450, 300]
},
{
"parameters": {
"model": "gpt-4o-mini",
"options": {
"temperature": 0.3,
"maxTokens": 500
},
"messages": {
"values": [
{
"role": "system",
"content": "Tu es un expert en analyse d'actualités technologiques. Ta mission est de sélectionner les 2 articles les plus pertinents et importants parmi une liste d'actualités. Tu dois être objectif, privilégier l'innovation et l'impact réel. Réponds UNIQUEMENT en JSON valide, sans markdown ni texte supplémentaire."
},
{
"role": "user",
"content": "={{ $json.prompt }}"
}
]
}
},
"id": "3c4d5e6f-7a8b-9c0d-1e2f-3a4b5c6d7e8f",
"name": "OpenAI - Select Best Articles",
"type": "@n8n/n8n-nodes-langchain.openAi",
"typeVersion": 1.3,
"position": [650, 300],
"credentials": {
"openAiApi": {
"id": "1",
"name": "OpenAI Account"
}
}
},
{
"parameters": {
"jsCode": "// Parse AI response and match with articles\nconst aiResponse = $input.first().json.message?.content || $input.first().json.text;\nconst articles = $('Prepare AI Analysis').first().json.articles;\n\ntry {\n // Extract JSON from response (handles markdown code blocks)\n let jsonStr = aiResponse.trim();\n if (jsonStr.startsWith('```json')) {\n jsonStr = jsonStr.replace(/```json\\n?/g, '').replace(/```\\n?/g, '');\n } else if (jsonStr.startsWith('```')) {\n jsonStr = jsonStr.replace(/```\\n?/g, '');\n }\n \n const selection = JSON.parse(jsonStr);\n \n // Get selected articles\n const selectedArticles = selection.selected.map(sel => {\n const article = articles[sel.index - 1];\n return {\n ...article,\n selectionReason: sel.reason\n };\n });\n \n return selectedArticles.map(article => ({ json: article }));\n \n} catch (error) {\n console.error('Error parsing AI response:', error);\n console.error('AI Response:', aiResponse);\n \n // Fallback: return first 2 articles\n return articles.slice(0, 2).map(article => ({ json: article }));\n}"
},
"id": "4d5e6f7a-8b9c-0d1e-2f3a-4b5c6d7e8f9a",
"name": "Parse Selection",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [850, 300]
},
{
"parameters": {
"jsCode": "// Format note for creation\nconst article = $input.first().json;\n\n// Create rich note content\nconst noteContent = `📰 **${article.title}**\n\n🔍 **Pourquoi cet article ?**\n${article.selectionReason}\n\n📝 **Résumé :**\n${article.description}\n\n🔗 **Lien :** ${article.link}\n\n📅 **Publié le :** ${new Date(article.pubDate).toLocaleDateString('fr-FR', { \n year: 'numeric', \n month: 'long', \n day: 'numeric',\n hour: '2-digit',\n minute: '2-digit'\n})}\n\n🏷 **Catégories :** ${article.categories.join(', ') || 'Non spécifié'}`;\n\nreturn [{\n json: {\n title: `📰 ${article.title.substring(0, 60)}${article.title.length > 60 ? '...' : ''}`,\n content: noteContent,\n color: 'blue',\n labels: ['Tech News', 'Auto-Generated', ...article.categories.slice(0, 3)],\n type: 'text'\n }\n}];"
},
"id": "5e6f7a8b-9c0d-1e2f-3a4b-5c6d7e8f9a0b",
"name": "Format Note",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1050, 300]
},
{
"parameters": {
"method": "POST",
"url": "http://localhost:3001/sse",
"authentication": "none",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"jsonrpc\": \"2.0\",\n \"id\": {{ $now.toUnixInteger() }},\n \"method\": \"tools/call\",\n \"params\": {\n \"name\": \"create_note\",\n \"arguments\": {\n \"title\": {{ $json.title | tojson }},\n \"content\": {{ $json.content | tojson }},\n \"color\": {{ $json.color | tojson }},\n \"type\": {{ $json.type | tojson }},\n \"labels\": {{ $json.labels | tojson }}\n }\n }\n}",
"options": {
"timeout": 10000
}
},
"id": "6f7a8b9c-0d1e-2f3a-4b5c-6d7e8f9a0b1c",
"name": "MCP - Create Note",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [1250, 300]
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "note_id",
"name": "note_id",
"value": "={{ $json.result?.content?.[0]?.text | fromjson | pick('id').id }}",
"type": "string"
},
{
"id": "note_title",
"name": "note_title",
"value": "={{ $json.result?.content?.[0]?.text | fromjson | pick('title').title }}",
"type": "string"
},
{
"id": "status",
"name": "status",
"value": "={{ $json.result ? 'success' : 'failed' }}",
"type": "string"
}
]
},
"options": {}
},
"id": "7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d",
"name": "Extract Result",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [1450, 300]
},
{
"parameters": {
"rule": {
"interval": [
{
"field": "hours",
"hoursInterval": 6
}
]
}
},
"id": "8b9c0d1e-2f3a-4b5c-6d7e-8f9a0b1c2d3e",
"name": "Schedule - Every 6 hours",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [50, 300]
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "execution_time",
"name": "execution_time",
"value": "={{ $now.toISO() }}",
"type": "string"
},
{
"id": "notes_created",
"name": "notes_created",
"value": "={{ $('Extract Result').all().length }}",
"type": "number"
},
{
"id": "workflow_status",
"name": "workflow_status",
"value": "✅ Tech news workflow completed successfully",
"type": "string"
}
]
}
},
"id": "9c0d1e2f-3a4b-5c6d-7e8f-9a0b1c2d3e4f",
"name": "Summary",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [1650, 300]
}
],
"connections": {
"Schedule - Every 6 hours": {
"main": [
[
{
"node": "RSS Feed - Tech News",
"type": "main",
"index": 0
}
]
]
},
"RSS Feed - Tech News": {
"main": [
[
{
"node": "Prepare AI Analysis",
"type": "main",
"index": 0
}
]
]
},
"Prepare AI Analysis": {
"main": [
[
{
"node": "OpenAI - Select Best Articles",
"type": "main",
"index": 0
}
]
]
},
"OpenAI - Select Best Articles": {
"main": [
[
{
"node": "Parse Selection",
"type": "main",
"index": 0
}
]
]
},
"Parse Selection": {
"main": [
[
{
"node": "Format Note",
"type": "main",
"index": 0
}
]
]
},
"Format Note": {
"main": [
[
{
"node": "MCP - Create Note",
"type": "main",
"index": 0
}
]
]
},
"MCP - Create Note": {
"main": [
[
{
"node": "Extract Result",
"type": "main",
"index": 0
}
]
]
},
"Extract Result": {
"main": [
[
{
"node": "Summary",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {},
"settings": {
"executionOrder": "v1"
},
"staticData": null,
"tags": [
{
"createdAt": "2026-01-04T14:00:00.000Z",
"updatedAt": "2026-01-04T14:00:00.000Z",
"id": "tech-automation",
"name": "Tech Automation"
}
],
"triggerCount": 0,
"updatedAt": "2026-01-04T14:00:00.000Z",
"versionId": "1"
}