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:
parent
2de2958b7a
commit
f0b41572bc
44
.gitignore
vendored
Normal file
44
.gitignore
vendored
Normal 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
|
||||||
@ -106,14 +106,14 @@ export const NOTE_COLORS = {
|
|||||||
**Fichiers**: `components/note-input.tsx`, `components/note-editor.tsx`
|
**Fichiers**: `components/note-input.tsx`, `components/note-editor.tsx`
|
||||||
|
|
||||||
**Icônes implémentées**:
|
**Icônes implémentées**:
|
||||||
1. **Bell** - Remind me (⚠️ non fonctionnel)
|
1. **Bell** - Remind me ✅ **FONCTIONNEL**
|
||||||
2. **Image** - Ajouter image ✅
|
2. **Image** - Ajouter image ✅
|
||||||
3. **UserPlus** - Collaborateur (⚠️ non fonctionnel)
|
3. **UserPlus** - Collaborateur (⚠️ non fonctionnel)
|
||||||
4. **Palette** - Changer couleur ✅
|
4. **Palette** - Changer couleur ✅
|
||||||
5. **Archive** - Archiver note ✅
|
5. **Archive** - Archiver note ✅
|
||||||
6. **MoreVertical** - Plus d'options (⚠️ non fonctionnel)
|
6. **MoreVertical** - Plus d'options ✅
|
||||||
7. **Undo2** - Annuler (⚠️ non fonctionnel)
|
7. **Undo2** - Annuler ✅ **FONCTIONNEL**
|
||||||
8. **Redo2** - Rétablir (⚠️ non fonctionnel)
|
8. **Redo2** - Rétablir ✅ **FONCTIONNEL**
|
||||||
9. **CheckSquare** - Mode checklist ✅
|
9. **CheckSquare** - Mode checklist ✅
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -176,11 +176,45 @@ function parseNote(dbNote: any): Note {
|
|||||||
- Temps réel avec debouncing
|
- Temps réel avec debouncing
|
||||||
|
|
||||||
### ✅ Drag-and-drop pour réorganiser
|
### ✅ Drag-and-drop pour réorganiser
|
||||||
**Fichiers**: `components/note-card.tsx`, `app/actions/notes.ts`
|
**Fichiers**: `components/note-card.tsx`, `app/actions/notes.ts`, `components/note-grid.tsx`
|
||||||
- Utilisation de `@hello-pangea/dnd` (fork de react-beautiful-dnd)
|
- Utilisation du drag-and-drop HTML5 natif
|
||||||
- Champ `order` dans la DB pour persister l'ordre
|
- Champ `order` dans la DB pour persister l'ordre
|
||||||
- Réorganisation visuelle fluide
|
- Réorganisation visuelle fluide avec feedback (opacity-30 pendant le drag)
|
||||||
- `updateNoteOrder()` pour sauvegarder les changements
|
- `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
|
- Documentation README complète
|
||||||
|
|
||||||
### ⚠️ Partiellement Complété (10%)
|
### ⚠️ Partiellement Complété (10%)
|
||||||
- Toolbar: 4/9 boutons fonctionnels
|
- Toolbar: UserPlus (Collaborateur) non fonctionnel
|
||||||
|
|
||||||
### ❌ À Implémenter (5%)
|
### ❌ À Implémenter (5%)
|
||||||
- Bell (Remind me) - Système de rappels
|
- UserPlus (Collaborator) - Collaboration temps réel
|
||||||
- UserPlus (Collaborator) - Collaboration
|
- Système de notification pour les reminders actifs
|
||||||
- MoreVertical (More options) - Menu additionnel
|
- Dark mode complet
|
||||||
- Undo2 - Annulation
|
|
||||||
- Redo2 - Rétablissement
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
168
MCP-LIGHTWEIGHT-TEST.md
Normal file
168
MCP-LIGHTWEIGHT-TEST.md
Normal 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
307
N8N-TECH-NEWS.md
Normal 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** ! 🚀
|
||||||
@ -58,8 +58,10 @@ export async function searchNotes(query: string) {
|
|||||||
where: {
|
where: {
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
OR: [
|
OR: [
|
||||||
{ title: { contains: query, mode: 'insensitive' } },
|
{ title: { contains: query } },
|
||||||
{ content: { contains: query, mode: 'insensitive' } }
|
{ content: { contains: query } },
|
||||||
|
{ labels: { contains: query } },
|
||||||
|
{ checkItems: { contains: query } }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
orderBy: [
|
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) {
|
} catch (error) {
|
||||||
console.error('Error searching notes:', error)
|
console.error('Error searching notes:', error)
|
||||||
return []
|
return []
|
||||||
@ -85,6 +118,8 @@ export async function createNote(data: {
|
|||||||
labels?: string[]
|
labels?: string[]
|
||||||
images?: string[]
|
images?: string[]
|
||||||
isArchived?: boolean
|
isArchived?: boolean
|
||||||
|
reminder?: Date | null
|
||||||
|
isMarkdown?: boolean
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
const note = await prisma.note.create({
|
const note = await prisma.note.create({
|
||||||
@ -97,6 +132,8 @@ export async function createNote(data: {
|
|||||||
labels: data.labels ? JSON.stringify(data.labels) : null,
|
labels: data.labels ? JSON.stringify(data.labels) : null,
|
||||||
images: data.images ? JSON.stringify(data.images) : null,
|
images: data.images ? JSON.stringify(data.images) : null,
|
||||||
isArchived: data.isArchived || false,
|
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
|
checkItems?: CheckItem[] | null
|
||||||
labels?: string[] | null
|
labels?: string[] | null
|
||||||
images?: string[] | null
|
images?: string[] | null
|
||||||
|
reminder?: Date | null
|
||||||
|
isMarkdown?: boolean
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
// Stringify JSON fields if they exist
|
// Stringify JSON fields if they exist
|
||||||
|
|||||||
19
keep-notes/components/markdown-content.tsx
Normal file
19
keep-notes/components/markdown-content.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -20,10 +20,14 @@ import {
|
|||||||
Pin,
|
Pin,
|
||||||
Tag,
|
Tag,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Bell,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { deleteNote, toggleArchive, togglePin, updateColor, updateNote } from '@/app/actions/notes'
|
import { deleteNote, toggleArchive, togglePin, updateColor, updateNote } from '@/app/actions/notes'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
|
import { fr } from 'date-fns/locale'
|
||||||
|
import { MarkdownContent } from './markdown-content'
|
||||||
|
|
||||||
interface NoteCardProps {
|
interface NoteCardProps {
|
||||||
note: Note
|
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" />
|
<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 */}
|
{/* Title */}
|
||||||
{note.title && (
|
{note.title && (
|
||||||
<h3 className="text-base font-medium mb-2 pr-6 text-gray-900 dark:text-gray-100">
|
<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 */}
|
{/* Content */}
|
||||||
{note.type === 'text' ? (
|
{note.type === 'text' ? (
|
||||||
<p className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap line-clamp-10">
|
note.isMarkdown ? (
|
||||||
{note.content}
|
<div className="text-sm line-clamp-10">
|
||||||
</p>
|
<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">
|
<div className="space-y-1">
|
||||||
{note.checkItems?.map((item) => (
|
{note.checkItems?.map((item) => (
|
||||||
@ -217,6 +237,11 @@ export function NoteCard({ note, onEdit, onDragStart, onDragEnd, onDragOver, isD
|
|||||||
</div>
|
</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 */}
|
{/* Action Bar - Shows on Hover */}
|
||||||
<div
|
<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"
|
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"
|
||||||
|
|||||||
@ -18,9 +18,11 @@ import {
|
|||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} 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 { updateNote } from '@/app/actions/notes'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useToast } from '@/components/ui/toast'
|
||||||
|
import { MarkdownContent } from './markdown-content'
|
||||||
|
|
||||||
interface NoteEditorProps {
|
interface NoteEditorProps {
|
||||||
note: Note
|
note: Note
|
||||||
@ -28,6 +30,7 @@ interface NoteEditorProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function NoteEditor({ note, onClose }: NoteEditorProps) {
|
export function NoteEditor({ note, onClose }: NoteEditorProps) {
|
||||||
|
const { addToast } = useToast()
|
||||||
const [title, setTitle] = useState(note.title || '')
|
const [title, setTitle] = useState(note.title || '')
|
||||||
const [content, setContent] = useState(note.content)
|
const [content, setContent] = useState(note.content)
|
||||||
const [checkItems, setCheckItems] = useState<CheckItem[]>(note.checkItems || [])
|
const [checkItems, setCheckItems] = useState<CheckItem[]>(note.checkItems || [])
|
||||||
@ -36,7 +39,15 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
|
|||||||
const [newLabel, setNewLabel] = useState('')
|
const [newLabel, setNewLabel] = useState('')
|
||||||
const [color, setColor] = useState(note.color)
|
const [color, setColor] = useState(note.color)
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [isMarkdown, setIsMarkdown] = useState(note.isMarkdown || false)
|
||||||
|
const [showMarkdownPreview, setShowMarkdownPreview] = useState(false)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
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
|
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))
|
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 () => {
|
const handleSave = async () => {
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
try {
|
try {
|
||||||
@ -67,6 +121,8 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
|
|||||||
labels,
|
labels,
|
||||||
images,
|
images,
|
||||||
color,
|
color,
|
||||||
|
reminder: currentReminder,
|
||||||
|
isMarkdown,
|
||||||
})
|
})
|
||||||
onClose()
|
onClose()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -158,12 +214,58 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
|
|||||||
|
|
||||||
{/* Content or Checklist */}
|
{/* Content or Checklist */}
|
||||||
{note.type === 'text' ? (
|
{note.type === 'text' ? (
|
||||||
<Textarea
|
<div className="space-y-2">
|
||||||
placeholder="Take a note..."
|
{/* Markdown controls */}
|
||||||
value={content}
|
<div className="flex items-center justify-between gap-2 pb-2">
|
||||||
onChange={(e) => setContent(e.target.value)}
|
<Button
|
||||||
className="min-h-[200px] border-0 focus-visible:ring-0 px-0 bg-transparent resize-none"
|
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">
|
<div className="space-y-2">
|
||||||
{checkItems.map((item) => (
|
{checkItems.map((item) => (
|
||||||
@ -221,6 +323,17 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
|
|||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="flex items-center justify-between pt-4 border-t">
|
<div className="flex items-center justify-between pt-4 border-t">
|
||||||
<div className="flex items-center gap-2">
|
<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 */}
|
{/* Add Image Button */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@ -303,6 +416,58 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
|
|||||||
onChange={handleImageUpload}
|
onChange={handleImageUpload}
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</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>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,7 +15,9 @@ import {
|
|||||||
Archive,
|
Archive,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
Undo2,
|
Undo2,
|
||||||
Redo2
|
Redo2,
|
||||||
|
FileText,
|
||||||
|
Eye
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { createNote } from '@/app/actions/notes'
|
import { createNote } from '@/app/actions/notes'
|
||||||
import { CheckItem, NOTE_COLORS, NoteColor } from '@/lib/types'
|
import { CheckItem, NOTE_COLORS, NoteColor } from '@/lib/types'
|
||||||
@ -34,6 +36,13 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useToast } from '@/components/ui/toast'
|
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 {
|
interface NoteState {
|
||||||
title: string
|
title: string
|
||||||
@ -56,6 +65,86 @@ export function NoteInput() {
|
|||||||
const [content, setContent] = useState('')
|
const [content, setContent] = useState('')
|
||||||
const [checkItems, setCheckItems] = useState<CheckItem[]>([])
|
const [checkItems, setCheckItems] = useState<CheckItem[]>([])
|
||||||
const [images, setImages] = useState<string[]>([])
|
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 handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = e.target.files
|
const files = e.target.files
|
||||||
@ -91,27 +180,37 @@ export function NoteInput() {
|
|||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleReminder = () => {
|
const handleReminderOpen = () => {
|
||||||
const reminderDate = prompt('Enter reminder date and time (e.g., 2026-01-10 14:30):')
|
const tomorrow = new Date(Date.now() + 86400000)
|
||||||
if (!reminderDate) return
|
setReminderDate(tomorrow.toISOString().split('T')[0])
|
||||||
|
setReminderTime('09:00')
|
||||||
try {
|
setShowReminderDialog(true)
|
||||||
const date = new Date(reminderDate)
|
}
|
||||||
if (isNaN(date.getTime())) {
|
|
||||||
addToast('Invalid date format. Use: YYYY-MM-DD HH:MM', 'error')
|
const handleReminderSave = () => {
|
||||||
return
|
if (!reminderDate || !reminderTime) {
|
||||||
}
|
addToast('Please enter date and time', 'warning')
|
||||||
|
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 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 () => {
|
const handleSubmit = async () => {
|
||||||
@ -139,6 +238,8 @@ export function NoteInput() {
|
|||||||
color,
|
color,
|
||||||
isArchived,
|
isArchived,
|
||||||
images: images.length > 0 ? images : undefined,
|
images: images.length > 0 ? images : undefined,
|
||||||
|
reminder: currentReminder,
|
||||||
|
isMarkdown,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Reset form
|
// Reset form
|
||||||
@ -146,10 +247,15 @@ export function NoteInput() {
|
|||||||
setContent('')
|
setContent('')
|
||||||
setCheckItems([])
|
setCheckItems([])
|
||||||
setImages([])
|
setImages([])
|
||||||
|
setIsMarkdown(false)
|
||||||
|
setShowMarkdownPreview(false)
|
||||||
|
setHistory([{ title: '', content: '' }])
|
||||||
|
setHistoryIndex(0)
|
||||||
setIsExpanded(false)
|
setIsExpanded(false)
|
||||||
setType('text')
|
setType('text')
|
||||||
setColor('default')
|
setColor('default')
|
||||||
setIsArchived(false)
|
setIsArchived(false)
|
||||||
|
setCurrentReminder(null)
|
||||||
|
|
||||||
addToast('Note created successfully', 'success')
|
addToast('Note created successfully', 'success')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -182,9 +288,12 @@ export function NoteInput() {
|
|||||||
setContent('')
|
setContent('')
|
||||||
setCheckItems([])
|
setCheckItems([])
|
||||||
setImages([])
|
setImages([])
|
||||||
|
setHistory([{ title: '', content: '' }])
|
||||||
|
setHistoryIndex(0)
|
||||||
setType('text')
|
setType('text')
|
||||||
setColor('default')
|
setColor('default')
|
||||||
setIsArchived(false)
|
setIsArchived(false)
|
||||||
|
setCurrentReminder(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isExpanded) {
|
if (!isExpanded) {
|
||||||
@ -217,6 +326,7 @@ export function NoteInput() {
|
|||||||
const colorClasses = NOTE_COLORS[color] || NOTE_COLORS.default
|
const colorClasses = NOTE_COLORS[color] || NOTE_COLORS.default
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Card className={cn(
|
<Card className={cn(
|
||||||
"p-4 max-w-2xl mx-auto mb-8 shadow-lg border",
|
"p-4 max-w-2xl mx-auto mb-8 shadow-lg border",
|
||||||
colorClasses.card
|
colorClasses.card
|
||||||
@ -253,13 +363,46 @@ export function NoteInput() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{type === 'text' ? (
|
{type === 'text' ? (
|
||||||
<Textarea
|
<div className="space-y-2">
|
||||||
placeholder="Take a note..."
|
{/* Markdown toggle button */}
|
||||||
value={content}
|
{isMarkdown && (
|
||||||
onChange={(e) => setContent(e.target.value)}
|
<div className="flex justify-end gap-2">
|
||||||
className="border-0 focus-visible:ring-0 min-h-[100px] resize-none"
|
<Button
|
||||||
autoFocus
|
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">
|
<div className="space-y-2">
|
||||||
{checkItems.map((item) => (
|
{checkItems.map((item) => (
|
||||||
@ -301,9 +444,12 @@ export function NoteInput() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8"
|
className={cn(
|
||||||
|
"h-8 w-8",
|
||||||
|
currentReminder && "text-blue-600"
|
||||||
|
)}
|
||||||
title="Remind me"
|
title="Remind me"
|
||||||
onClick={handleReminder}
|
onClick={handleReminderOpen}
|
||||||
>
|
>
|
||||||
<Bell className="h-4 w-4" />
|
<Bell className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@ -311,6 +457,27 @@ export function NoteInput() {
|
|||||||
<TooltipContent>Remind me</TooltipContent>
|
<TooltipContent>Remind me</TooltipContent>
|
||||||
</Tooltip>
|
</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>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@ -390,31 +557,54 @@ export function NoteInput() {
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>More</TooltipContent>
|
<TooltipContent>More</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<Tooltip>
|
||||||
<Button
|
<TooltipTrigger asChild>
|
||||||
onClick={handleSubmit}
|
<Button
|
||||||
disabled={isSubmitting}
|
variant="ghost"
|
||||||
size="sm"
|
size="icon"
|
||||||
</TooltipProvider>
|
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">
|
<Tooltip>
|
||||||
<Button
|
<TooltipTrigger asChild>
|
||||||
onClick={handleSubmit}
|
<Button
|
||||||
disabled={isSubmitting}
|
variant="ghost"
|
||||||
size="sm"
|
size="icon"
|
||||||
>
|
className="h-8 w-8"
|
||||||
{isSubmitting ? 'Adding...' : 'Add'}
|
onClick={handleRedo}
|
||||||
</Button>
|
disabled={historyIndex >= history.length - 1}
|
||||||
<Button
|
>
|
||||||
variant="ghost"
|
<Redo2 className="h-4 w-4" />
|
||||||
onClick={handleClose}
|
</Button>
|
||||||
size="sm"
|
</TooltipTrigger>
|
||||||
>
|
<TooltipContent>Redo (Ctrl+Y)</TooltipContent>
|
||||||
Close
|
</Tooltip>
|
||||||
</Button>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -427,5 +617,48 @@ export function NoteInput() {
|
|||||||
onChange={handleImageUpload}
|
onChange={handleImageUpload}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</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>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,6 +15,11 @@ export interface Note {
|
|||||||
checkItems: CheckItem[] | null;
|
checkItems: CheckItem[] | null;
|
||||||
labels: string[] | null;
|
labels: string[] | null;
|
||||||
images: string[] | null;
|
images: string[] | null;
|
||||||
|
reminder: Date | null;
|
||||||
|
reminderRecurrence: string | null;
|
||||||
|
reminderLocation: string | null;
|
||||||
|
isMarkdown: boolean;
|
||||||
|
order: number;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
1727
keep-notes/package-lock.json
generated
1727
keep-notes/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -5,9 +5,13 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start"
|
"start": "next start",
|
||||||
|
"test": "playwright test",
|
||||||
|
"test:ui": "playwright test --ui",
|
||||||
|
"test:headed": "playwright test --headed"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@auth/prisma-adapter": "^2.11.1",
|
||||||
"@libsql/client": "^0.15.15",
|
"@libsql/client": "^0.15.15",
|
||||||
"@prisma/adapter-better-sqlite3": "^7.2.0",
|
"@prisma/adapter-better-sqlite3": "^7.2.0",
|
||||||
"@prisma/adapter-libsql": "^7.2.0",
|
"@prisma/adapter-libsql": "^7.2.0",
|
||||||
@ -22,15 +26,20 @@
|
|||||||
"better-sqlite3": "^12.5.0",
|
"better-sqlite3": "^12.5.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"next": "16.1.1",
|
"next": "16.1.1",
|
||||||
|
"next-auth": "^5.0.0-beta.30",
|
||||||
"prisma": "5.22.0",
|
"prisma": "5.22.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"tailwind-merge": "^3.4.0"
|
"tailwind-merge": "^3.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.57.0",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
|||||||
27
keep-notes/playwright.config.ts
Normal file
27
keep-notes/playwright.config.ts
Normal 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.
@ -0,0 +1,5 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Note" ADD COLUMN "reminder" DATETIME;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Note_reminder_idx" ON "Note"("reminder");
|
||||||
@ -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;
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Note" ADD COLUMN "reminderLocation" TEXT;
|
||||||
|
ALTER TABLE "Note" ADD COLUMN "reminderRecurrence" TEXT;
|
||||||
@ -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");
|
||||||
@ -10,22 +10,82 @@ datasource db {
|
|||||||
url = env("DATABASE_URL")
|
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 {
|
model Note {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
title String?
|
title String?
|
||||||
content String
|
content String
|
||||||
color String @default("default")
|
color String @default("default")
|
||||||
isPinned Boolean @default(false)
|
isPinned Boolean @default(false)
|
||||||
isArchived Boolean @default(false)
|
isArchived Boolean @default(false)
|
||||||
type String @default("text") // "text" or "checklist"
|
type String @default("text") // "text" or "checklist"
|
||||||
checkItems String? // For checklist items stored as JSON string
|
checkItems String? // For checklist items stored as JSON string
|
||||||
labels String? // Array of label names stored as JSON string
|
labels String? // Array of label names stored as JSON string
|
||||||
images String? // Array of image URLs stored as JSON string
|
images String? // Array of image URLs stored as JSON string
|
||||||
order Int @default(0)
|
reminder DateTime? // Reminder date and time
|
||||||
createdAt DateTime @default(now())
|
reminderRecurrence String? // "none", "daily", "weekly", "monthly", "custom"
|
||||||
updatedAt DateTime @updatedAt
|
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([isPinned])
|
||||||
@@index([isArchived])
|
@@index([isArchived])
|
||||||
@@index([order])
|
@@index([order])
|
||||||
|
@@index([reminder])
|
||||||
|
@@index([userId])
|
||||||
}
|
}
|
||||||
|
|||||||
181
keep-notes/tests/drag-drop.spec.ts
Normal file
181
keep-notes/tests/drag-drop.spec.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
423
keep-notes/tests/reminder-dialog.spec.ts
Normal file
423
keep-notes/tests/reminder-dialog.spec.ts
Normal 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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
167
keep-notes/tests/undo-redo.spec.ts
Normal file
167
keep-notes/tests/undo-redo.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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
|
// Create MCP server
|
||||||
const server = new Server(
|
const server = new Server(
|
||||||
{
|
{
|
||||||
@ -118,7 +139,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'get_notes',
|
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: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@ -131,6 +152,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|||||||
type: 'string',
|
type: 'string',
|
||||||
description: 'Search query to filter notes',
|
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 {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: JSON.stringify(notes.map(parseNote), null, 2),
|
text: JSON.stringify(parsedNotes, null, 2),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,86 +1,101 @@
|
|||||||
{
|
{
|
||||||
"meta": {
|
"meta": {
|
||||||
"instanceId": "memento-demo"
|
"instanceId": "agentic-research-workflow"
|
||||||
},
|
},
|
||||||
"nodes": [
|
"nodes": [
|
||||||
{
|
|
||||||
"parameters": {},
|
|
||||||
"id": "b1c9e8f2-1234-4567-89ab-cdef12345678",
|
|
||||||
"name": "Déclencheur Manuel (Start)",
|
|
||||||
"type": "n8n-nodes-base.manualTrigger",
|
|
||||||
"typeVersion": 1,
|
|
||||||
"position": [
|
|
||||||
250,
|
|
||||||
300
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"values": {
|
"rule": {
|
||||||
"string": [
|
"interval": [
|
||||||
{
|
{
|
||||||
"name": "subject",
|
"field": "hours",
|
||||||
"value": "Réunion Projet MCP"
|
"hoursInterval": 2
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "body",
|
|
||||||
"value": "N'oublie pas de vérifier l'intégration N8N aujourd'hui à 15h00."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "labels",
|
|
||||||
"value": "['work', 'n8n']"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
}
|
||||||
"options": {}
|
|
||||||
},
|
},
|
||||||
"id": "a2b3c4d5-1234-4567-89ab-cdef12345678",
|
"id": "a1b2c3d4",
|
||||||
"name": "Simuler Email (Données)",
|
"name": "Trigger: Veille Heure",
|
||||||
"type": "n8n-nodes-base.set",
|
"type": "n8n-nodes-base.scheduleTrigger",
|
||||||
"typeVersion": 3.3,
|
"typeVersion": 1.1,
|
||||||
"position": [
|
"position": [
|
||||||
500,
|
240,
|
||||||
300
|
400
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"parameters": {
|
"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": {}
|
"options": {}
|
||||||
},
|
},
|
||||||
"id": "e4f5g6h7-1234-4567-89ab-cdef12345678",
|
"id": "b2c3d4e5",
|
||||||
"name": "AI Agent (MCP Client)",
|
"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",
|
"type": "@n8n/n8n-nodes-langchain.agent",
|
||||||
"typeVersion": 1.5,
|
"typeVersion": 1.5,
|
||||||
"position": [
|
"position": [
|
||||||
750,
|
680,
|
||||||
300
|
400
|
||||||
],
|
],
|
||||||
|
"notes": "Cet Agent décide lui-même si l'info vaut le coup d'être sauvegardée. Pas de filtre statique.",
|
||||||
"credentials": {
|
"credentials": {
|
||||||
"openAiApi": {
|
"openAiApi": {
|
||||||
"id": "1",
|
"id": "OPENAI_API_KEY_ID",
|
||||||
"name": "OpenAI (N'oubliez pas de configurer votre clé)"
|
"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": {
|
"connections": {
|
||||||
"Déclencheur Manuel (Start)": {
|
"Trigger: Veille Heure": {
|
||||||
"main": [
|
"main": [
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"node": "Simuler Email (Données)",
|
"node": "Source: Flux AI (Tech)",
|
||||||
"type": "main",
|
"type": "main",
|
||||||
"index": 0
|
"index": 0
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"Simuler Email (Données)": {
|
"Source: Flux AI (Tech)": {
|
||||||
"main": [
|
"main": [
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"node": "AI Agent (MCP Client)",
|
"node": "Agent: Architecte (Filtre & MCP)",
|
||||||
"type": "main",
|
"type": "main",
|
||||||
"index": 0
|
"index": 0
|
||||||
}
|
}
|
||||||
|
|||||||
282
n8n-tech-news-workflow.json
Normal file
282
n8n-tech-news-workflow.json
Normal 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"
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user