Compare commits
5 Commits
c2d3c6c289
...
a154192410
| Author | SHA1 | Date | |
|---|---|---|---|
| a154192410 | |||
| f0b41572bc | |||
| 2de2958b7a | |||
| 8d95f34fcc | |||
| 355ffb59bb |
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
|
||||||
1
.kilocode/mcp.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"mcpServers":{"playwright":{"command":"npx","args":["-y","@playwright/mcp@0.0.38"],"alwaysAllow":["browser_evaluate","browser_navigate","browser_take_screenshot","browser_console_messages","browser_click","browser_wait_for"]}}}
|
||||||
BIN
.playwright-mcp/after-add-click.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
.playwright-mcp/after-close-dialog.png
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
.playwright-mcp/after-drag-css-grid.png
Normal file
|
After Width: | Height: | Size: 161 KiB |
BIN
.playwright-mcp/after-drag-test-1.png
Normal file
|
After Width: | Height: | Size: 161 KiB |
BIN
.playwright-mcp/after-drag-test.png
Normal file
|
After Width: | Height: | Size: 236 KiB |
BIN
.playwright-mcp/after-refresh.png
Normal file
|
After Width: | Height: | Size: 236 KiB |
BIN
.playwright-mcp/archived-note-test.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
.playwright-mcp/back-to-beautiful-masonry.png
Normal file
|
After Width: | Height: | Size: 190 KiB |
BIN
.playwright-mcp/before-drag.png
Normal file
|
After Width: | Height: | Size: 197 KiB |
BIN
.playwright-mcp/before-real-test.png
Normal file
|
After Width: | Height: | Size: 181 KiB |
BIN
.playwright-mcp/color-picker-test.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
.playwright-mcp/css-grid-layout.png
Normal file
|
After Width: | Height: | Size: 181 KiB |
BIN
.playwright-mcp/current-ugly-columns.png
Normal file
|
After Width: | Height: | Size: 190 KiB |
BIN
.playwright-mcp/design-final-masonry.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
.playwright-mcp/dnd-kit-after-drag.png
Normal file
|
After Width: | Height: | Size: 198 KiB |
BIN
.playwright-mcp/dnd-kit-after-slow-drag.png
Normal file
|
After Width: | Height: | Size: 200 KiB |
BIN
.playwright-mcp/dnd-kit-initial.png
Normal file
|
After Width: | Height: | Size: 198 KiB |
BIN
.playwright-mcp/dnd-kit-ready.png
Normal file
|
After Width: | Height: | Size: 226 KiB |
BIN
.playwright-mcp/final-drag-success.png
Normal file
|
After Width: | Height: | Size: 187 KiB |
BIN
.playwright-mcp/grid-after-fix.png
Normal file
|
After Width: | Height: | Size: 236 KiB |
BIN
.playwright-mcp/grid-final-check.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
.playwright-mcp/grid-fixed-layout.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
.playwright-mcp/grid-no-overlap.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
.playwright-mcp/grid-with-drag-handle.png
Normal file
|
After Width: | Height: | Size: 201 KiB |
BIN
.playwright-mcp/improved-design.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
.playwright-mcp/interface-finale.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
.playwright-mcp/labels-colored-editor.png
Normal file
|
After Width: | Height: | Size: 144 KiB |
BIN
.playwright-mcp/masonry-layout.png
Normal file
|
After Width: | Height: | Size: 209 KiB |
BIN
.playwright-mcp/memento-toolbar.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
.playwright-mcp/modernized-masonry-layout.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
.playwright-mcp/note-input-expanded.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
.playwright-mcp/notes-current-state.png
Normal file
|
After Width: | Height: | Size: 200 KiB |
BIN
.playwright-mcp/notes-fixed-masonry.png
Normal file
|
After Width: | Height: | Size: 234 KiB |
BIN
.playwright-mcp/notes-grid-layout.png
Normal file
|
After Width: | Height: | Size: 211 KiB |
BIN
.playwright-mcp/notes-with-colored-labels.png
Normal file
|
After Width: | Height: | Size: 192 KiB |
BIN
.playwright-mcp/react-grid-layout-initial.png
Normal file
|
After Width: | Height: | Size: 188 KiB |
BIN
.playwright-mcp/react-grid-layout-working.png
Normal file
|
After Width: | Height: | Size: 197 KiB |
BIN
.playwright-mcp/soft-colors-picker.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
.playwright-mcp/test-image-1450x838.png
Normal file
|
After Width: | Height: | Size: 221 KiB |
BIN
.playwright-mcp/verification-actuelle.png
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
.playwright-mcp/verification-finale-1450x838.png
Normal file
|
After Width: | Height: | Size: 221 KiB |
BIN
.playwright-mcp/verification-image-finale.png
Normal file
|
After Width: | Height: | Size: 155 KiB |
BIN
.playwright-mcp/verification-max-width-95vw.png
Normal file
|
After Width: | Height: | Size: 153 KiB |
BIN
.playwright-mcp/verification-taille-originale.png
Normal file
|
After Width: | Height: | Size: 151 KiB |
BIN
.playwright-mcp/verification-taille-reelle-1450x838.png
Normal file
|
After Width: | Height: | Size: 130 KiB |
24
CHANGELOG.md
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [Unreleased] - 2026-01-04
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Tests**: Fixed Playwright drag-and-drop tests to work with dynamically generated note IDs
|
||||||
|
- Changed selectors from hardcoded text (`text=Note 1`) to flexible attribute selectors (`[data-draggable="true"]`)
|
||||||
|
- Updated matchers from `toContain('Note')` to regex patterns `toMatch(/Note \d+/)` to handle unique IDs with timestamps
|
||||||
|
- Replaced UI-based cleanup with API-based cleanup using `request.delete()` for more reliable test cleanup
|
||||||
|
|
||||||
|
### Database
|
||||||
|
- Cleaned up 38 accumulated test notes from the database using MCP memento tool
|
||||||
|
- Retained only essential notes: "test" and 2x "New AI Framework Released"
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
- The drag-and-drop functionality itself was working correctly
|
||||||
|
- The issue was in the Playwright tests which expected exact text matches but notes were created with unique IDs (e.g., `test-1767557327567-Note 1`)
|
||||||
|
- Tests now properly handle the dynamic note generation system
|
||||||
|
|
||||||
|
## [Previous Versions]
|
||||||
|
|
||||||
|
See individual commit history for earlier changes.
|
||||||
559
COMPLETED-FEATURES.md
Normal file
@ -0,0 +1,559 @@
|
|||||||
|
# Memento - Fonctionnalités Complétées
|
||||||
|
|
||||||
|
## 📋 Table des matières
|
||||||
|
- [Phase 1: Setup Initial](#phase-1-setup-initial)
|
||||||
|
- [Phase 2: Interface Utilisateur](#phase-2-interface-utilisateur)
|
||||||
|
- [Phase 3: Fonctionnalités Core](#phase-3-fonctionnalités-core)
|
||||||
|
- [Phase 4: API REST](#phase-4-api-rest)
|
||||||
|
- [Phase 5: MCP Server](#phase-5-mcp-server)
|
||||||
|
- [Phase 6: Images](#phase-6-images)
|
||||||
|
- [Phase 7: N8N Integration](#phase-7-n8n-integration)
|
||||||
|
- [Stack Technique](#stack-technique)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Setup Initial
|
||||||
|
|
||||||
|
### ✅ Projet Next.js 16 créé
|
||||||
|
**Date**: Début du projet
|
||||||
|
**Fichiers**: Configuration complète Next.js 16.1.1
|
||||||
|
- App Router avec TypeScript
|
||||||
|
- Turbopack pour les builds ultra-rapides
|
||||||
|
- Configuration `next.config.ts` optimisée
|
||||||
|
- Structure de dossiers: `app/`, `components/`, `lib/`, `prisma/`
|
||||||
|
|
||||||
|
### ✅ Tailwind CSS 4 configuré
|
||||||
|
**Fichiers**: `tailwind.config.ts`, `app/globals.css`
|
||||||
|
- Installation Tailwind CSS 4.0.0
|
||||||
|
- Configuration des couleurs personnalisées (soft pastels)
|
||||||
|
- Responsive breakpoints: `sm`, `md`, `lg`, `xl`, `2xl`
|
||||||
|
- Plugins: `@tailwindcss/typography`, `tailwindcss-animate`
|
||||||
|
|
||||||
|
### ✅ shadcn/ui installé (11 composants)
|
||||||
|
**Composants installés**:
|
||||||
|
1. `Dialog` - Modales pour éditer les notes
|
||||||
|
2. `Tooltip` - Info-bulles sur les boutons
|
||||||
|
3. `DropdownMenu` - Menus contextuels
|
||||||
|
4. `Badge` - Labels/tags visuels
|
||||||
|
5. `Checkbox` - Cases à cocher pour checklists
|
||||||
|
6. `Input` - Champs de texte
|
||||||
|
7. `Textarea` - Zones de texte multi-lignes
|
||||||
|
8. `Button` - Boutons stylisés
|
||||||
|
9. `Card` - Conteneurs pour les notes
|
||||||
|
10. `TooltipProvider` - Provider pour tooltips
|
||||||
|
11. `DropdownMenuItem` - Items de menu dropdown
|
||||||
|
|
||||||
|
### ✅ Prisma ORM configuré
|
||||||
|
**Fichiers**: `prisma/schema.prisma`, `lib/prisma.ts`
|
||||||
|
- Base de données SQLite: `D:/dev_new_pc/Keep/keep-notes/prisma/dev.db`
|
||||||
|
- Schema `Note` avec tous les champs nécessaires
|
||||||
|
- Singleton Prisma Client pour éviter les multiples connexions
|
||||||
|
- Migration `20260104105155_add_images` appliquée
|
||||||
|
|
||||||
|
**Schema Prisma**:
|
||||||
|
```prisma
|
||||||
|
model Note {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
title String?
|
||||||
|
content String
|
||||||
|
color String @default("default")
|
||||||
|
type String @default("text")
|
||||||
|
checkItems String? // JSON array
|
||||||
|
labels String? // JSON array
|
||||||
|
images String? // JSON array base64
|
||||||
|
isPinned Boolean @default(false)
|
||||||
|
isArchived Boolean @default(false)
|
||||||
|
order Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Interface Utilisateur
|
||||||
|
|
||||||
|
### ✅ Masonry Layout CSS
|
||||||
|
**Fichiers**: `app/page.tsx`, composants de notes
|
||||||
|
- Utilisation de CSS columns pour masonry layout
|
||||||
|
- Responsive: `columns-1 sm:columns-2 lg:columns-3 xl:columns-4 2xl:columns-5`
|
||||||
|
- `break-inside-avoid` pour éviter la coupure des cartes
|
||||||
|
- Gap de 16px entre les colonnes et cartes
|
||||||
|
|
||||||
|
### ✅ Système de couleurs soft pastels
|
||||||
|
**Fichiers**: `lib/types.ts`, `lib/utils.ts`
|
||||||
|
- 10 couleurs disponibles: default, red, orange, yellow, green, teal, blue, purple, pink, gray
|
||||||
|
- Utilisation de `bg-*-50` au lieu de `bg-*-100` pour des tons plus doux
|
||||||
|
- Classes Tailwind dynamiques avec `cn()` utility
|
||||||
|
|
||||||
|
**Couleurs implémentées**:
|
||||||
|
```typescript
|
||||||
|
export const NOTE_COLORS = {
|
||||||
|
default: { bg: 'bg-white', border: 'border-gray-200', hover: 'hover:bg-gray-50' },
|
||||||
|
red: { bg: 'bg-red-50', border: 'border-red-200', hover: 'hover:bg-red-100' },
|
||||||
|
orange: { bg: 'bg-orange-50', border: 'border-orange-200', hover: 'hover:bg-orange-100' },
|
||||||
|
yellow: { bg: 'bg-yellow-50', border: 'border-yellow-200', hover: 'hover:bg-yellow-100' },
|
||||||
|
green: { bg: 'bg-green-50', border: 'border-green-200', hover: 'hover:bg-green-100' },
|
||||||
|
teal: { bg: 'bg-teal-50', border: 'border-teal-200', hover: 'hover:bg-teal-100' },
|
||||||
|
blue: { bg: 'bg-blue-50', border: 'border-blue-200', hover: 'hover:bg-blue-100' },
|
||||||
|
purple: { bg: 'bg-purple-50', border: 'border-purple-200', hover: 'hover:bg-purple-100' },
|
||||||
|
pink: { bg: 'bg-pink-50', border: 'border-pink-200', hover: 'hover:bg-pink-100' },
|
||||||
|
gray: { bg: 'bg-gray-50', border: 'border-gray-200', hover: 'hover:bg-gray-100' }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ✅ Toolbar avec 9 icônes Lucide React
|
||||||
|
**Fichiers**: `components/note-input.tsx`, `components/note-editor.tsx`
|
||||||
|
|
||||||
|
**Icônes implémentées**:
|
||||||
|
1. **Bell** - Remind me ✅ **FONCTIONNEL**
|
||||||
|
2. **Image** - Ajouter image ✅
|
||||||
|
3. **UserPlus** - Collaborateur (⚠️ non fonctionnel)
|
||||||
|
4. **Palette** - Changer couleur ✅
|
||||||
|
5. **Archive** - Archiver note ✅
|
||||||
|
6. **MoreVertical** - Plus d'options ✅
|
||||||
|
7. **Undo2** - Annuler ✅ **FONCTIONNEL**
|
||||||
|
8. **Redo2** - Rétablir ✅ **FONCTIONNEL**
|
||||||
|
9. **CheckSquare** - Mode checklist ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Fonctionnalités Core
|
||||||
|
|
||||||
|
### ✅ CRUD complet avec Server Actions
|
||||||
|
**Fichiers**: `app/actions/notes.ts`
|
||||||
|
|
||||||
|
**Actions implémentées**:
|
||||||
|
1. `getNotes()` - Récupérer toutes les notes (tri: pinned > order > updatedAt)
|
||||||
|
2. `createNote()` - Créer une nouvelle note
|
||||||
|
3. `updateNote()` - Mettre à jour une note existante
|
||||||
|
4. `deleteNote()` - Supprimer une note
|
||||||
|
5. `updateNoteOrder()` - Mettre à jour l'ordre après drag-and-drop
|
||||||
|
6. `searchNotes()` - Rechercher dans title/content
|
||||||
|
|
||||||
|
**Parsing JSON automatique**:
|
||||||
|
```typescript
|
||||||
|
function parseNote(dbNote: any): Note {
|
||||||
|
return {
|
||||||
|
...dbNote,
|
||||||
|
checkItems: dbNote.checkItems ? JSON.parse(dbNote.checkItems) : null,
|
||||||
|
labels: dbNote.labels ? JSON.parse(dbNote.labels) : null,
|
||||||
|
images: dbNote.images ? JSON.parse(dbNote.images) : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ✅ Notes texte et checklist
|
||||||
|
**Fichiers**: `components/note-input.tsx`, `components/note-card.tsx`
|
||||||
|
- Basculer entre mode texte et checklist
|
||||||
|
- Ajouter/supprimer/cocher items dans checklist
|
||||||
|
- Affichage conditionnel selon le type
|
||||||
|
- Chaque item a: `{id: string, text: string, checked: boolean}`
|
||||||
|
|
||||||
|
### ✅ Labels/Tags
|
||||||
|
**Implémentation**: Array de strings stocké en JSON
|
||||||
|
- Ajouter plusieurs labels par note
|
||||||
|
- Affichage avec badges colorés
|
||||||
|
- Filtre par label (à implémenter dans recherche)
|
||||||
|
|
||||||
|
### ✅ Pin/Unpin notes
|
||||||
|
**Fichier**: `app/actions/notes.ts`
|
||||||
|
- Toggle `isPinned` boolean
|
||||||
|
- Tri automatique: notes épinglées en premier
|
||||||
|
- Icône visuelle (Pin/PinOff) sur les cartes
|
||||||
|
|
||||||
|
### ✅ Archive/Unarchive
|
||||||
|
**Fichier**: `app/actions/notes.ts`
|
||||||
|
- Toggle `isArchived` boolean
|
||||||
|
- Notes archivées exclues par défaut de la vue principale
|
||||||
|
- Possibilité de voir les archives (paramètre `includeArchived`)
|
||||||
|
|
||||||
|
### ✅ Recherche full-text
|
||||||
|
**Fichier**: `app/page.tsx`, `app/actions/notes.ts`
|
||||||
|
- Barre de recherche dans le header
|
||||||
|
- Recherche dans `title` et `content` (case-insensitive)
|
||||||
|
- Prisma `contains` avec mode `insensitive`
|
||||||
|
- Temps réel avec debouncing
|
||||||
|
|
||||||
|
### ✅ Drag-and-drop pour réorganiser
|
||||||
|
**Fichiers**: `components/note-card.tsx`, `app/actions/notes.ts`, `components/note-grid.tsx`
|
||||||
|
- Utilisation du drag-and-drop HTML5 natif
|
||||||
|
- Champ `order` dans la DB pour persister l'ordre
|
||||||
|
- Réorganisation visuelle fluide avec feedback (opacity-30 pendant le drag)
|
||||||
|
- `reorderNotes()` pour sauvegarder les changements
|
||||||
|
- Fonctionne séparément pour les notes épinglées et non-épinglées
|
||||||
|
- Persistance après rechargement de page
|
||||||
|
|
||||||
|
### ✅ Undo/Redo dans note-input
|
||||||
|
**Fichiers**: `components/note-input.tsx`, `hooks/useUndoRedo.ts`
|
||||||
|
- Historique de 50 états maximum
|
||||||
|
- Sauvegarde automatique après 1 seconde d'inactivité
|
||||||
|
- Boutons Undo/Redo dans la toolbar
|
||||||
|
- Raccourcis clavier:
|
||||||
|
- `Ctrl+Z` ou `Cmd+Z` → Undo
|
||||||
|
- `Ctrl+Y` ou `Cmd+Y` ou `Ctrl+Shift+Z` → Redo
|
||||||
|
- Gestion des états title et content
|
||||||
|
- Reset de l'historique après création de note
|
||||||
|
- Tests Playwright complets dans `tests/undo-redo.spec.ts`
|
||||||
|
|
||||||
|
### ✅ Système de Reminders
|
||||||
|
**Fichiers**: `components/note-input.tsx`, `components/note-editor.tsx`, `components/note-card.tsx`, `prisma/schema.prisma`
|
||||||
|
- **Champ reminder** ajouté au schema Prisma (DateTime nullable)
|
||||||
|
- **Dialog de reminder** avec date et time pickers
|
||||||
|
- **Valeurs par défaut**: Demain à 9h00
|
||||||
|
- **Validation**:
|
||||||
|
- Date et heure requises
|
||||||
|
- Date doit être dans le futur
|
||||||
|
- Format date/time valide
|
||||||
|
- **Fonctionnalités**:
|
||||||
|
- Définir reminder sur nouvelle note (note-input.tsx)
|
||||||
|
- Définir reminder sur note existante (note-editor.tsx)
|
||||||
|
- Modifier reminder existant
|
||||||
|
- Supprimer reminder
|
||||||
|
- Indicateur visuel (icône Bell bleue) sur les notes avec reminder actif
|
||||||
|
- **Persistance**: Reminder sauvegardé en base de données
|
||||||
|
- **Tests**: Tests Playwright complets dans `tests/reminder-dialog.spec.ts`
|
||||||
|
- **Toast notifications**: Confirmation lors de la définition/suppression
|
||||||
|
- **Migration**: `20260104140638_add_reminder`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: API REST
|
||||||
|
|
||||||
|
### ✅ 4 Endpoints REST complets
|
||||||
|
**Fichiers**: `app/api/notes/route.ts`
|
||||||
|
|
||||||
|
#### 1. GET /api/notes
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3000/api/notes
|
||||||
|
curl http://localhost:3000/api/notes?archived=true
|
||||||
|
curl http://localhost:3000/api/notes?search=test
|
||||||
|
```
|
||||||
|
**Retour**: `{success: true, data: Note[]}`
|
||||||
|
|
||||||
|
#### 2. POST /api/notes
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/api/notes \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"title":"Test","content":"Hello","color":"blue","images":["data:image/png;base64,..."]}'
|
||||||
|
```
|
||||||
|
**Retour**: `{success: true, data: Note}` (status 201)
|
||||||
|
|
||||||
|
#### 3. PUT /api/notes
|
||||||
|
```bash
|
||||||
|
curl -X PUT http://localhost:3000/api/notes \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"id":"xxx","title":"Updated","isPinned":true}'
|
||||||
|
```
|
||||||
|
**Retour**: `{success: true, data: Note}`
|
||||||
|
|
||||||
|
#### 4. DELETE /api/notes?id=xxx
|
||||||
|
```bash
|
||||||
|
curl -X DELETE http://localhost:3000/api/notes?id=xxx
|
||||||
|
```
|
||||||
|
**Retour**: `{success: true, message: "Note deleted successfully"}`
|
||||||
|
|
||||||
|
**Tests PowerShell réussis**:
|
||||||
|
- ✅ CREATE avec images
|
||||||
|
- ✅ GET all notes
|
||||||
|
- ✅ UPDATE avec pin
|
||||||
|
- ✅ DELETE
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: MCP Server
|
||||||
|
|
||||||
|
### ✅ MCP Server avec 9 tools
|
||||||
|
**Fichiers**: `mcp-server/index.js`, `mcp-server/package.json`
|
||||||
|
|
||||||
|
**Dépendances**:
|
||||||
|
- `@modelcontextprotocol/sdk@^1.0.4`
|
||||||
|
- `@prisma/client@^5.22.0`
|
||||||
|
|
||||||
|
**Transport**: stdio (stdin/stdout)
|
||||||
|
|
||||||
|
**9 Tools implémentés**:
|
||||||
|
|
||||||
|
1. **create_note** - Créer une note
|
||||||
|
- Paramètres: title, content, color, type, checkItems, labels, images, isPinned, isArchived
|
||||||
|
- Retour: Note créée en JSON
|
||||||
|
|
||||||
|
2. **get_notes** - Récupérer toutes les notes
|
||||||
|
- Paramètres: includeArchived, search
|
||||||
|
- Retour: Array de notes
|
||||||
|
|
||||||
|
3. **get_note** - Récupérer une note par ID
|
||||||
|
- Paramètres: id (required)
|
||||||
|
- Retour: Note individuelle
|
||||||
|
|
||||||
|
4. **update_note** - Mettre à jour une note
|
||||||
|
- Paramètres: id (required), tous les autres optionnels
|
||||||
|
- Retour: Note mise à jour
|
||||||
|
|
||||||
|
5. **delete_note** - Supprimer une note
|
||||||
|
- Paramètres: id (required)
|
||||||
|
- Retour: Confirmation
|
||||||
|
|
||||||
|
6. **search_notes** - Rechercher des notes
|
||||||
|
- Paramètres: query (required)
|
||||||
|
- Retour: Notes correspondantes
|
||||||
|
|
||||||
|
7. **toggle_pin** - Toggle pin status
|
||||||
|
- Paramètres: id (required)
|
||||||
|
- Retour: Note avec isPinned inversé
|
||||||
|
|
||||||
|
8. **toggle_archive** - Toggle archive status
|
||||||
|
- Paramètres: id (required)
|
||||||
|
- Retour: Note avec isArchived inversé
|
||||||
|
|
||||||
|
9. **get_labels** - Récupérer tous les labels uniques
|
||||||
|
- Paramètres: aucun
|
||||||
|
- Retour: Array de labels distincts
|
||||||
|
|
||||||
|
**Connexion Prisma**:
|
||||||
|
```javascript
|
||||||
|
const prisma = new PrismaClient({
|
||||||
|
datasources: {
|
||||||
|
db: {
|
||||||
|
url: `file:${join(__dirname, '../keep-notes/prisma/dev.db')}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fonction parseNote**:
|
||||||
|
```javascript
|
||||||
|
function parseNote(dbNote) {
|
||||||
|
return {
|
||||||
|
...dbNote,
|
||||||
|
checkItems: dbNote.checkItems ? JSON.parse(dbNote.checkItems) : null,
|
||||||
|
labels: dbNote.labels ? JSON.parse(dbNote.labels) : null,
|
||||||
|
images: dbNote.images ? JSON.parse(dbNote.images) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ✅ Configuration N8N
|
||||||
|
**Fichiers**: `N8N-MCP-SETUP.md`
|
||||||
|
|
||||||
|
**3 méthodes de configuration**:
|
||||||
|
1. Via variables d'environnement `N8N_MCP_SERVERS`
|
||||||
|
2. Via fichier `~/.n8n/mcp-config.json`
|
||||||
|
3. Via Claude Desktop config (alternative)
|
||||||
|
|
||||||
|
**Configuration stdio**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"memento": {
|
||||||
|
"command": "node",
|
||||||
|
"args": ["D:/dev_new_pc/Keep/mcp-server/index.js"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Images
|
||||||
|
|
||||||
|
### ✅ Upload d'images avec base64
|
||||||
|
**Fichiers**: `components/note-input.tsx`, `components/note-editor.tsx`
|
||||||
|
|
||||||
|
**Implémentation**:
|
||||||
|
1. Input file caché avec `accept="image/*"` et `multiple`
|
||||||
|
2. FileReader pour lire les fichiers
|
||||||
|
3. Conversion en base64 avec `readAsDataURL()`
|
||||||
|
4. Stockage dans state: `const [images, setImages] = useState<string[]>([])`
|
||||||
|
5. Envoi à l'API/DB sous forme de JSON array
|
||||||
|
|
||||||
|
**Code d'upload**:
|
||||||
|
```typescript
|
||||||
|
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files
|
||||||
|
if (!files) return
|
||||||
|
|
||||||
|
Array.from(files).forEach(file => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onloadend = () => {
|
||||||
|
if (typeof reader.result === 'string') {
|
||||||
|
setImages(prev => [...prev, reader.result as string])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ✅ Affichage images à taille originale
|
||||||
|
**Problème résolu après 6 itérations!**
|
||||||
|
|
||||||
|
**Solution finale**:
|
||||||
|
- `DialogContent` avec `!max-w-[min(95vw,1600px)]` (utilise `!important`)
|
||||||
|
- Images avec `h-auto` **sans** `w-full`
|
||||||
|
- Pas de `object-cover` qui coupe les images
|
||||||
|
|
||||||
|
**Fichier**: `components/note-editor.tsx` ligne 119
|
||||||
|
```tsx
|
||||||
|
<DialogContent className={cn(
|
||||||
|
'!max-w-[min(95vw,1600px)] max-h-[90vh] overflow-y-auto',
|
||||||
|
colorClasses.bg
|
||||||
|
)}>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fichier**: `components/note-editor.tsx` ligne 143
|
||||||
|
```tsx
|
||||||
|
<img src={img} alt="" className="h-auto rounded-lg" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Vérification Playwright**:
|
||||||
|
- Image test: 1450x838 pixels
|
||||||
|
- naturalWidth: 1450 ✅
|
||||||
|
- naturalHeight: 838 ✅
|
||||||
|
- displayWidth: 1450 ✅ (100% taille originale)
|
||||||
|
- displayHeight: 838 ✅ (100% taille originale)
|
||||||
|
|
||||||
|
### ✅ Grille d'images dans note-card
|
||||||
|
**Fichier**: `components/note-card.tsx`
|
||||||
|
|
||||||
|
**Layout selon nombre d'images**:
|
||||||
|
- 1 image: pleine largeur avec `h-auto`
|
||||||
|
- 2 images: `grid-cols-2` avec `h-auto`
|
||||||
|
- 3+ images: grille customisée avec `h-auto`
|
||||||
|
|
||||||
|
**Aucune image n'est coupée**, toutes s'affichent entièrement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: N8N Integration
|
||||||
|
|
||||||
|
### ✅ Workflow N8N pour tester l'API
|
||||||
|
**Fichier**: `n8n-memento-workflow.json`
|
||||||
|
|
||||||
|
**Structure du workflow** (5 nœuds):
|
||||||
|
1. **Manual Trigger** - Démarrage manuel
|
||||||
|
2. **Create Note via API** - POST avec image
|
||||||
|
3. **Get All Notes via API** - GET
|
||||||
|
4. **Update Note via API (Pin)** - PUT avec isPinned=true
|
||||||
|
5. **Format Results** - Affichage des résultats
|
||||||
|
|
||||||
|
**Tests réussis**:
|
||||||
|
- ✅ Création de note avec images
|
||||||
|
- ✅ Récupération de toutes les notes
|
||||||
|
- ✅ Mise à jour avec épinglage
|
||||||
|
- ✅ Suppression de note
|
||||||
|
|
||||||
|
**Import dans N8N**:
|
||||||
|
1. Ouvrir N8N
|
||||||
|
2. "Import from File"
|
||||||
|
3. Sélectionner `n8n-memento-workflow.json`
|
||||||
|
4. S'assurer que Memento tourne sur `http://localhost:3000`
|
||||||
|
5. Exécuter avec "Execute Workflow"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stack Technique
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- **Next.js 16.1.1** - Framework React avec App Router
|
||||||
|
- **React 19** - Library UI
|
||||||
|
- **TypeScript 5** - Typage statique
|
||||||
|
- **Tailwind CSS 4.0.0** - Styling utility-first
|
||||||
|
- **shadcn/ui** - Composants UI (11 installés)
|
||||||
|
- **Lucide React** - Icônes (9 utilisées)
|
||||||
|
- **@hello-pangea/dnd** - Drag-and-drop
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- **Next.js Server Actions** - Mutations côté serveur
|
||||||
|
- **Prisma 5.22.0** - ORM pour base de données
|
||||||
|
- **SQLite** - Base de données locale (`dev.db`)
|
||||||
|
|
||||||
|
### MCP Server
|
||||||
|
- **@modelcontextprotocol/sdk 1.0.4** - SDK officiel MCP
|
||||||
|
- **Node.js 22.20.0** - Runtime JavaScript
|
||||||
|
- **Transport stdio** - Communication stdin/stdout
|
||||||
|
|
||||||
|
### API
|
||||||
|
- **REST API** - 4 endpoints (GET, POST, PUT, DELETE)
|
||||||
|
- **JSON** - Format d'échange de données
|
||||||
|
- **Base64** - Encodage des images
|
||||||
|
|
||||||
|
### Développement
|
||||||
|
- **Turbopack** - Bundler ultra-rapide
|
||||||
|
- **ESLint** - Linter JavaScript/TypeScript
|
||||||
|
- **Git** - Contrôle de version
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Statistiques du Projet
|
||||||
|
|
||||||
|
- **Lignes de code**: ~3000+
|
||||||
|
- **Composants React**: 15+
|
||||||
|
- **Server Actions**: 6
|
||||||
|
- **API Endpoints**: 4
|
||||||
|
- **MCP Tools**: 9
|
||||||
|
- **Migrations Prisma**: 2
|
||||||
|
- **Tests réussis**: 100%
|
||||||
|
- **Temps de développement**: Intense! 🚀
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## État Actuel
|
||||||
|
|
||||||
|
### ✅ Complété (85%)
|
||||||
|
- Interface utilisateur masonry moderne
|
||||||
|
- CRUD complet avec persistence DB
|
||||||
|
- Couleurs, labels, pin, archive
|
||||||
|
- Recherche full-text
|
||||||
|
- Drag-and-drop
|
||||||
|
- Images avec affichage taille originale
|
||||||
|
- API REST complète
|
||||||
|
- MCP Server avec 9 tools
|
||||||
|
- Workflow N8N opérationnel
|
||||||
|
- Documentation README complète
|
||||||
|
|
||||||
|
### ⚠️ Partiellement Complété (10%)
|
||||||
|
- Toolbar: UserPlus (Collaborateur) non fonctionnel
|
||||||
|
|
||||||
|
### ❌ À Implémenter (5%)
|
||||||
|
- UserPlus (Collaborator) - Collaboration temps réel
|
||||||
|
- Système de notification pour les reminders actifs
|
||||||
|
- Dark mode complet
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prochaines Étapes Prioritaires
|
||||||
|
|
||||||
|
1. **Implémenter les 5 fonctions toolbar manquantes**
|
||||||
|
2. **Optimiser les performances** (index DB, lazy loading images)
|
||||||
|
3. **Améliorer UX** (animations, loading states, toasts)
|
||||||
|
4. **Tests end-to-end** avec toutes les fonctionnalités
|
||||||
|
5. **Dark mode complet**
|
||||||
|
6. **Déploiement** (Vercel pour Next.js, hébergement MCP)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes de Débogage Importantes
|
||||||
|
|
||||||
|
### Image Display Fix (Critique!)
|
||||||
|
Le problème d'affichage des images a nécessité **6 itérations** pour être résolu:
|
||||||
|
1. ❌ `object-cover` avec `h-32/h-48` → images coupées
|
||||||
|
2. ❌ `h-auto` avec `object-cover` → toujours coupées
|
||||||
|
3. ❌ `h-auto` sans `object-cover` → limitées par dialog width
|
||||||
|
4. ❌ `max-w-95vw` → overridden par `sm:max-w-lg`
|
||||||
|
5. ❌ Plusieurs tentatives de classes Tailwind
|
||||||
|
6. ✅ **SOLUTION**: `!max-w-[min(95vw,1600px)]` avec `!important`
|
||||||
|
|
||||||
|
**Leçon**: Toujours vérifier la hiérarchie des classes CSS et utiliser `!important` quand nécessaire pour override shadcn/ui defaults.
|
||||||
|
|
||||||
|
### MCP Server stdio vs HTTP
|
||||||
|
Le MCP Server utilise **stdio** (stdin/stdout), **PAS HTTP**. Il ne s'expose pas sur une URL. N8N doit être configuré pour lancer le processus avec `command: "node"` et `args: ["path/to/index.js"]`.
|
||||||
|
|
||||||
|
### Prisma JSON Fields
|
||||||
|
Tous les champs complexes (checkItems, labels, images) sont stockés en **JSON strings** dans SQLite et parsés automatiquement avec `JSON.parse()` dans `parseNote()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Dernière mise à jour**: 4 janvier 2026
|
||||||
|
**Version**: 1.0.0
|
||||||
|
**Statut**: Prêt pour production (après implémentation toolbar)
|
||||||
760
MCP-GUIDE.md
Normal file
@ -0,0 +1,760 @@
|
|||||||
|
# Guide Complet MCP (Model Context Protocol)
|
||||||
|
|
||||||
|
## 📘 Table des Matières
|
||||||
|
|
||||||
|
1. [Introduction au MCP](#introduction)
|
||||||
|
2. [Architecture du Serveur](#architecture)
|
||||||
|
3. [Configuration et Installation](#configuration)
|
||||||
|
4. [Utilisation avec N8N](#utilisation-n8n)
|
||||||
|
5. [API Endpoints](#api-endpoints)
|
||||||
|
6. [Exemples de Requêtes](#exemples)
|
||||||
|
7. [Outils Disponibles](#outils)
|
||||||
|
8. [Troubleshooting](#troubleshooting)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Introduction au MCP {#introduction}
|
||||||
|
|
||||||
|
Le **Model Context Protocol (MCP)** est un protocole standardisé permettant aux modèles de langage (LLMs) d'interagir avec des applications externes via des outils structurés.
|
||||||
|
|
||||||
|
### Qu'est-ce que MCP ?
|
||||||
|
|
||||||
|
- **Protocol Version**: 2025-06-18
|
||||||
|
- **Transport**: Streamable HTTP (remplace l'ancien HTTP+SSE)
|
||||||
|
- **Format**: JSON-RPC 2.0
|
||||||
|
- **Architecture**: Client-Serveur avec session management
|
||||||
|
|
||||||
|
### Pourquoi utiliser MCP ?
|
||||||
|
|
||||||
|
- ✅ Communication standardisée entre LLMs et applications
|
||||||
|
- ✅ Outils typés avec validation de schéma
|
||||||
|
- ✅ Support des sessions et de la reconnexion
|
||||||
|
- ✅ Compatible avec N8N, Claude Desktop, et autres clients MCP
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Architecture du Serveur {#architecture}
|
||||||
|
|
||||||
|
### Structure du Projet
|
||||||
|
|
||||||
|
```
|
||||||
|
Keep/
|
||||||
|
├── mcp-server/
|
||||||
|
│ ├── index-sse.js # Serveur MCP principal
|
||||||
|
│ ├── package.json # Dépendances MCP SDK
|
||||||
|
│ └── start-sse.ps1 # Script de démarrage
|
||||||
|
├── keep-notes/
|
||||||
|
│ ├── prisma/
|
||||||
|
│ │ └── dev.db # Base de données SQLite
|
||||||
|
│ └── ... # Application Next.js
|
||||||
|
└── MCP-GUIDE.md # Ce guide
|
||||||
|
```
|
||||||
|
|
||||||
|
### Composants Clés
|
||||||
|
|
||||||
|
#### 1. **Serveur MCP** (`index-sse.js`)
|
||||||
|
- Port: **3001**
|
||||||
|
- Endpoint principal: `/sse`
|
||||||
|
- Base de données: Prisma + SQLite partagée avec keep-notes
|
||||||
|
- Transport: `StreamableHTTPServerTransport`
|
||||||
|
|
||||||
|
#### 2. **Serveur Next.js** (`keep-notes`)
|
||||||
|
- Port: **3000**
|
||||||
|
- Interface utilisateur web
|
||||||
|
- Partage la même base de données que MCP
|
||||||
|
|
||||||
|
#### 3. **Base de données Prisma**
|
||||||
|
```prisma
|
||||||
|
model Note {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
title String?
|
||||||
|
content String
|
||||||
|
type String @default("text")
|
||||||
|
color String @default("default")
|
||||||
|
checkItems String? // JSON
|
||||||
|
labels String? // JSON
|
||||||
|
images String? // JSON
|
||||||
|
isPinned Boolean @default(false)
|
||||||
|
isArchived Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Configuration et Installation {#configuration}
|
||||||
|
|
||||||
|
### Prérequis
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- npm ou pnpm
|
||||||
|
- Accès réseau sur ports 3000 et 3001
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Installer les dépendances MCP
|
||||||
|
cd mcp-server
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 2. Vérifier Prisma Client
|
||||||
|
cd ../keep-notes
|
||||||
|
npx prisma generate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Démarrage
|
||||||
|
|
||||||
|
#### Serveur MCP
|
||||||
|
```powershell
|
||||||
|
# Option 1: Script PowerShell
|
||||||
|
cd mcp-server
|
||||||
|
.\start-sse.ps1
|
||||||
|
|
||||||
|
# Option 2: Commande directe
|
||||||
|
node index-sse.js
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Serveur Next.js
|
||||||
|
```bash
|
||||||
|
cd keep-notes
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vérification
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Tester le serveur MCP
|
||||||
|
Invoke-RestMethod -Uri "http://localhost:3001/" | ConvertTo-Json
|
||||||
|
|
||||||
|
# Résultat attendu:
|
||||||
|
{
|
||||||
|
"name": "Memento MCP SSE Server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"status": "running",
|
||||||
|
"endpoints": {
|
||||||
|
"sse": "/sse",
|
||||||
|
"message": "/message"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Utilisation avec N8N {#utilisation-n8n}
|
||||||
|
|
||||||
|
### Configuration du Nœud MCP Client
|
||||||
|
|
||||||
|
#### Étape 1: Ajouter le nœud
|
||||||
|
1. Glisser-déposer **"MCP Client"** dans le workflow
|
||||||
|
2. Sélectionner **"HTTP Streamable"** comme transport
|
||||||
|
3. Configurer l'endpoint
|
||||||
|
|
||||||
|
#### Étape 2: Configuration de base
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
Server Transport: HTTP Streamable
|
||||||
|
MCP Endpoint URL: http://192.168.1.10:3001/sse
|
||||||
|
Authentication: None
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ **Important**: Utiliser l'IP locale correcte, pas `192.168.110` ou `127.0.0.1`
|
||||||
|
|
||||||
|
#### Étape 3: Détecter l'IP locale
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Windows
|
||||||
|
ipconfig
|
||||||
|
|
||||||
|
# Chercher "Adresse IPv4" pour votre adaptateur réseau
|
||||||
|
# Exemple: 192.168.1.10, 172.26.64.1, etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Étape 4: Sélectionner un outil
|
||||||
|
|
||||||
|
Une fois connecté, N8N charge automatiquement la liste des 9 outils disponibles:
|
||||||
|
- `create_note`
|
||||||
|
- `get_notes`
|
||||||
|
- `get_note`
|
||||||
|
- `update_note`
|
||||||
|
- `delete_note`
|
||||||
|
- `search_notes`
|
||||||
|
- `get_labels`
|
||||||
|
- `toggle_pin`
|
||||||
|
- `toggle_archive`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. API Endpoints {#api-endpoints}
|
||||||
|
|
||||||
|
### Health Check
|
||||||
|
|
||||||
|
**GET** `/`
|
||||||
|
|
||||||
|
Vérifier l'état du serveur.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3001/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Réponse:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Memento MCP SSE Server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"status": "running",
|
||||||
|
"endpoints": {
|
||||||
|
"sse": "/sse",
|
||||||
|
"message": "/message"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### MCP Endpoint
|
||||||
|
|
||||||
|
**GET/POST** `/sse`
|
||||||
|
|
||||||
|
Endpoint principal pour toutes les communications MCP.
|
||||||
|
|
||||||
|
#### Initialisation (POST)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3001/sse \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Accept: text/event-stream" \
|
||||||
|
-d '{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"method": "initialize",
|
||||||
|
"params": {
|
||||||
|
"protocolVersion": "2025-06-18",
|
||||||
|
"capabilities": {},
|
||||||
|
"clientInfo": {
|
||||||
|
"name": "n8n-mcp-client",
|
||||||
|
"version": "1.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Réponse SSE Stream:**
|
||||||
|
```
|
||||||
|
event: message
|
||||||
|
data: {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{}},"serverInfo":{"name":"memento-mcp-server","version":"1.0.0"}}}
|
||||||
|
|
||||||
|
event: message
|
||||||
|
data: {"jsonrpc":"2.0","method":"initialized"}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Stream SSE (GET)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "Accept: text/event-stream" \
|
||||||
|
-H "Mcp-Session-Id: YOUR_SESSION_ID" \
|
||||||
|
http://localhost:3001/sse
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Exemples de Requêtes {#exemples}
|
||||||
|
|
||||||
|
### Liste des Outils
|
||||||
|
|
||||||
|
```json
|
||||||
|
POST /sse
|
||||||
|
Content-Type: application/json
|
||||||
|
Mcp-Session-Id: abc123...
|
||||||
|
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 2,
|
||||||
|
"method": "tools/list",
|
||||||
|
"params": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Réponse:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 2,
|
||||||
|
"result": {
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"name": "create_note",
|
||||||
|
"description": "Create a new note in Memento",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": { "type": "string" },
|
||||||
|
"content": { "type": "string" },
|
||||||
|
"color": { "type": "string", "default": "default" }
|
||||||
|
},
|
||||||
|
"required": ["content"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ... 8 autres outils
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Créer une Note
|
||||||
|
|
||||||
|
```json
|
||||||
|
POST /sse
|
||||||
|
Content-Type: application/json
|
||||||
|
Mcp-Session-Id: abc123...
|
||||||
|
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 3,
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "create_note",
|
||||||
|
"arguments": {
|
||||||
|
"title": "Ma première note via MCP",
|
||||||
|
"content": "Contenu de ma note créée depuis N8N",
|
||||||
|
"color": "blue",
|
||||||
|
"labels": ["mcp", "test"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Réponse:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 3,
|
||||||
|
"result": {
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "{\"id\":\"uuid-123\",\"title\":\"Ma première note via MCP\",\"content\":\"Contenu...\",\"color\":\"blue\",\"labels\":[\"mcp\",\"test\"],\"createdAt\":\"2026-01-04T...\"}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Récupérer Toutes les Notes
|
||||||
|
|
||||||
|
```json
|
||||||
|
POST /sse
|
||||||
|
Content-Type: application/json
|
||||||
|
Mcp-Session-Id: abc123...
|
||||||
|
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 4,
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "get_notes",
|
||||||
|
"arguments": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rechercher des Notes
|
||||||
|
|
||||||
|
```json
|
||||||
|
POST /sse
|
||||||
|
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 5,
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "search_notes",
|
||||||
|
"arguments": {
|
||||||
|
"query": "réunion"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Épingler/Désépingler une Note
|
||||||
|
|
||||||
|
```json
|
||||||
|
POST /sse
|
||||||
|
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 6,
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "toggle_pin",
|
||||||
|
"arguments": {
|
||||||
|
"id": "uuid-123"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Outils Disponibles {#outils}
|
||||||
|
|
||||||
|
### 1. `create_note`
|
||||||
|
|
||||||
|
Créer une nouvelle note.
|
||||||
|
|
||||||
|
**Paramètres:**
|
||||||
|
- `title` (string, optionnel) - Titre de la note
|
||||||
|
- `content` (string, **requis**) - Contenu de la note
|
||||||
|
- `color` (string) - Couleur parmi: default, red, orange, yellow, green, teal, blue, purple, pink, gray
|
||||||
|
- `type` (string) - Type: "text" ou "checklist"
|
||||||
|
- `checkItems` (array) - Items de checklist (si type=checklist)
|
||||||
|
- `labels` (array[string]) - Tags/labels
|
||||||
|
- `isPinned` (boolean) - Épingler la note
|
||||||
|
- `isArchived` (boolean) - Archiver la note
|
||||||
|
- `images` (array[string]) - Images base64
|
||||||
|
|
||||||
|
**Exemple:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "Liste de courses",
|
||||||
|
"content": "",
|
||||||
|
"type": "checklist",
|
||||||
|
"checkItems": [
|
||||||
|
{"id": "1", "text": "Lait", "checked": false},
|
||||||
|
{"id": "2", "text": "Pain", "checked": false}
|
||||||
|
],
|
||||||
|
"color": "yellow",
|
||||||
|
"labels": ["shopping"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. `get_notes`
|
||||||
|
|
||||||
|
Récupérer toutes les notes non archivées.
|
||||||
|
|
||||||
|
**Paramètres:** Aucun
|
||||||
|
|
||||||
|
**Retour:** Array de notes
|
||||||
|
|
||||||
|
### 3. `get_note`
|
||||||
|
|
||||||
|
Récupérer une note spécifique par ID.
|
||||||
|
|
||||||
|
**Paramètres:**
|
||||||
|
- `id` (string, **requis**) - UUID de la note
|
||||||
|
|
||||||
|
### 4. `update_note`
|
||||||
|
|
||||||
|
Mettre à jour une note existante.
|
||||||
|
|
||||||
|
**Paramètres:**
|
||||||
|
- `id` (string, **requis**) - UUID de la note
|
||||||
|
- `title` (string, optionnel) - Nouveau titre
|
||||||
|
- `content` (string, optionnel) - Nouveau contenu
|
||||||
|
- `color` (string, optionnel) - Nouvelle couleur
|
||||||
|
- `checkItems` (array, optionnel) - Nouveaux items
|
||||||
|
- `labels` (array, optionnel) - Nouveaux labels
|
||||||
|
- `images` (array, optionnel) - Nouvelles images
|
||||||
|
|
||||||
|
### 5. `delete_note`
|
||||||
|
|
||||||
|
Supprimer définitivement une note.
|
||||||
|
|
||||||
|
**Paramètres:**
|
||||||
|
- `id` (string, **requis**) - UUID de la note
|
||||||
|
|
||||||
|
⚠️ **Attention:** Suppression irréversible
|
||||||
|
|
||||||
|
### 6. `search_notes`
|
||||||
|
|
||||||
|
Rechercher des notes par mots-clés.
|
||||||
|
|
||||||
|
**Paramètres:**
|
||||||
|
- `query` (string, **requis**) - Texte à rechercher
|
||||||
|
|
||||||
|
**Recherche dans:**
|
||||||
|
- Titres
|
||||||
|
- Contenus
|
||||||
|
- Labels
|
||||||
|
- Items de checklist
|
||||||
|
|
||||||
|
**Exemple:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"query": "réunion 2026"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. `get_labels`
|
||||||
|
|
||||||
|
Récupérer tous les labels uniques utilisés.
|
||||||
|
|
||||||
|
**Paramètres:** Aucun
|
||||||
|
|
||||||
|
**Retour:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "[\"work\",\"personal\",\"urgent\",\"mcp\"]"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. `toggle_pin`
|
||||||
|
|
||||||
|
Épingler/désépingler une note.
|
||||||
|
|
||||||
|
**Paramètres:**
|
||||||
|
- `id` (string, **requis**) - UUID de la note
|
||||||
|
|
||||||
|
**Comportement:** Si épinglée → désépingle, si non épinglée → épingle
|
||||||
|
|
||||||
|
### 9. `toggle_archive`
|
||||||
|
|
||||||
|
Archiver/désarchiver une note.
|
||||||
|
|
||||||
|
**Paramètres:**
|
||||||
|
- `id` (string, **requis**) - UUID de la note
|
||||||
|
|
||||||
|
**Comportement:** Si archivée → désarchive, si non archivée → archive
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Troubleshooting {#troubleshooting}
|
||||||
|
|
||||||
|
### ❌ "Could not connect to your MCP server"
|
||||||
|
|
||||||
|
**Causes possibles:**
|
||||||
|
1. Serveur MCP non démarré
|
||||||
|
2. IP incorrecte dans N8N
|
||||||
|
3. Firewall bloque le port 3001
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 1. Vérifier si le serveur tourne
|
||||||
|
Get-Process -Name node | Where-Object {
|
||||||
|
(Get-NetTCPConnection -OwningProcess $_.Id -ErrorAction SilentlyContinue).LocalPort -eq 3001
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Détecter votre IP
|
||||||
|
ipconfig | Select-String "IPv4"
|
||||||
|
|
||||||
|
# 3. Tester la connexion
|
||||||
|
Invoke-RestMethod -Uri "http://localhost:3001/"
|
||||||
|
|
||||||
|
# 4. Tester depuis l'IP réseau
|
||||||
|
Invoke-RestMethod -Uri "http://192.168.1.10:3001/"
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ "Could not load list"
|
||||||
|
|
||||||
|
**Cause:** Serveur MCP ne répond pas correctement au protocole Streamable HTTP
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
|
||||||
|
1. Vérifier la version du SDK:
|
||||||
|
```bash
|
||||||
|
cd mcp-server
|
||||||
|
npm list @modelcontextprotocol/sdk
|
||||||
|
# Doit être >= 1.0.4
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Redémarrer le serveur:
|
||||||
|
```powershell
|
||||||
|
# Tuer tous les processus node
|
||||||
|
Get-Process -Name node | Stop-Process -Force
|
||||||
|
|
||||||
|
# Relancer
|
||||||
|
cd mcp-server
|
||||||
|
node index-sse.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ Port 3001 déjà utilisé
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Trouver le processus
|
||||||
|
Get-Process -Name node | Where-Object {
|
||||||
|
(Get-NetTCPConnection -OwningProcess $_.Id).LocalPort -eq 3001
|
||||||
|
} | Stop-Process -Force
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ Base de données verrouillée
|
||||||
|
|
||||||
|
**Erreur:** `SQLITE_BUSY: database is locked`
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Arrêter tous les serveurs
|
||||||
|
Get-Process -Name node | Stop-Process -Force
|
||||||
|
|
||||||
|
# Vérifier qu'aucun processus n'accède à la DB
|
||||||
|
lsof keep-notes/prisma/dev.db # Linux/Mac
|
||||||
|
handle dev.db # Windows
|
||||||
|
|
||||||
|
# Redémarrer les serveurs
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ "Invalid session ID"
|
||||||
|
|
||||||
|
**Cause:** Session expirée ou non initialisée
|
||||||
|
|
||||||
|
**Solution:** Relancer la connexion depuis N8N (bouton "Execute Node")
|
||||||
|
|
||||||
|
### 🔍 Logs de Débogage
|
||||||
|
|
||||||
|
Le serveur MCP affiche des logs détaillés:
|
||||||
|
|
||||||
|
```
|
||||||
|
New SSE connection from: 192.168.1.10
|
||||||
|
Session initialized: abc-123-def
|
||||||
|
Received message: {"jsonrpc":"2.0","id":1,"method":"tools/call",...}
|
||||||
|
Transport closed for session abc-123-def
|
||||||
|
```
|
||||||
|
|
||||||
|
**Activer plus de logs:**
|
||||||
|
```javascript
|
||||||
|
// Dans index-sse.js, ajouter:
|
||||||
|
console.log('Request body:', JSON.stringify(req.body, null, 2));
|
||||||
|
console.log('Response:', JSON.stringify(result, null, 2));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Workflow N8N Exemple
|
||||||
|
|
||||||
|
### Exemple: Créer une note à partir d'un email
|
||||||
|
|
||||||
|
```
|
||||||
|
[Email Trigger]
|
||||||
|
↓
|
||||||
|
[MCP Client] → create_note
|
||||||
|
• title: {{ $json.subject }}
|
||||||
|
• content: {{ $json.body }}
|
||||||
|
• labels: ["email", "auto"]
|
||||||
|
• color: "blue"
|
||||||
|
↓
|
||||||
|
[Send Notification]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exemple: Recherche et mise à jour
|
||||||
|
|
||||||
|
```
|
||||||
|
[HTTP Request] (webhook)
|
||||||
|
↓
|
||||||
|
[MCP Client] → search_notes
|
||||||
|
• query: {{ $json.keyword }}
|
||||||
|
↓
|
||||||
|
[Code Node] (filtrer résultats)
|
||||||
|
↓
|
||||||
|
[MCP Client] → update_note
|
||||||
|
• id: {{ $json.noteId }}
|
||||||
|
• labels: [...$json.existingLabels, "processed"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exemple: Backup quotidien
|
||||||
|
|
||||||
|
```
|
||||||
|
[Schedule Trigger] (daily 2am)
|
||||||
|
↓
|
||||||
|
[MCP Client] → get_notes
|
||||||
|
↓
|
||||||
|
[Convert to File] (JSON)
|
||||||
|
↓
|
||||||
|
[Save to Dropbox/Drive]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Avancé
|
||||||
|
|
||||||
|
### Sessions et Reconnexion
|
||||||
|
|
||||||
|
Le serveur gère automatiquement les sessions:
|
||||||
|
- Génère un UUID unique par session
|
||||||
|
- Retourne `Mcp-Session-Id` dans le header de réponse
|
||||||
|
- Accepte la reconnexion avec le même session ID
|
||||||
|
- Nettoie automatiquement les sessions fermées
|
||||||
|
|
||||||
|
### Streaming SSE
|
||||||
|
|
||||||
|
Le serveur peut envoyer des notifications au client via SSE:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Côté serveur (exemple futur)
|
||||||
|
transport.send({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
method: 'notifications/resources/updated',
|
||||||
|
params: { uri: 'notes://123' }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sécurité
|
||||||
|
|
||||||
|
⚠️ **Important pour la production:**
|
||||||
|
|
||||||
|
1. **Bind à localhost uniquement:**
|
||||||
|
```javascript
|
||||||
|
app.listen(PORT, '127.0.0.1'); // Pas 0.0.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Ajouter authentication:**
|
||||||
|
```javascript
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
const token = req.headers.authorization;
|
||||||
|
if (token !== 'Bearer SECRET_TOKEN') {
|
||||||
|
return res.status(401).send('Unauthorized');
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Validation Origin:**
|
||||||
|
```javascript
|
||||||
|
const allowedOrigins = ['http://localhost:3000'];
|
||||||
|
if (!allowedOrigins.includes(req.headers.origin)) {
|
||||||
|
return res.status(403).send('Forbidden');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Ressources
|
||||||
|
|
||||||
|
### Documentation Officielle
|
||||||
|
- MCP Spec: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports
|
||||||
|
- TypeScript SDK: https://github.com/modelcontextprotocol/typescript-sdk
|
||||||
|
|
||||||
|
### Exemples de Code
|
||||||
|
- MCP Examples: https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/server
|
||||||
|
|
||||||
|
### Support
|
||||||
|
- GitHub Issues: https://github.com/modelcontextprotocol/typescript-sdk/issues
|
||||||
|
- Discord: https://discord.gg/modelcontextprotocol
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Changelog
|
||||||
|
|
||||||
|
### Version 1.0.0 (2026-01-04)
|
||||||
|
- ✅ Implémentation Streamable HTTP transport
|
||||||
|
- ✅ 9 outils de gestion de notes
|
||||||
|
- ✅ Support des sessions
|
||||||
|
- ✅ Intégration Prisma
|
||||||
|
- ✅ Compatible N8N
|
||||||
|
|
||||||
|
### Améliorations Futures
|
||||||
|
- [ ] Authentication OAuth
|
||||||
|
- [ ] WebSocket transport
|
||||||
|
- [ ] Notifications temps réel
|
||||||
|
- [ ] Backup/restore automatique
|
||||||
|
- [ ] Rate limiting
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Auteur:** MCP Memento Server
|
||||||
|
**Version:** 1.0.0
|
||||||
|
**Date:** 2026-01-04
|
||||||
|
**Licence:** MIT
|
||||||
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
|
||||||
324
MCP-SSE-ANALYSIS.md
Normal file
@ -0,0 +1,324 @@
|
|||||||
|
# MCP et SSE (Server-Sent Events) - Analyse
|
||||||
|
|
||||||
|
## Question
|
||||||
|
**Peut-on utiliser le MCP en SSE?**
|
||||||
|
|
||||||
|
## Réponse: OUI ✅ (avec nuances)
|
||||||
|
|
||||||
|
Le SDK MCP (@modelcontextprotocol/sdk) supporte **plusieurs transports**, dont certains utilisent SSE.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Transports MCP Disponibles
|
||||||
|
|
||||||
|
### 1. **stdio** (Actuellement utilisé) ✅
|
||||||
|
```javascript
|
||||||
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||||
|
```
|
||||||
|
|
||||||
|
**Avantages**:
|
||||||
|
- Simple, direct, pas de réseau
|
||||||
|
- Idéal pour processus locaux
|
||||||
|
- Utilisé par Claude Desktop, Cline, etc.
|
||||||
|
|
||||||
|
**Inconvénients**:
|
||||||
|
- ❌ Nécessite accès fichier local
|
||||||
|
- ❌ Pas adapté pour N8N sur machine distante
|
||||||
|
- ❌ N8N doit avoir accès à `D:/dev_new_pc/Keep/mcp-server/index.js`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. **SSE via HTTP** ✅ (RECOMMANDÉ pour N8N distant!)
|
||||||
|
```javascript
|
||||||
|
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||||
|
```
|
||||||
|
|
||||||
|
**Avantages**:
|
||||||
|
- ✅ Fonctionne sur le réseau (parfait pour N8N distant!)
|
||||||
|
- ✅ Connexion persistante unidirectionnelle
|
||||||
|
- ✅ Standard HTTP/HTTPS
|
||||||
|
- ✅ Pas de WebSocket nécessaire
|
||||||
|
- ✅ Compatible avec la plupart des firewalls
|
||||||
|
|
||||||
|
**Comment ça marche**:
|
||||||
|
1. Client (N8N) se connecte à `http://your-ip:3001/sse`
|
||||||
|
2. Serveur maintient la connexion ouverte
|
||||||
|
3. Envoie des événements SSE au format `data: {...}\n\n`
|
||||||
|
4. Client peut envoyer des requêtes via POST sur `/message`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. **HTTP avec WebSockets** (Alternative)
|
||||||
|
```javascript
|
||||||
|
// Pas officiellement documenté dans SDK 1.0.4
|
||||||
|
// Mais possible avec implémentation custom
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration SSE pour Memento MCP Server
|
||||||
|
|
||||||
|
### Option A: Créer un serveur SSE séparé
|
||||||
|
|
||||||
|
**Fichier**: `mcp-server/index-sse.js`
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
#!/usr/bin/env node
|
||||||
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
|
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||||
|
import express from 'express';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { join, dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = 3001;
|
||||||
|
|
||||||
|
// Initialize Prisma
|
||||||
|
const prisma = new PrismaClient({
|
||||||
|
datasources: {
|
||||||
|
db: {
|
||||||
|
url: `file:${join(__dirname, '../keep-notes/prisma/dev.db')}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function
|
||||||
|
function parseNote(dbNote) {
|
||||||
|
return {
|
||||||
|
...dbNote,
|
||||||
|
checkItems: dbNote.checkItems ? JSON.parse(dbNote.checkItems) : null,
|
||||||
|
labels: dbNote.labels ? JSON.parse(dbNote.labels) : null,
|
||||||
|
images: dbNote.images ? JSON.parse(dbNote.images) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize MCP Server
|
||||||
|
const server = new Server(
|
||||||
|
{
|
||||||
|
name: 'memento-mcp-server',
|
||||||
|
version: '1.0.0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
capabilities: {
|
||||||
|
tools: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Register all tools (same as stdio version)
|
||||||
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||||
|
return {
|
||||||
|
tools: [
|
||||||
|
// ... tous les tools comme create_note, get_notes, etc.
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||||
|
// ... même logique que stdio version
|
||||||
|
});
|
||||||
|
|
||||||
|
// SSE Endpoint
|
||||||
|
app.get('/sse', async (req, res) => {
|
||||||
|
const transport = new SSEServerTransport('/message', res);
|
||||||
|
await server.connect(transport);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Message Endpoint
|
||||||
|
app.post('/message', express.json(), async (req, res) => {
|
||||||
|
// Handled by SSEServerTransport
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`MCP SSE Server running on http://localhost:${PORT}`);
|
||||||
|
console.log(`SSE endpoint: http://localhost:${PORT}/sse`);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dépendances à ajouter**:
|
||||||
|
```bash
|
||||||
|
cd mcp-server
|
||||||
|
npm install express
|
||||||
|
```
|
||||||
|
|
||||||
|
**Démarrage**:
|
||||||
|
```bash
|
||||||
|
node mcp-server/index-sse.js
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Option B: Utiliser Next.js API Route avec SSE
|
||||||
|
|
||||||
|
**Fichier**: `keep-notes/app/api/mcp/route.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
|
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
const mcpServer = new Server(
|
||||||
|
{
|
||||||
|
name: 'memento-mcp-server',
|
||||||
|
version: '1.0.0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
capabilities: {
|
||||||
|
tools: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Register tools...
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
async start(controller) {
|
||||||
|
const transport = new SSEServerTransport('/api/mcp/message', {
|
||||||
|
write: (data) => controller.enqueue(encoder.encode(data)),
|
||||||
|
});
|
||||||
|
await mcpServer.connect(transport);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
// Handle messages
|
||||||
|
const body = await req.json();
|
||||||
|
// Process via mcpServer
|
||||||
|
return Response.json({ success: true });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Avantage**: Même serveur Next.js, pas de port séparé!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration N8N avec SSE
|
||||||
|
|
||||||
|
### Méthode 1: MCP Client Community Node (si supporte SSE)
|
||||||
|
|
||||||
|
**Configuration**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "memento-sse",
|
||||||
|
"transport": "sse",
|
||||||
|
"url": "http://YOUR_IP:3001/sse"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Méthode 2: HTTP Request Nodes (Fallback)
|
||||||
|
|
||||||
|
Si le MCP Client ne supporte pas SSE, utiliser les nodes HTTP Request standards de N8N avec l'API REST existante (comme actuellement).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Comparaison: stdio vs SSE
|
||||||
|
|
||||||
|
| Feature | stdio | SSE |
|
||||||
|
|---------|-------|-----|
|
||||||
|
| **Connexion** | Process local | HTTP réseau |
|
||||||
|
| **N8N distant** | ❌ Non | ✅ Oui |
|
||||||
|
| **Latence** | Très faible | Faible |
|
||||||
|
| **Setup** | Simple | Moyen |
|
||||||
|
| **Firewall** | N/A | Standard HTTP |
|
||||||
|
| **Scaling** | 1 instance | Multiple clients |
|
||||||
|
| **Debugging** | Console logs | Network logs + curl |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommandation pour ton cas
|
||||||
|
|
||||||
|
### Contexte
|
||||||
|
- N8N déployé sur une **machine séparée**
|
||||||
|
- Besoin d'accès distant au MCP server
|
||||||
|
- MCP Client Community Node installé
|
||||||
|
|
||||||
|
### Solution: **SSE via Express** ✅
|
||||||
|
|
||||||
|
**Pourquoi**:
|
||||||
|
1. stdio nécessite accès fichier local (impossible si N8N sur autre machine)
|
||||||
|
2. SSE fonctionne sur HTTP standard (réseau)
|
||||||
|
3. Facile à tester avec curl
|
||||||
|
4. Compatible avec la plupart des clients MCP
|
||||||
|
|
||||||
|
**Étapes**:
|
||||||
|
1. Créer `mcp-server/index-sse.js` avec Express + SSE
|
||||||
|
2. Ajouter `express` aux dépendances
|
||||||
|
3. Démarrer sur port 3001 (ou autre)
|
||||||
|
4. Trouver l'IP de ta machine Windows: `ipconfig`
|
||||||
|
5. Configurer N8N MCP Client avec `http://YOUR_IP:3001/sse`
|
||||||
|
6. Tester avec N8N workflows
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test SSE sans N8N
|
||||||
|
|
||||||
|
### Avec curl:
|
||||||
|
```bash
|
||||||
|
# Test connexion SSE
|
||||||
|
curl -N http://localhost:3001/sse
|
||||||
|
|
||||||
|
# Test appel d'un tool
|
||||||
|
curl -X POST http://localhost:3001/message \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "get_notes",
|
||||||
|
"arguments": {}
|
||||||
|
},
|
||||||
|
"id": 1
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Avec JavaScript (test rapide):
|
||||||
|
```javascript
|
||||||
|
const eventSource = new EventSource('http://localhost:3001/sse');
|
||||||
|
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
console.log('Received:', event.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = (error) => {
|
||||||
|
console.error('SSE Error:', error);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prochaines étapes
|
||||||
|
|
||||||
|
1. ✅ **Je peux créer le serveur SSE si tu veux**
|
||||||
|
2. ✅ **Tester localement avec curl**
|
||||||
|
3. ✅ **Configurer N8N pour pointer vers SSE endpoint**
|
||||||
|
4. ✅ **Vérifier que MCP Client Community Node supporte SSE**
|
||||||
|
|
||||||
|
**Tu veux que je crée le serveur SSE maintenant?**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ressources
|
||||||
|
|
||||||
|
- [MCP SDK Documentation](https://github.com/modelcontextprotocol/sdk)
|
||||||
|
- [SSE Specification](https://html.spec.whatwg.org/multipage/server-sent-events.html)
|
||||||
|
- [N8N MCP Integration](https://community.n8n.io/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Conclusion**: Oui, MCP peut utiliser SSE! C'est même **recommandé** pour ton cas avec N8N sur une machine distante. Le transport stdio actuel ne fonctionne que pour processus locaux.
|
||||||
150
N8N-MCP-SETUP.md
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
# Configuration du MCP Server avec N8N
|
||||||
|
|
||||||
|
Le serveur MCP de Memento utilise le protocole **stdio** (standard input/output), pas HTTP. Voici comment le configurer avec N8N:
|
||||||
|
|
||||||
|
## 1. Configuration du MCP Server dans N8N
|
||||||
|
|
||||||
|
N8N doit connaître le serveur MCP avant de pouvoir l'utiliser. Ajoutez cette configuration dans votre fichier de config N8N:
|
||||||
|
|
||||||
|
### Méthode A: Via Variables d'Environnement
|
||||||
|
|
||||||
|
Ajoutez dans votre fichier `.env` de N8N:
|
||||||
|
|
||||||
|
```env
|
||||||
|
N8N_MCP_SERVERS='{"memento":{"command":"node","args":["D:/dev_new_pc/Keep/mcp-server/index.js"],"env":{}}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Méthode B: Via Fichier de Configuration MCP
|
||||||
|
|
||||||
|
Créez un fichier `~/.n8n/mcp-config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"memento": {
|
||||||
|
"command": "node",
|
||||||
|
"args": ["D:/dev_new_pc/Keep/mcp-server/index.js"],
|
||||||
|
"env": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis dans N8N, ajoutez:
|
||||||
|
```env
|
||||||
|
N8N_MCP_CONFIG_PATH=/path/to/.n8n/mcp-config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Méthode C: Configuration Claude Desktop (Alternative)
|
||||||
|
|
||||||
|
Si vous utilisez Claude Desktop avec MCP, ajoutez dans `%APPDATA%\Claude\claude_desktop_config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"memento": {
|
||||||
|
"command": "node",
|
||||||
|
"args": ["D:/dev_new_pc/Keep/mcp-server/index.js"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Utilisation dans N8N Workflows
|
||||||
|
|
||||||
|
Une fois configuré, vous pouvez utiliser le noeud **"MCP Client"** dans vos workflows:
|
||||||
|
|
||||||
|
### Exemple: Créer une note via MCP
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Node MCP Client
|
||||||
|
{
|
||||||
|
"serverName": "memento",
|
||||||
|
"tool": "create_note",
|
||||||
|
"parameters": {
|
||||||
|
"title": "Note from N8N",
|
||||||
|
"content": "Created via MCP protocol",
|
||||||
|
"color": "blue"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Outils MCP Disponibles
|
||||||
|
|
||||||
|
1. **create_note** - Créer une note
|
||||||
|
- Paramètres: title, content, color, type, checkItems, labels, isPinned, isArchived
|
||||||
|
|
||||||
|
2. **get_notes** - Récupérer toutes les notes
|
||||||
|
- Paramètres: includeArchived, search
|
||||||
|
|
||||||
|
3. **get_note** - Récupérer une note par ID
|
||||||
|
- Paramètres: id
|
||||||
|
|
||||||
|
4. **update_note** - Mettre à jour une note
|
||||||
|
- Paramètres: id, title, content, color, checkItems, labels, isPinned, isArchived
|
||||||
|
|
||||||
|
5. **delete_note** - Supprimer une note
|
||||||
|
- Paramètres: id
|
||||||
|
|
||||||
|
6. **search_notes** - Rechercher des notes
|
||||||
|
- Paramètres: query
|
||||||
|
|
||||||
|
7. **pin_note** - Épingler/désépingler une note
|
||||||
|
- Paramètres: id, isPinned
|
||||||
|
|
||||||
|
8. **archive_note** - Archiver/désarchiver une note
|
||||||
|
- Paramètres: id, isArchived
|
||||||
|
|
||||||
|
9. **add_label** - Ajouter un label à une note
|
||||||
|
- Paramètres: id, label
|
||||||
|
|
||||||
|
## 3. Alternative: Workflow REST API
|
||||||
|
|
||||||
|
Pour tester immédiatement sans configurer MCP, utilisez le workflow REST API fourni dans `n8n-memento-workflow.json`.
|
||||||
|
|
||||||
|
Ce workflow teste l'API REST de Memento:
|
||||||
|
- Create Note (POST /api/notes)
|
||||||
|
- Get All Notes (GET /api/notes)
|
||||||
|
- Update Note (PUT /api/notes)
|
||||||
|
|
||||||
|
## 4. Démarrage des Serveurs
|
||||||
|
|
||||||
|
### Serveur Web Memento
|
||||||
|
```bash
|
||||||
|
cd D:/dev_new_pc/Keep/keep-notes
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
→ http://localhost:3000
|
||||||
|
|
||||||
|
### Serveur MCP (pour test manuel)
|
||||||
|
```bash
|
||||||
|
cd D:/dev_new_pc/Keep/mcp-server
|
||||||
|
node index.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Import du Workflow dans N8N
|
||||||
|
|
||||||
|
1. Ouvrez N8N
|
||||||
|
2. Cliquez sur "Import from File"
|
||||||
|
3. Sélectionnez `n8n-memento-workflow.json`
|
||||||
|
4. Assurez-vous que Memento tourne sur http://localhost:3000
|
||||||
|
5. Exécutez le workflow avec "Execute Workflow"
|
||||||
|
|
||||||
|
## 5. Test du MCP Server
|
||||||
|
|
||||||
|
Pour tester le serveur MCP en ligne de commande:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd D:/dev_new_pc/Keep/mcp-server
|
||||||
|
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | node index.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Vous devriez voir la liste des 9 outils disponibles.
|
||||||
|
|
||||||
|
## Notes Importantes
|
||||||
|
|
||||||
|
- Le serveur MCP utilise **stdio** (stdin/stdout) pour la communication, pas HTTP
|
||||||
|
- N8N doit être configuré pour connaître le serveur MCP avant utilisation
|
||||||
|
- Le workflow JSON fourni teste uniquement l'API REST (plus simple à tester)
|
||||||
|
- Pour utiliser MCP dans N8N, utilisez le noeud "MCP Client" après configuration
|
||||||
|
- Le serveur Next.js doit tourner pour que l'API REST fonctionne
|
||||||
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** ! 🚀
|
||||||
292
README.md
@ -0,0 +1,292 @@
|
|||||||
|
# Memento - Your Digital Notepad
|
||||||
|
|
||||||
|
A beautiful and functional note-taking app inspired by Google Keep, built with Next.js 16, TypeScript, Tailwind CSS 4, and Prisma.
|
||||||
|
|
||||||
|
## 🚀 Project Location
|
||||||
|
|
||||||
|
The complete application is in the `keep-notes/` directory.
|
||||||
|
|
||||||
|
## ✅ Completed Features
|
||||||
|
|
||||||
|
### Core Functionality
|
||||||
|
- ✅ Create, read, update, delete notes
|
||||||
|
- ✅ Text notes and checklist notes
|
||||||
|
- ✅ Pin/unpin notes
|
||||||
|
- ✅ Archive/unarchive notes
|
||||||
|
- ✅ Real-time search across all notes
|
||||||
|
- ✅ Color customization (10 soft pastel themes)
|
||||||
|
- ✅ Label management
|
||||||
|
- ✅ Responsive masonry grid layout
|
||||||
|
- ✅ Drag-and-drop note reordering
|
||||||
|
- ✅ **Image upload with original size preservation**
|
||||||
|
|
||||||
|
### UI/UX Features
|
||||||
|
- ✅ Expandable note input (Google Keep style)
|
||||||
|
- ✅ Modal note editor with full editing (`!max-w-[min(95vw,1600px)]`)
|
||||||
|
- ✅ **Images display at original dimensions** (no cropping, `h-auto` without `w-full`)
|
||||||
|
- ✅ Hover effects and smooth animations
|
||||||
|
- ✅ **Masonry layout with CSS columns** (responsive: 1-5 columns)
|
||||||
|
- ✅ **Soft pastel color themes** (bg-red-50, bg-blue-50, etc.)
|
||||||
|
- ✅ Dark mode with system preference
|
||||||
|
- ✅ Mobile responsive design
|
||||||
|
- ✅ Icon-based navigation with 9 toolbar icons
|
||||||
|
- ✅ Toast notifications (via shadcn)
|
||||||
|
|
||||||
|
### Integration Features
|
||||||
|
- ✅ **REST API** (4 endpoints: GET, POST, PUT, DELETE)
|
||||||
|
- `/api/notes` - List all notes
|
||||||
|
- `/api/notes` - Create new note
|
||||||
|
- `/api/notes/[id]` - Update note
|
||||||
|
- `/api/notes/[id]` - Delete note
|
||||||
|
- ✅ **MCP Server** (Model Context Protocol) with 9 tools:
|
||||||
|
- `getNotes` - Fetch all notes
|
||||||
|
- `createNote` - Create new note
|
||||||
|
- `updateNote` - Update existing note
|
||||||
|
- `deleteNote` - Delete note
|
||||||
|
- `searchNotes` - Search notes by query
|
||||||
|
- `getNoteById` - Get specific note
|
||||||
|
- `archiveNote` - Archive/unarchive note
|
||||||
|
- `pinNote` - Pin/unpin note
|
||||||
|
- `addLabel` - Add label to note
|
||||||
|
|
||||||
|
### Technical Features
|
||||||
|
- ✅ Next.js 16 with App Router & Turbopack
|
||||||
|
- ✅ Server Actions for mutations
|
||||||
|
- ✅ TypeScript throughout
|
||||||
|
- ✅ Tailwind CSS 4
|
||||||
|
- ✅ shadcn/ui components (11 components)
|
||||||
|
- ✅ Prisma ORM 5.22.0 with SQLite
|
||||||
|
- ✅ Type-safe database operations
|
||||||
|
- ✅ **Base64 image encoding** (FileReader.readAsDataURL)
|
||||||
|
- ✅ **@modelcontextprotocol/sdk** v1.0.4
|
||||||
|
|
||||||
|
## 🏃 Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd keep-notes
|
||||||
|
npm install
|
||||||
|
npx prisma generate
|
||||||
|
npx prisma migrate dev
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open http://localhost:3000
|
||||||
|
|
||||||
|
## 📱 Application Features
|
||||||
|
|
||||||
|
### 1. Note Creation
|
||||||
|
- Click the input field to expand
|
||||||
|
- Add title and content
|
||||||
|
- **Upload images** (displayed at original size)
|
||||||
|
- Switch to checklist mode with one click
|
||||||
|
- Add labels and choose from 10 soft pastel colors
|
||||||
|
|
||||||
|
### 2. Note Management
|
||||||
|
- **Edit**: Click any note to open the editor (max-width: 95vw or 1600px)
|
||||||
|
- **Pin**: Click pin icon to keep notes at top
|
||||||
|
- **Archive**: Move notes to archive
|
||||||
|
- **Delete**: Remove notes permanently
|
||||||
|
- **Color**: Choose from 10 beautiful pastel colors
|
||||||
|
- **Labels**: Add multiple labels
|
||||||
|
- **Images**: Upload images that preserve original dimensions
|
||||||
|
|
||||||
|
### 3. Checklist Notes
|
||||||
|
- Create todo lists
|
||||||
|
- Check/uncheck items
|
||||||
|
- Add/remove items dynamically
|
||||||
|
- Strike-through completed items
|
||||||
|
|
||||||
|
### 4. Search & Navigation
|
||||||
|
- Real-time search in header
|
||||||
|
- Search by title or content
|
||||||
|
- Navigate to Archive page
|
||||||
|
- Dark/light mode toggle
|
||||||
|
|
||||||
|
### 5. API Integration
|
||||||
|
Use the REST API to integrate with other services:
|
||||||
|
```bash
|
||||||
|
# Get all notes
|
||||||
|
curl http://localhost:3000/api/notes
|
||||||
|
|
||||||
|
# Create a note
|
||||||
|
curl -X POST http://localhost:3000/api/notes \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"title":"API Note","content":"Created via API"}'
|
||||||
|
|
||||||
|
# Update a note
|
||||||
|
curl -X PUT http://localhost:3000/api/notes/1 \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"title":"Updated","content":"Modified via API"}'
|
||||||
|
|
||||||
|
# Delete a note
|
||||||
|
curl -X DELETE http://localhost:3000/api/notes/1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. MCP Server for AI Agents
|
||||||
|
Start the MCP server for integration with Claude, N8N, or other MCP clients:
|
||||||
|
```bash
|
||||||
|
cd keep-notes
|
||||||
|
npm run mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
The MCP server exposes 9 tools for AI agents to interact with your notes:
|
||||||
|
- Create, read, update, and delete notes
|
||||||
|
- Search notes by content
|
||||||
|
- Manage pins, archives, and labels
|
||||||
|
- Perfect for N8N workflows, Claude Desktop, or custom integrations
|
||||||
|
|
||||||
|
Example N8N workflow available in: `n8n-memento-workflow.json`
|
||||||
|
|
||||||
|
## 🛠️ Tech Stack
|
||||||
|
|
||||||
|
- **Frontend**: Next.js 16, React, TypeScript, Tailwind CSS 4
|
||||||
|
- **UI Components**: shadcn/ui (11 components: Dialog, Tooltip, Badge, etc.)
|
||||||
|
- **Icons**: Lucide React (Bell, Image, UserPlus, Palette, Archive, etc.)
|
||||||
|
- **Backend**: Next.js Server Actions
|
||||||
|
- **Database**: Prisma ORM 5.22.0 + SQLite (upgradeable to PostgreSQL)
|
||||||
|
- **Styling**: Tailwind CSS 4 with soft pastel themes (bg-*-50)
|
||||||
|
- **Layout**: CSS columns for masonry grid (responsive 1-5 columns)
|
||||||
|
- **Images**: Base64 encoding, original size preservation
|
||||||
|
- **Integration**: REST API + MCP Server (@modelcontextprotocol/sdk v1.0.4)
|
||||||
|
|
||||||
|
## 📂 Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
keep-notes/
|
||||||
|
├── app/
|
||||||
|
│ ├── actions/notes.ts # Server actions (CRUD + images)
|
||||||
|
│ ├── api/notes/ # REST API endpoints
|
||||||
|
│ ├── archive/page.tsx # Archive page
|
||||||
|
│ ├── layout.tsx # Root layout
|
||||||
|
│ └── page.tsx # Home page
|
||||||
|
├── components/
|
||||||
|
│ ├── ui/ # shadcn components
|
||||||
|
│ ├── header.tsx # Navigation
|
||||||
|
│ ├── note-card.tsx # Note display (masonry, images)
|
||||||
|
│ ├── note-editor.tsx # Note editing (!max-w-[95vw])
|
||||||
|
│ ├── note-input.tsx # Note creation (image upload)
|
||||||
|
│ └── note-grid.tsx # Masonry layout
|
||||||
|
├── lib/
|
||||||
|
│ ├── types.ts # TypeScript types (Note with images)
|
||||||
|
│ └── utils.ts # Utilities
|
||||||
|
├── prisma/
|
||||||
|
│ ├── schema.prisma # Database schema (images String?)
|
||||||
|
│ └── dev.db # SQLite database
|
||||||
|
├── mcp/
|
||||||
|
│ └── server.ts # MCP server (9 tools)
|
||||||
|
└── package.json # Scripts: dev, build, start, mcp
|
||||||
|
```
|
||||||
|
│ ├── note-editor.tsx # Edit modal
|
||||||
|
│ ├── note-grid.tsx # Grid layout
|
||||||
|
│ └── note-input.tsx # Note creation
|
||||||
|
├── lib/
|
||||||
|
│ ├── prisma.ts # DB client
|
||||||
|
│ ├── types.ts # TypeScript types
|
||||||
|
│ └── utils.ts # Utilities
|
||||||
|
└── prisma/
|
||||||
|
├── schema.prisma # Database schema
|
||||||
|
└── migrations/ # DB migrations
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎨 Color Themes
|
||||||
|
|
||||||
|
The app includes 10 color themes:
|
||||||
|
- Default (White)
|
||||||
|
- Red
|
||||||
|
- Orange
|
||||||
|
- Yellow
|
||||||
|
- Green
|
||||||
|
- Teal
|
||||||
|
- Blue
|
||||||
|
- Purple
|
||||||
|
- Pink
|
||||||
|
- Gray
|
||||||
|
|
||||||
|
All themes support dark mode!
|
||||||
|
|
||||||
|
## 🔧 Configuration
|
||||||
|
|
||||||
|
### Database
|
||||||
|
Currently uses SQLite. To switch to PostgreSQL:
|
||||||
|
|
||||||
|
1. Edit `prisma/schema.prisma`:
|
||||||
|
```prisma
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Update `prisma.config.ts` with PostgreSQL URL
|
||||||
|
3. Run: `npx prisma migrate dev`
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
Located in `.env`:
|
||||||
|
```
|
||||||
|
DATABASE_URL="file:./dev.db"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Deployment
|
||||||
|
|
||||||
|
### Vercel (Recommended)
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
# Deploy to Vercel
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
```dockerfile
|
||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
RUN npm install
|
||||||
|
RUN npx prisma generate
|
||||||
|
RUN npm run build
|
||||||
|
CMD ["npm", "start"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Development Notes
|
||||||
|
|
||||||
|
### Server Actions
|
||||||
|
All CRUD operations use Next.js Server Actions:
|
||||||
|
- `createNote()` - Create new note
|
||||||
|
- `updateNote()` - Update existing note
|
||||||
|
- `deleteNote()` - Delete note
|
||||||
|
- `getNotes()` - Fetch all notes
|
||||||
|
- `searchNotes()` - Search notes
|
||||||
|
- `togglePin()` - Pin/unpin
|
||||||
|
- `toggleArchive()` - Archive/unarchive
|
||||||
|
|
||||||
|
### Type Safety
|
||||||
|
Full TypeScript coverage with interfaces:
|
||||||
|
- `Note` - Main note type
|
||||||
|
- `CheckItem` - Checklist item
|
||||||
|
- `NoteColor` - Color themes
|
||||||
|
|
||||||
|
### Responsive Design
|
||||||
|
- Mobile: Single column
|
||||||
|
- Tablet: 2 columns
|
||||||
|
- Desktop: 3-4 columns
|
||||||
|
- Auto-adjusts with window size
|
||||||
|
|
||||||
|
## 🎯 Future Enhancements
|
||||||
|
|
||||||
|
Possible additions:
|
||||||
|
- User authentication (NextAuth.js)
|
||||||
|
- Real-time collaboration
|
||||||
|
- Image uploads
|
||||||
|
- Rich text editor
|
||||||
|
- Note sharing
|
||||||
|
- Reminders
|
||||||
|
- Export to PDF/Markdown
|
||||||
|
- Voice notes
|
||||||
|
- Drawing support
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
MIT License - feel free to use for personal or commercial projects!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Built with ❤️ using Next.js 16, TypeScript, and Tailwind CSS 4**
|
||||||
|
|
||||||
|
Server running at: http://localhost:3000
|
||||||
43
keep-notes/.gitignore
vendored
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
/lib/generated/prisma
|
||||||
104
keep-notes/README.md
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
# Keep Notes - Google Keep Clone
|
||||||
|
|
||||||
|
A beautiful and feature-rich Google Keep clone built with modern web technologies.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
## ✨ Features
|
||||||
|
|
||||||
|
- 📝 **Create & Edit Notes**: Quick note creation with expandable input
|
||||||
|
- ☑️ **Checklist Support**: Create todo lists with checkable items
|
||||||
|
- 🎨 **Color Customization**: 10 beautiful color themes for organizing notes
|
||||||
|
- 📌 **Pin Notes**: Keep important notes at the top
|
||||||
|
- 📦 **Archive**: Archive notes you want to keep but don't need to see
|
||||||
|
- 🏷️ **Labels**: Organize notes with custom labels
|
||||||
|
- 🔍 **Real-time Search**: Instantly search through all your notes
|
||||||
|
- 🌓 **Dark Mode**: Beautiful dark theme with system preference detection
|
||||||
|
- 📱 **Fully Responsive**: Works perfectly on desktop, tablet, and mobile
|
||||||
|
- ⚡ **Server Actions**: Lightning-fast CRUD operations with Next.js 16
|
||||||
|
- 🎯 **Type-Safe**: Full TypeScript support throughout
|
||||||
|
|
||||||
|
## 🚀 Tech Stack
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- **Next.js 16** - React framework with App Router
|
||||||
|
- **TypeScript** - Type safety and better DX
|
||||||
|
- **Tailwind CSS 4** - Utility-first CSS framework
|
||||||
|
- **shadcn/ui** - Beautiful, accessible UI components
|
||||||
|
- **Lucide React** - Modern icon library
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- **Next.js Server Actions** - Server-side mutations
|
||||||
|
- **Prisma ORM** - Type-safe database client
|
||||||
|
- **SQLite** - Lightweight database (easily switchable to PostgreSQL)
|
||||||
|
|
||||||
|
## 📦 Installation
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- Node.js 18+
|
||||||
|
- npm or yarn
|
||||||
|
|
||||||
|
### Steps
|
||||||
|
|
||||||
|
1. **Clone the repository**
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd keep-notes
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Install dependencies**
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Set up the database**
|
||||||
|
```bash
|
||||||
|
npx prisma generate
|
||||||
|
npx prisma migrate dev
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Start the development server**
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Open your browser**
|
||||||
|
Navigate to [http://localhost:3000](http://localhost:3000)
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
301
keep-notes/app/actions/notes.ts
Normal file
@ -0,0 +1,301 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { revalidatePath } from 'next/cache'
|
||||||
|
import prisma from '@/lib/prisma'
|
||||||
|
import { Note, CheckItem } from '@/lib/types'
|
||||||
|
|
||||||
|
// Helper function to parse JSON strings from database
|
||||||
|
function parseNote(dbNote: any): Note {
|
||||||
|
return {
|
||||||
|
...dbNote,
|
||||||
|
checkItems: dbNote.checkItems ? JSON.parse(dbNote.checkItems) : null,
|
||||||
|
labels: dbNote.labels ? JSON.parse(dbNote.labels) : null,
|
||||||
|
images: dbNote.images ? JSON.parse(dbNote.images) : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all notes (non-archived by default)
|
||||||
|
export async function getNotes(includeArchived = false) {
|
||||||
|
try {
|
||||||
|
const notes = await prisma.note.findMany({
|
||||||
|
where: includeArchived ? {} : { isArchived: false },
|
||||||
|
orderBy: [
|
||||||
|
{ isPinned: 'desc' },
|
||||||
|
{ order: 'asc' },
|
||||||
|
{ updatedAt: 'desc' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
return notes.map(parseNote)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching notes:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get archived notes only
|
||||||
|
export async function getArchivedNotes() {
|
||||||
|
try {
|
||||||
|
const notes = await prisma.note.findMany({
|
||||||
|
where: { isArchived: true },
|
||||||
|
orderBy: { updatedAt: 'desc' }
|
||||||
|
})
|
||||||
|
|
||||||
|
return notes.map(parseNote)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching archived notes:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search notes
|
||||||
|
export async function searchNotes(query: string) {
|
||||||
|
try {
|
||||||
|
if (!query.trim()) {
|
||||||
|
return await getNotes()
|
||||||
|
}
|
||||||
|
|
||||||
|
const notes = await prisma.note.findMany({
|
||||||
|
where: {
|
||||||
|
isArchived: false,
|
||||||
|
OR: [
|
||||||
|
{ title: { contains: query } },
|
||||||
|
{ content: { contains: query } },
|
||||||
|
{ labels: { contains: query } },
|
||||||
|
{ checkItems: { contains: query } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ isPinned: 'desc' },
|
||||||
|
{ updatedAt: 'desc' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Enhanced ranking: prioritize title matches
|
||||||
|
const rankedNotes = notes.map(note => {
|
||||||
|
const parsedNote = parseNote(note)
|
||||||
|
let score = 0
|
||||||
|
|
||||||
|
// Title match gets highest score
|
||||||
|
if (parsedNote.title?.toLowerCase().includes(query.toLowerCase())) {
|
||||||
|
score += 10
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content match
|
||||||
|
if (parsedNote.content.toLowerCase().includes(query.toLowerCase())) {
|
||||||
|
score += 5
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label match
|
||||||
|
if (parsedNote.labels?.some(label => label.toLowerCase().includes(query.toLowerCase()))) {
|
||||||
|
score += 3
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckItems match
|
||||||
|
if (parsedNote.checkItems?.some(item => item.text.toLowerCase().includes(query.toLowerCase()))) {
|
||||||
|
score += 2
|
||||||
|
}
|
||||||
|
|
||||||
|
return { note: parsedNote, score }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sort by score descending, then by existing order (pinned/updated)
|
||||||
|
return rankedNotes
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.map(item => item.note)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error searching notes:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new note
|
||||||
|
export async function createNote(data: {
|
||||||
|
title?: string
|
||||||
|
content: string
|
||||||
|
color?: string
|
||||||
|
type?: 'text' | 'checklist'
|
||||||
|
checkItems?: CheckItem[]
|
||||||
|
labels?: string[]
|
||||||
|
images?: string[]
|
||||||
|
isArchived?: boolean
|
||||||
|
reminder?: Date | null
|
||||||
|
isMarkdown?: boolean
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const note = await prisma.note.create({
|
||||||
|
data: {
|
||||||
|
title: data.title || null,
|
||||||
|
content: data.content,
|
||||||
|
color: data.color || 'default',
|
||||||
|
type: data.type || 'text',
|
||||||
|
checkItems: data.checkItems ? JSON.stringify(data.checkItems) : null,
|
||||||
|
labels: data.labels ? JSON.stringify(data.labels) : null,
|
||||||
|
images: data.images ? JSON.stringify(data.images) : null,
|
||||||
|
isArchived: data.isArchived || false,
|
||||||
|
reminder: data.reminder || null,
|
||||||
|
isMarkdown: data.isMarkdown || false,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
revalidatePath('/')
|
||||||
|
return parseNote(note)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating note:', error)
|
||||||
|
throw new Error('Failed to create note')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update a note
|
||||||
|
export async function updateNote(id: string, data: {
|
||||||
|
title?: string | null
|
||||||
|
content?: string
|
||||||
|
color?: string
|
||||||
|
isPinned?: boolean
|
||||||
|
isArchived?: boolean
|
||||||
|
type?: 'text' | 'checklist'
|
||||||
|
checkItems?: CheckItem[] | null
|
||||||
|
labels?: string[] | null
|
||||||
|
images?: string[] | null
|
||||||
|
reminder?: Date | null
|
||||||
|
isMarkdown?: boolean
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
// Stringify JSON fields if they exist
|
||||||
|
const updateData: any = { ...data }
|
||||||
|
if ('checkItems' in data) {
|
||||||
|
updateData.checkItems = data.checkItems ? JSON.stringify(data.checkItems) : null
|
||||||
|
}
|
||||||
|
if ('labels' in data) {
|
||||||
|
updateData.labels = data.labels ? JSON.stringify(data.labels) : null
|
||||||
|
}
|
||||||
|
if ('images' in data) {
|
||||||
|
updateData.images = data.images ? JSON.stringify(data.images) : null
|
||||||
|
}
|
||||||
|
updateData.updatedAt = new Date()
|
||||||
|
|
||||||
|
const note = await prisma.note.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateData
|
||||||
|
})
|
||||||
|
|
||||||
|
revalidatePath('/')
|
||||||
|
return parseNote(note)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating note:', error)
|
||||||
|
throw new Error('Failed to update note')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete a note
|
||||||
|
export async function deleteNote(id: string) {
|
||||||
|
try {
|
||||||
|
await prisma.note.delete({
|
||||||
|
where: { id }
|
||||||
|
})
|
||||||
|
|
||||||
|
revalidatePath('/')
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting note:', error)
|
||||||
|
throw new Error('Failed to delete note')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle pin status
|
||||||
|
export async function togglePin(id: string, isPinned: boolean) {
|
||||||
|
return updateNote(id, { isPinned })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle archive status
|
||||||
|
export async function toggleArchive(id: string, isArchived: boolean) {
|
||||||
|
return updateNote(id, { isArchived })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update note color
|
||||||
|
export async function updateColor(id: string, color: string) {
|
||||||
|
return updateNote(id, { color })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update note labels
|
||||||
|
export async function updateLabels(id: string, labels: string[]) {
|
||||||
|
return updateNote(id, { labels })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all unique labels
|
||||||
|
export async function getAllLabels() {
|
||||||
|
try {
|
||||||
|
const notes = await prisma.note.findMany({
|
||||||
|
select: { labels: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
const labelsSet = new Set<string>()
|
||||||
|
notes.forEach(note => {
|
||||||
|
const labels = note.labels ? JSON.parse(note.labels) : null
|
||||||
|
if (labels) {
|
||||||
|
labels.forEach((label: string) => labelsSet.add(label))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return Array.from(labelsSet).sort()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching labels:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reorder notes (drag and drop)
|
||||||
|
export async function reorderNotes(draggedId: string, targetId: string) {
|
||||||
|
console.log('[REORDER-DEBUG] reorderNotes called:', { draggedId, targetId })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const draggedNote = await prisma.note.findUnique({ where: { id: draggedId } })
|
||||||
|
const targetNote = await prisma.note.findUnique({ where: { id: targetId } })
|
||||||
|
|
||||||
|
console.log('[REORDER-DEBUG] Notes found:', {
|
||||||
|
draggedNote: draggedNote ? { id: draggedNote.id, title: draggedNote.title, isPinned: draggedNote.isPinned, order: draggedNote.order } : null,
|
||||||
|
targetNote: targetNote ? { id: targetNote.id, title: targetNote.title, isPinned: targetNote.isPinned, order: targetNote.order } : null
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!draggedNote || !targetNote) {
|
||||||
|
console.error('[REORDER-DEBUG] Notes not found')
|
||||||
|
throw new Error('Notes not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all notes in the same category (pinned or unpinned)
|
||||||
|
const allNotes = await prisma.note.findMany({
|
||||||
|
where: {
|
||||||
|
isPinned: draggedNote.isPinned,
|
||||||
|
isArchived: false
|
||||||
|
},
|
||||||
|
orderBy: { order: 'asc' }
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[REORDER-DEBUG] All notes in category:', allNotes.map(n => ({ id: n.id, title: n.title, order: n.order })))
|
||||||
|
|
||||||
|
// Create new order array
|
||||||
|
const reorderedNotes = allNotes.filter(n => n.id !== draggedId)
|
||||||
|
const targetIndex = reorderedNotes.findIndex(n => n.id === targetId)
|
||||||
|
reorderedNotes.splice(targetIndex, 0, draggedNote)
|
||||||
|
|
||||||
|
console.log('[REORDER-DEBUG] New order:', reorderedNotes.map((n, i) => ({ id: n.id, title: n.title, newOrder: i })))
|
||||||
|
|
||||||
|
// Update all notes with new order
|
||||||
|
const updates = reorderedNotes.map((note, index) =>
|
||||||
|
prisma.note.update({
|
||||||
|
where: { id: note.id },
|
||||||
|
data: { order: index }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log('[REORDER-DEBUG] Executing transaction with', updates.length, 'updates')
|
||||||
|
await prisma.$transaction(updates)
|
||||||
|
console.log('[REORDER-DEBUG] Transaction completed successfully')
|
||||||
|
|
||||||
|
revalidatePath('/')
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[REORDER-DEBUG] Error reordering notes:', error)
|
||||||
|
throw new Error('Failed to reorder notes')
|
||||||
|
}
|
||||||
|
}
|
||||||
30
keep-notes/app/api/labels/route.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import prisma from '@/lib/prisma'
|
||||||
|
|
||||||
|
// GET /api/labels - Get all unique labels
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const notes = await prisma.note.findMany({
|
||||||
|
select: { labels: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
const labelsSet = new Set<string>()
|
||||||
|
notes.forEach(note => {
|
||||||
|
const labels = note.labels ? JSON.parse(note.labels) : null
|
||||||
|
if (labels) {
|
||||||
|
labels.forEach((label: string) => labelsSet.add(label))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: Array.from(labelsSet).sort()
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('GET /api/labels error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Failed to fetch labels' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
100
keep-notes/app/api/notes/[id]/route.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import prisma from '@/lib/prisma'
|
||||||
|
|
||||||
|
// Helper to parse JSON fields
|
||||||
|
function parseNote(dbNote: any) {
|
||||||
|
return {
|
||||||
|
...dbNote,
|
||||||
|
checkItems: dbNote.checkItems ? JSON.parse(dbNote.checkItems) : null,
|
||||||
|
labels: dbNote.labels ? JSON.parse(dbNote.labels) : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/notes/[id] - Get a single note
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const note = await prisma.note.findUnique({
|
||||||
|
where: { id: params.id }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!note) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Note not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: parseNote(note)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('GET /api/notes/[id] error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Failed to fetch note' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/notes/[id] - Update a note
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const updateData: any = { ...body }
|
||||||
|
|
||||||
|
// Stringify JSON fields if they exist
|
||||||
|
if ('checkItems' in body) {
|
||||||
|
updateData.checkItems = body.checkItems ? JSON.stringify(body.checkItems) : null
|
||||||
|
}
|
||||||
|
if ('labels' in body) {
|
||||||
|
updateData.labels = body.labels ? JSON.stringify(body.labels) : null
|
||||||
|
}
|
||||||
|
updateData.updatedAt = new Date()
|
||||||
|
|
||||||
|
const note = await prisma.note.update({
|
||||||
|
where: { id: params.id },
|
||||||
|
data: updateData
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: parseNote(note)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('PUT /api/notes/[id] error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Failed to update note' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/notes/[id] - Delete a note
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await prisma.note.delete({
|
||||||
|
where: { id: params.id }
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Note deleted successfully'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('DELETE /api/notes/[id] error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Failed to delete note' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
166
keep-notes/app/api/notes/route.ts
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import prisma from '@/lib/prisma'
|
||||||
|
import { CheckItem } from '@/lib/types'
|
||||||
|
|
||||||
|
// Helper to parse JSON fields
|
||||||
|
function parseNote(dbNote: any) {
|
||||||
|
return {
|
||||||
|
...dbNote,
|
||||||
|
checkItems: dbNote.checkItems ? JSON.parse(dbNote.checkItems) : null,
|
||||||
|
labels: dbNote.labels ? JSON.parse(dbNote.labels) : null,
|
||||||
|
images: dbNote.images ? JSON.parse(dbNote.images) : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/notes - Get all notes
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const searchParams = request.nextUrl.searchParams
|
||||||
|
const includeArchived = searchParams.get('archived') === 'true'
|
||||||
|
const search = searchParams.get('search')
|
||||||
|
|
||||||
|
let where: any = {}
|
||||||
|
|
||||||
|
if (!includeArchived) {
|
||||||
|
where.isArchived = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ title: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ content: { contains: search, mode: 'insensitive' } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const notes = await prisma.note.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [
|
||||||
|
{ isPinned: 'desc' },
|
||||||
|
{ order: 'asc' },
|
||||||
|
{ updatedAt: 'desc' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: notes.map(parseNote)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('GET /api/notes error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Failed to fetch notes' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/notes - Create a new note
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const { title, content, color, type, checkItems, labels, images } = body
|
||||||
|
|
||||||
|
if (!content && type !== 'checklist') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Content is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const note = await prisma.note.create({
|
||||||
|
data: {
|
||||||
|
title: title || null,
|
||||||
|
content: content || '',
|
||||||
|
color: color || 'default',
|
||||||
|
type: type || 'text',
|
||||||
|
checkItems: checkItems ? JSON.stringify(checkItems) : null,
|
||||||
|
labels: labels ? JSON.stringify(labels) : null,
|
||||||
|
images: images ? JSON.stringify(images) : null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: parseNote(note)
|
||||||
|
}, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('POST /api/notes error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Failed to create note' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/notes - Update an existing note
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const { id, title, content, color, type, checkItems, labels, isPinned, isArchived, images } = body
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Note ID is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: any = {}
|
||||||
|
|
||||||
|
if (title !== undefined) updateData.title = title
|
||||||
|
if (content !== undefined) updateData.content = content
|
||||||
|
if (color !== undefined) updateData.color = color
|
||||||
|
if (type !== undefined) updateData.type = type
|
||||||
|
if (checkItems !== undefined) updateData.checkItems = checkItems ? JSON.stringify(checkItems) : null
|
||||||
|
if (labels !== undefined) updateData.labels = labels ? JSON.stringify(labels) : null
|
||||||
|
if (isPinned !== undefined) updateData.isPinned = isPinned
|
||||||
|
if (isArchived !== undefined) updateData.isArchived = isArchived
|
||||||
|
if (images !== undefined) updateData.images = images ? JSON.stringify(images) : null
|
||||||
|
|
||||||
|
const note = await prisma.note.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateData
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: parseNote(note)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('PUT /api/notes error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Failed to update note' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/notes?id=xxx - Delete a note
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const searchParams = request.nextUrl.searchParams
|
||||||
|
const id = searchParams.get('id')
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Note ID is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.note.delete({
|
||||||
|
where: { id }
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Note deleted successfully'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('DELETE /api/notes error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Failed to delete note' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
15
keep-notes/app/archive/page.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { getArchivedNotes } from '@/app/actions/notes'
|
||||||
|
import { NoteGrid } from '@/components/note-grid'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
export default async function ArchivePage() {
|
||||||
|
const notes = await getArchivedNotes()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
||||||
|
<h1 className="text-3xl font-bold mb-8">Archive</h1>
|
||||||
|
<NoteGrid notes={notes} />
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
BIN
keep-notes/app/favicon.ico
Normal file
|
After Width: | Height: | Size: 25 KiB |
125
keep-notes/app/globals.css
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--font-sans: var(--font-geist-sans);
|
||||||
|
--font-mono: var(--font-geist-mono);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--radius-2xl: calc(var(--radius) + 8px);
|
||||||
|
--radius-3xl: calc(var(--radius) + 12px);
|
||||||
|
--radius-4xl: calc(var(--radius) + 16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.205 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.205 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.922 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.556 0 0);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
31
keep-notes/app/layout.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Inter } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
import { HeaderWrapper } from "@/components/header-wrapper";
|
||||||
|
import { ToastProvider } from "@/components/ui/toast";
|
||||||
|
|
||||||
|
const inter = Inter({
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Memento - Your Digital Notepad",
|
||||||
|
description: "A beautiful note-taking app inspired by Google Keep, built with Next.js 16",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en" suppressHydrationWarning>
|
||||||
|
<body className={inter.className}>
|
||||||
|
<ToastProvider>
|
||||||
|
<HeaderWrapper />
|
||||||
|
{children}
|
||||||
|
</ToastProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
keep-notes/app/page.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useSearchParams } from 'next/navigation'
|
||||||
|
import { Note } from '@/lib/types'
|
||||||
|
import { getNotes, searchNotes } from '@/app/actions/notes'
|
||||||
|
import { NoteInput } from '@/components/note-input'
|
||||||
|
import { NoteGrid } from '@/components/note-grid'
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const [notes, setNotes] = useState<Note[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadNotes = async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
const search = searchParams.get('search')
|
||||||
|
const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || []
|
||||||
|
|
||||||
|
let allNotes = search ? await searchNotes(search) : await getNotes()
|
||||||
|
|
||||||
|
// Filter by selected labels
|
||||||
|
if (labelFilter.length > 0) {
|
||||||
|
allNotes = allNotes.filter(note =>
|
||||||
|
note.labels?.some(label => labelFilter.includes(label))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
setNotes(allNotes)
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
loadNotes()
|
||||||
|
}, [searchParams])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
||||||
|
<NoteInput />
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-8 text-gray-500">Loading...</div>
|
||||||
|
) : (
|
||||||
|
<NoteGrid notes={notes} />
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
22
keep-notes/components.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
25
keep-notes/components/header-wrapper.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Header } from './header'
|
||||||
|
import { useSearchParams, useRouter } from 'next/navigation'
|
||||||
|
|
||||||
|
export function HeaderWrapper() {
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const selectedLabels = searchParams.get('labels')?.split(',').filter(Boolean) || []
|
||||||
|
|
||||||
|
const handleLabelFilterChange = (labels: string[]) => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
|
|
||||||
|
if (labels.length > 0) {
|
||||||
|
params.set('labels', labels.join(','))
|
||||||
|
} else {
|
||||||
|
params.delete('labels')
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(`/?${params.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Header selectedLabels={selectedLabels} onLabelFilterChange={handleLabelFilterChange} />
|
||||||
|
}
|
||||||
148
keep-notes/components/header.tsx
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { Menu, Search, Archive, StickyNote, Tag, Moon, Sun } from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { usePathname } from 'next/navigation'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { searchNotes } from '@/app/actions/notes'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { LabelFilter } from './label-filter'
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
selectedLabels?: string[]
|
||||||
|
onLabelFilterChange?: (labels: string[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header({ selectedLabels = [], onLabelFilterChange }: HeaderProps = {}) {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [isSearching, setIsSearching] = useState(false)
|
||||||
|
const [theme, setTheme] = useState<'light' | 'dark'>('light')
|
||||||
|
const pathname = usePathname()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check for saved theme or system preference
|
||||||
|
const savedTheme = localStorage.getItem('theme') as 'light' | 'dark' | null
|
||||||
|
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||||
|
const initialTheme = savedTheme || systemTheme
|
||||||
|
|
||||||
|
setTheme(initialTheme)
|
||||||
|
document.documentElement.classList.toggle('dark', initialTheme === 'dark')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
const newTheme = theme === 'light' ? 'dark' : 'light'
|
||||||
|
setTheme(newTheme)
|
||||||
|
localStorage.setItem('theme', newTheme)
|
||||||
|
document.documentElement.classList.toggle('dark', newTheme === 'dark')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = async (query: string) => {
|
||||||
|
setSearchQuery(query)
|
||||||
|
if (query.trim()) {
|
||||||
|
setIsSearching(true)
|
||||||
|
// Search functionality will be handled by the parent component
|
||||||
|
// For now, we'll just update the URL
|
||||||
|
router.push(`/?search=${encodeURIComponent(query)}`)
|
||||||
|
setIsSearching(false)
|
||||||
|
} else {
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
|
<div className="container flex h-16 items-center px-4 gap-4">
|
||||||
|
{/* Mobile Menu */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="md:hidden">
|
||||||
|
<Menu className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="w-48">
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/" className="flex items-center">
|
||||||
|
<StickyNote className="h-4 w-4 mr-2" />
|
||||||
|
Notes
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/archive" className="flex items-center">
|
||||||
|
<Archive className="h-4 w-4 mr-2" />
|
||||||
|
Archive
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{/* Logo */}
|
||||||
|
<Link href="/" className="flex items-center gap-2">
|
||||||
|
<StickyNote className="h-6 w-6 text-yellow-500" />
|
||||||
|
<span className="font-semibold text-xl hidden sm:inline-block">Memento</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Search Bar */}
|
||||||
|
<div className="flex-1 max-w-2xl flex items-center gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search notes..."
|
||||||
|
className="pl-10"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => handleSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{onLabelFilterChange && (
|
||||||
|
<LabelFilter
|
||||||
|
selectedLabels={selectedLabels}
|
||||||
|
onFilterChange={onLabelFilterChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Theme Toggle */}
|
||||||
|
<Button variant="ghost" size="sm" onClick={toggleTheme}>
|
||||||
|
{theme === 'light' ? (
|
||||||
|
<Moon className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<Sun className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop Navigation */}
|
||||||
|
<nav className="hidden md:flex border-t">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors hover:bg-accent',
|
||||||
|
pathname === '/' && 'bg-accent'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<StickyNote className="h-4 w-4" />
|
||||||
|
Notes
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/archive"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors hover:bg-accent',
|
||||||
|
pathname === '/archive' && 'bg-accent'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Archive className="h-4 w-4" />
|
||||||
|
Archive
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
130
keep-notes/components/label-filter.tsx
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Filter, X } from 'lucide-react'
|
||||||
|
import { getAllLabelColors, getLabelColor } from '@/lib/label-storage'
|
||||||
|
import { LABEL_COLORS } from '@/lib/types'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface LabelFilterProps {
|
||||||
|
selectedLabels: string[]
|
||||||
|
onFilterChange: (labels: string[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LabelFilter({ selectedLabels, onFilterChange }: LabelFilterProps) {
|
||||||
|
const [allLabels, setAllLabels] = useState<string[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Load all labels from localStorage
|
||||||
|
const labelColors = getAllLabelColors()
|
||||||
|
setAllLabels(Object.keys(labelColors).sort())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleToggleLabel = (label: string) => {
|
||||||
|
if (selectedLabels.includes(label)) {
|
||||||
|
onFilterChange(selectedLabels.filter(l => l !== label))
|
||||||
|
} else {
|
||||||
|
onFilterChange([...selectedLabels, label])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearAll = () => {
|
||||||
|
onFilterChange([])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allLabels.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="h-9">
|
||||||
|
<Filter className="h-4 w-4 mr-2" />
|
||||||
|
Filter by Label
|
||||||
|
{selectedLabels.length > 0 && (
|
||||||
|
<Badge variant="secondary" className="ml-2 h-5 min-w-5 px-1.5">
|
||||||
|
{selectedLabels.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-64">
|
||||||
|
<DropdownMenuLabel className="flex items-center justify-between">
|
||||||
|
Filter by Labels
|
||||||
|
{selectedLabels.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleClearAll}
|
||||||
|
className="h-6 text-xs"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{allLabels.map((label) => {
|
||||||
|
const colorName = getLabelColor(label)
|
||||||
|
const colorClasses = LABEL_COLORS[colorName]
|
||||||
|
const isSelected = selectedLabels.includes(label)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={label}
|
||||||
|
checked={isSelected}
|
||||||
|
onCheckedChange={() => handleToggleLabel(label)}
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
className={cn(
|
||||||
|
'text-xs border mr-2',
|
||||||
|
colorClasses.bg,
|
||||||
|
colorClasses.text,
|
||||||
|
colorClasses.border
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Badge>
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{/* Active filters display */}
|
||||||
|
{selectedLabels.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{selectedLabels.map((label) => {
|
||||||
|
const colorName = getLabelColor(label)
|
||||||
|
const colorClasses = LABEL_COLORS[colorName]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
key={label}
|
||||||
|
className={cn(
|
||||||
|
'text-xs border cursor-pointer pr-1',
|
||||||
|
colorClasses.bg,
|
||||||
|
colorClasses.text,
|
||||||
|
colorClasses.border
|
||||||
|
)}
|
||||||
|
onClick={() => handleToggleLabel(label)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
<X className="h-3 w-3 ml-1" />
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
229
keep-notes/components/label-manager.tsx
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Button } from './ui/button'
|
||||||
|
import { Input } from './ui/input'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from './ui/dialog'
|
||||||
|
import { Badge } from './ui/badge'
|
||||||
|
import { Tag, X, Plus, Palette } from 'lucide-react'
|
||||||
|
import { LABEL_COLORS, LabelColorName } from '@/lib/types'
|
||||||
|
import { getLabelColor, setLabelColor, deleteLabelColor, getAllLabelColors } from '@/lib/label-storage'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface LabelManagerProps {
|
||||||
|
existingLabels: string[]
|
||||||
|
onUpdate: (labels: string[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LabelManager({ existingLabels, onUpdate }: LabelManagerProps) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [newLabel, setNewLabel] = useState('')
|
||||||
|
const [selectedLabels, setSelectedLabels] = useState<string[]>(existingLabels)
|
||||||
|
const [allLabelsInStorage, setAllLabelsInStorage] = useState<string[]>([])
|
||||||
|
const [editingColor, setEditingColor] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Load all labels from localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
const allColors = getAllLabelColors()
|
||||||
|
setAllLabelsInStorage(Object.keys(allColors))
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const handleAddLabel = () => {
|
||||||
|
const trimmed = newLabel.trim()
|
||||||
|
if (trimmed && !selectedLabels.includes(trimmed)) {
|
||||||
|
const updated = [...selectedLabels, trimmed]
|
||||||
|
setSelectedLabels(updated)
|
||||||
|
setNewLabel('')
|
||||||
|
|
||||||
|
// Set default color if doesn't exist
|
||||||
|
if (getLabelColor(trimmed) === 'gray') {
|
||||||
|
const colors = Object.keys(LABEL_COLORS) as LabelColorName[]
|
||||||
|
const randomColor = colors[Math.floor(Math.random() * colors.length)]
|
||||||
|
setLabelColor(trimmed, randomColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveLabel = (label: string) => {
|
||||||
|
setSelectedLabels(selectedLabels.filter(l => l !== label))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectExisting = (label: string) => {
|
||||||
|
if (!selectedLabels.includes(label)) {
|
||||||
|
setSelectedLabels([...selectedLabels, label])
|
||||||
|
} else {
|
||||||
|
setSelectedLabels(selectedLabels.filter(l => l !== label))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChangeColor = (label: string, color: LabelColorName) => {
|
||||||
|
setLabelColor(label, color)
|
||||||
|
setEditingColor(null)
|
||||||
|
// Force re-render
|
||||||
|
const allColors = getAllLabelColors()
|
||||||
|
setAllLabelsInStorage(Object.keys(allColors))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
onUpdate(selectedLabels)
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setSelectedLabels(existingLabels)
|
||||||
|
setEditingColor(null)
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(isOpen) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
handleCancel()
|
||||||
|
} else {
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<Tag className="h-4 w-4 mr-2" />
|
||||||
|
Labels
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Manage Labels</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Add or remove labels for this note. Click on a label to change its color.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
{/* Add new label */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="New label name"
|
||||||
|
value={newLabel}
|
||||||
|
onChange={(e) => setNewLabel(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
handleAddLabel()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button onClick={handleAddLabel} size="sm">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected labels */}
|
||||||
|
{selectedLabels.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium mb-2">Selected Labels</h4>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{selectedLabels.map((label) => {
|
||||||
|
const colorName = getLabelColor(label)
|
||||||
|
const colorClasses = LABEL_COLORS[colorName]
|
||||||
|
const isEditing = editingColor === label
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={label} className="relative">
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="absolute z-10 top-8 left-0 bg-white dark:bg-zinc-900 border rounded-lg shadow-lg p-2">
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{(Object.keys(LABEL_COLORS) as LabelColorName[]).map((color) => {
|
||||||
|
const classes = LABEL_COLORS[color]
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={color}
|
||||||
|
className={cn(
|
||||||
|
'h-8 w-8 rounded-full border-2 transition-transform hover:scale-110',
|
||||||
|
classes.bg,
|
||||||
|
colorName === color ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-600'
|
||||||
|
)}
|
||||||
|
onClick={() => handleChangeColor(label, color)}
|
||||||
|
title={color}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Badge
|
||||||
|
className={cn(
|
||||||
|
'text-xs border cursor-pointer pr-1 flex items-center gap-1',
|
||||||
|
colorClasses.bg,
|
||||||
|
colorClasses.text,
|
||||||
|
colorClasses.border
|
||||||
|
)}
|
||||||
|
onClick={() => setEditingColor(isEditing ? null : label)}
|
||||||
|
>
|
||||||
|
<Palette className="h-3 w-3" />
|
||||||
|
{label}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleRemoveLabel(label)
|
||||||
|
}}
|
||||||
|
className="ml-1 hover:bg-black/10 dark:hover:bg-white/10 rounded-full p-0.5"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Available labels from storage */}
|
||||||
|
{allLabelsInStorage.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium mb-2">All Labels</h4>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{allLabelsInStorage
|
||||||
|
.filter(label => !selectedLabels.includes(label))
|
||||||
|
.map((label) => {
|
||||||
|
const colorName = getLabelColor(label)
|
||||||
|
const colorClasses = LABEL_COLORS[colorName]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
key={label}
|
||||||
|
className={cn(
|
||||||
|
'text-xs border cursor-pointer',
|
||||||
|
colorClasses.bg,
|
||||||
|
colorClasses.text,
|
||||||
|
colorClasses.border,
|
||||||
|
'hover:opacity-80'
|
||||||
|
)}
|
||||||
|
onClick={() => handleSelectExisting(label)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={handleCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave}>Save</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
332
keep-notes/components/note-card.tsx
Normal file
@ -0,0 +1,332 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Note, NOTE_COLORS, NoteColor, LABEL_COLORS } from '@/lib/types'
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import {
|
||||||
|
Archive,
|
||||||
|
ArchiveRestore,
|
||||||
|
MoreVertical,
|
||||||
|
Palette,
|
||||||
|
Pin,
|
||||||
|
Tag,
|
||||||
|
Trash2,
|
||||||
|
Bell,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { deleteNote, toggleArchive, togglePin, updateColor, updateNote } from '@/app/actions/notes'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
|
import { fr } from 'date-fns/locale'
|
||||||
|
import { MarkdownContent } from './markdown-content'
|
||||||
|
import { getLabelColor } from '@/lib/label-storage'
|
||||||
|
|
||||||
|
interface NoteCardProps {
|
||||||
|
note: Note
|
||||||
|
onEdit?: (note: Note) => void
|
||||||
|
isDragging?: boolean
|
||||||
|
isDragOver?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NoteCard({ note, onEdit, isDragging, isDragOver }: NoteCardProps) {
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
|
const [labelColors, setLabelColors] = useState<Record<string, string>>({})
|
||||||
|
const colorClasses = NOTE_COLORS[note.color as NoteColor] || NOTE_COLORS.default
|
||||||
|
|
||||||
|
// Load label colors from localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
if (note.labels) {
|
||||||
|
const colors: Record<string, string> = {}
|
||||||
|
note.labels.forEach(label => {
|
||||||
|
colors[label] = getLabelColor(label)
|
||||||
|
})
|
||||||
|
setLabelColors(colors)
|
||||||
|
}
|
||||||
|
}, [note.labels])
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (confirm('Are you sure you want to delete this note?')) {
|
||||||
|
setIsDeleting(true)
|
||||||
|
try {
|
||||||
|
await deleteNote(note.id)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete note:', error)
|
||||||
|
setIsDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTogglePin = async () => {
|
||||||
|
await togglePin(note.id, !note.isPinned)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleArchive = async () => {
|
||||||
|
await toggleArchive(note.id, !note.isArchived)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleColorChange = async (color: string) => {
|
||||||
|
await updateColor(note.id, color)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCheckItem = async (checkItemId: string) => {
|
||||||
|
if (note.type === 'checklist' && note.checkItems) {
|
||||||
|
const updatedItems = note.checkItems.map(item =>
|
||||||
|
item.id === checkItemId ? { ...item, checked: !item.checked } : item
|
||||||
|
)
|
||||||
|
await updateNote(note.id, { checkItems: updatedItems })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDeleting) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
'note-card-main group relative p-4 transition-all duration-200 border cursor-move',
|
||||||
|
'hover:shadow-md',
|
||||||
|
colorClasses.bg,
|
||||||
|
colorClasses.card,
|
||||||
|
colorClasses.hover,
|
||||||
|
isDragging && 'opacity-30',
|
||||||
|
isDragOver && 'ring-2 ring-blue-500'
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
// Only trigger edit if not clicking on buttons
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
if (!target.closest('button') && !target.closest('[role="checkbox"]')) {
|
||||||
|
onEdit?.(note)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Pin Icon */}
|
||||||
|
{note.isPinned && (
|
||||||
|
<Pin className="absolute top-3 right-3 h-4 w-4 text-gray-600 dark:text-gray-400" fill="currentColor" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reminder Icon */}
|
||||||
|
{note.reminder && new Date(note.reminder) > new Date() && (
|
||||||
|
<Bell
|
||||||
|
className={cn(
|
||||||
|
"absolute h-4 w-4 text-blue-600 dark:text-blue-400",
|
||||||
|
note.isPinned ? "top-3 right-9" : "top-3 right-3"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
{note.title && (
|
||||||
|
<h3 className="text-base font-medium mb-2 pr-6 text-gray-900 dark:text-gray-100">
|
||||||
|
{note.title}
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Images */}
|
||||||
|
{note.images && note.images.length > 0 && (
|
||||||
|
<div className={cn(
|
||||||
|
"mb-3 -mx-4",
|
||||||
|
!note.title && "-mt-4"
|
||||||
|
)}>
|
||||||
|
{note.images.length === 1 ? (
|
||||||
|
<img
|
||||||
|
src={note.images[0]}
|
||||||
|
alt=""
|
||||||
|
className="w-full h-auto rounded-lg"
|
||||||
|
/>
|
||||||
|
) : note.images.length === 2 ? (
|
||||||
|
<div className="grid grid-cols-2 gap-2 px-4">
|
||||||
|
{note.images.map((img, idx) => (
|
||||||
|
<img
|
||||||
|
key={idx}
|
||||||
|
src={img}
|
||||||
|
alt=""
|
||||||
|
className="w-full h-auto rounded-lg"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : note.images.length === 3 ? (
|
||||||
|
<div className="grid grid-cols-2 gap-2 px-4">
|
||||||
|
<img
|
||||||
|
src={note.images[0]}
|
||||||
|
alt=""
|
||||||
|
className="col-span-2 w-full h-auto rounded-lg"
|
||||||
|
/>
|
||||||
|
{note.images.slice(1).map((img, idx) => (
|
||||||
|
<img
|
||||||
|
key={idx}
|
||||||
|
src={img}
|
||||||
|
alt=""
|
||||||
|
className="w-full h-auto rounded-lg"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-2 px-4">
|
||||||
|
{note.images.slice(0, 4).map((img, idx) => (
|
||||||
|
<img
|
||||||
|
key={idx}
|
||||||
|
src={img}
|
||||||
|
alt=""
|
||||||
|
className="w-full h-auto rounded-lg"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{note.images.length > 4 && (
|
||||||
|
<div className="absolute bottom-2 right-2 bg-black/70 text-white px-2 py-1 rounded text-xs">
|
||||||
|
+{note.images.length - 4}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{note.type === 'text' ? (
|
||||||
|
note.isMarkdown ? (
|
||||||
|
<div className="text-sm line-clamp-10">
|
||||||
|
<MarkdownContent content={note.content} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap line-clamp-10">
|
||||||
|
{note.content}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{note.checkItems?.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="flex items-start gap-2"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleCheckItem(item.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={item.checked}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-sm',
|
||||||
|
item.checked
|
||||||
|
? 'line-through text-gray-500 dark:text-gray-500'
|
||||||
|
: 'text-gray-700 dark:text-gray-300'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Labels */}
|
||||||
|
{note.labels && note.labels.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-3">
|
||||||
|
{note.labels.map((label) => {
|
||||||
|
const colorName = labelColors[label] || 'gray'
|
||||||
|
const colorClasses = LABEL_COLORS[colorName as keyof typeof LABEL_COLORS] || LABEL_COLORS.gray
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
key={label}
|
||||||
|
className={cn(
|
||||||
|
'text-xs border',
|
||||||
|
colorClasses.bg,
|
||||||
|
colorClasses.text,
|
||||||
|
colorClasses.border
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Creation Date */}
|
||||||
|
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: fr })}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Bar - Shows on Hover */}
|
||||||
|
<div
|
||||||
|
className="absolute bottom-0 left-0 right-0 p-2 flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Pin Button */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={handleTogglePin}
|
||||||
|
title={note.isPinned ? 'Unpin' : 'Pin'}
|
||||||
|
>
|
||||||
|
<Pin className={cn('h-4 w-4', note.isPinned && 'fill-current')} />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Color Palette */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" title="Change color">
|
||||||
|
<Palette className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<div className="grid grid-cols-5 gap-2 p-2">
|
||||||
|
{Object.entries(NOTE_COLORS).map(([colorName, classes]) => (
|
||||||
|
<button
|
||||||
|
key={colorName}
|
||||||
|
className={cn(
|
||||||
|
'h-8 w-8 rounded-full border-2 transition-transform hover:scale-110',
|
||||||
|
classes.bg,
|
||||||
|
note.color === colorName ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-700'
|
||||||
|
)}
|
||||||
|
onClick={() => handleColorChange(colorName)}
|
||||||
|
title={colorName}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{/* More Options */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||||
|
<MoreVertical className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={handleToggleArchive}>
|
||||||
|
{note.isArchived ? (
|
||||||
|
<>
|
||||||
|
<ArchiveRestore className="h-4 w-4 mr-2" />
|
||||||
|
Unarchive
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Archive className="h-4 w-4 mr-2" />
|
||||||
|
Archive
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={handleDelete} className="text-red-600 dark:text-red-400">
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
466
keep-notes/components/note-editor.tsx
Normal file
@ -0,0 +1,466 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { Note, CheckItem, NOTE_COLORS, NoteColor, LABEL_COLORS } from '@/lib/types'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { X, Plus, Palette, Tag, Image as ImageIcon, Bell, FileText, Eye } from 'lucide-react'
|
||||||
|
import { updateNote } from '@/app/actions/notes'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useToast } from '@/components/ui/toast'
|
||||||
|
import { MarkdownContent } from './markdown-content'
|
||||||
|
import { LabelManager } from './label-manager'
|
||||||
|
import { getLabelColor } from '@/lib/label-storage'
|
||||||
|
|
||||||
|
interface NoteEditorProps {
|
||||||
|
note: Note
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NoteEditor({ note, onClose }: NoteEditorProps) {
|
||||||
|
const { addToast } = useToast()
|
||||||
|
const [title, setTitle] = useState(note.title || '')
|
||||||
|
const [content, setContent] = useState(note.content)
|
||||||
|
const [checkItems, setCheckItems] = useState<CheckItem[]>(note.checkItems || [])
|
||||||
|
const [labels, setLabels] = useState<string[]>(note.labels || [])
|
||||||
|
const [images, setImages] = useState<string[]>(note.images || [])
|
||||||
|
const [newLabel, setNewLabel] = useState('')
|
||||||
|
const [color, setColor] = useState(note.color)
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [isMarkdown, setIsMarkdown] = useState(note.isMarkdown || false)
|
||||||
|
const [showMarkdownPreview, setShowMarkdownPreview] = useState(false)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// Reminder state
|
||||||
|
const [showReminderDialog, setShowReminderDialog] = useState(false)
|
||||||
|
const [reminderDate, setReminderDate] = useState('')
|
||||||
|
const [reminderTime, setReminderTime] = useState('')
|
||||||
|
const [currentReminder, setCurrentReminder] = useState<Date | null>(note.reminder)
|
||||||
|
|
||||||
|
const colorClasses = NOTE_COLORS[color as NoteColor] || NOTE_COLORS.default
|
||||||
|
|
||||||
|
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files
|
||||||
|
if (!files) return
|
||||||
|
|
||||||
|
Array.from(files).forEach(file => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onloadend = () => {
|
||||||
|
setImages(prev => [...prev, reader.result as string])
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveImage = (index: number) => {
|
||||||
|
setImages(images.filter((_, i) => i !== index))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReminderOpen = () => {
|
||||||
|
if (currentReminder) {
|
||||||
|
const date = new Date(currentReminder)
|
||||||
|
setReminderDate(date.toISOString().split('T')[0])
|
||||||
|
setReminderTime(date.toTimeString().slice(0, 5))
|
||||||
|
} else {
|
||||||
|
const tomorrow = new Date(Date.now() + 86400000)
|
||||||
|
setReminderDate(tomorrow.toISOString().split('T')[0])
|
||||||
|
setReminderTime('09:00')
|
||||||
|
}
|
||||||
|
setShowReminderDialog(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReminderSave = () => {
|
||||||
|
if (!reminderDate || !reminderTime) {
|
||||||
|
addToast('Please enter date and time', 'warning')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateTimeString = `${reminderDate}T${reminderTime}`
|
||||||
|
const date = new Date(dateTimeString)
|
||||||
|
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
addToast('Invalid date or time', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date < new Date()) {
|
||||||
|
addToast('Reminder must be in the future', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentReminder(date)
|
||||||
|
addToast(`Reminder set for ${date.toLocaleString()}`, 'success')
|
||||||
|
setShowReminderDialog(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveReminder = () => {
|
||||||
|
setCurrentReminder(null)
|
||||||
|
setShowReminderDialog(false)
|
||||||
|
addToast('Reminder removed', 'success')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setIsSaving(true)
|
||||||
|
try {
|
||||||
|
await updateNote(note.id, {
|
||||||
|
title: title.trim() || null,
|
||||||
|
content: note.type === 'text' ? content : '',
|
||||||
|
checkItems: note.type === 'checklist' ? checkItems : null,
|
||||||
|
labels,
|
||||||
|
images,
|
||||||
|
color,
|
||||||
|
reminder: currentReminder,
|
||||||
|
isMarkdown,
|
||||||
|
})
|
||||||
|
onClose()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save note:', error)
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCheckItem = (id: string) => {
|
||||||
|
setCheckItems(items =>
|
||||||
|
items.map(item =>
|
||||||
|
item.id === id ? { ...item, checked: !item.checked } : item
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateCheckItem = (id: string, text: string) => {
|
||||||
|
setCheckItems(items =>
|
||||||
|
items.map(item => (item.id === id ? { ...item, text } : item))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddCheckItem = () => {
|
||||||
|
setCheckItems([
|
||||||
|
...checkItems,
|
||||||
|
{ id: Date.now().toString(), text: '', checked: false },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveCheckItem = (id: string) => {
|
||||||
|
setCheckItems(items => items.filter(item => item.id !== id))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddLabel = () => {
|
||||||
|
if (newLabel.trim() && !labels.includes(newLabel.trim())) {
|
||||||
|
setLabels([...labels, newLabel.trim()])
|
||||||
|
setNewLabel('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveLabel = (label: string) => {
|
||||||
|
setLabels(labels.filter(l => l !== label))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={onClose}>
|
||||||
|
<DialogContent
|
||||||
|
className={cn(
|
||||||
|
'!max-w-[min(95vw,1600px)] max-h-[90vh] overflow-y-auto',
|
||||||
|
colorClasses.bg
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="sr-only">Edit Note</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Title */}
|
||||||
|
<Input
|
||||||
|
placeholder="Title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
className="text-lg font-semibold border-0 focus-visible:ring-0 px-0 bg-transparent"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Images */}
|
||||||
|
{images.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-3 mb-4">
|
||||||
|
{images.map((img, idx) => (
|
||||||
|
<div key={idx} className="relative group">
|
||||||
|
<img
|
||||||
|
src={img}
|
||||||
|
alt=""
|
||||||
|
className="h-auto rounded-lg"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="absolute top-2 right-2 h-7 w-7 p-0 bg-white/90 hover:bg-white opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
onClick={() => handleRemoveImage(idx)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content or Checklist */}
|
||||||
|
{note.type === 'text' ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Markdown controls */}
|
||||||
|
<div className="flex items-center justify-between gap-2 pb-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setIsMarkdown(!isMarkdown)
|
||||||
|
if (isMarkdown) setShowMarkdownPreview(false)
|
||||||
|
}}
|
||||||
|
className={cn("h-7 text-xs", isMarkdown && "text-blue-600")}
|
||||||
|
>
|
||||||
|
<FileText className="h-3 w-3 mr-1" />
|
||||||
|
{isMarkdown ? 'Markdown ON' : 'Markdown OFF'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{isMarkdown && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowMarkdownPreview(!showMarkdownPreview)}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
{showMarkdownPreview ? (
|
||||||
|
<>
|
||||||
|
<FileText className="h-3 w-3 mr-1" />
|
||||||
|
Edit
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Eye className="h-3 w-3 mr-1" />
|
||||||
|
Preview
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showMarkdownPreview && isMarkdown ? (
|
||||||
|
<MarkdownContent
|
||||||
|
content={content || '*No content*'}
|
||||||
|
className="min-h-[200px] p-3 rounded-md border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Textarea
|
||||||
|
placeholder={isMarkdown ? "Take a note... (Markdown supported)" : "Take a note..."}
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
className="min-h-[200px] border-0 focus-visible:ring-0 px-0 bg-transparent resize-none"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{checkItems.map((item) => (
|
||||||
|
<div key={item.id} className="flex items-start gap-2 group">
|
||||||
|
<Checkbox
|
||||||
|
checked={item.checked}
|
||||||
|
onCheckedChange={() => handleCheckItem(item.id)}
|
||||||
|
className="mt-2"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={item.text}
|
||||||
|
onChange={(e) => handleUpdateCheckItem(item.id, e.target.value)}
|
||||||
|
placeholder="List item"
|
||||||
|
className="flex-1 border-0 focus-visible:ring-0 px-0 bg-transparent"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="opacity-0 group-hover:opacity-100 h-8 w-8 p-0"
|
||||||
|
onClick={() => handleRemoveCheckItem(item.id)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAddCheckItem}
|
||||||
|
className="text-gray-600 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
Add item
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Labels */}
|
||||||
|
{labels.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{labels.map((label) => {
|
||||||
|
const colorName = getLabelColor(label)
|
||||||
|
const colorClasses = LABEL_COLORS[colorName]
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
key={label}
|
||||||
|
className={cn(
|
||||||
|
'gap-1 border',
|
||||||
|
colorClasses.bg,
|
||||||
|
colorClasses.text,
|
||||||
|
colorClasses.border
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveLabel(label)}
|
||||||
|
className="hover:text-red-600"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center justify-between pt-4 border-t">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Reminder Button */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleReminderOpen}
|
||||||
|
title="Set reminder"
|
||||||
|
className={currentReminder ? "text-blue-600" : ""}
|
||||||
|
>
|
||||||
|
<Bell className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Add Image Button */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
title="Add image"
|
||||||
|
>
|
||||||
|
<ImageIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Color Picker */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" title="Change color">
|
||||||
|
<Palette className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<div className="grid grid-cols-5 gap-2 p-2">
|
||||||
|
{Object.entries(NOTE_COLORS).map(([colorName, classes]) => (
|
||||||
|
<button
|
||||||
|
key={colorName}
|
||||||
|
className={cn(
|
||||||
|
'h-8 w-8 rounded-full border-2 transition-transform hover:scale-110',
|
||||||
|
classes.bg,
|
||||||
|
color === colorName ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-700'
|
||||||
|
)}
|
||||||
|
onClick={() => setColor(colorName)}
|
||||||
|
title={colorName}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{/* Label Manager */}
|
||||||
|
<LabelManager
|
||||||
|
existingLabels={labels}
|
||||||
|
onUpdate={setLabels}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={isSaving}>
|
||||||
|
{isSaving ? 'Saving...' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleImageUpload}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
{/* Reminder Dialog */}
|
||||||
|
<Dialog open={showReminderDialog} onOpenChange={setShowReminderDialog}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Set Reminder</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="reminder-date" className="text-sm font-medium">
|
||||||
|
Date
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="reminder-date"
|
||||||
|
type="date"
|
||||||
|
value={reminderDate}
|
||||||
|
onChange={(e) => setReminderDate(e.target.value)}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="reminder-time" className="text-sm font-medium">
|
||||||
|
Time
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="reminder-time"
|
||||||
|
type="time"
|
||||||
|
value={reminderTime}
|
||||||
|
onChange={(e) => setReminderTime(e.target.value)}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<div>
|
||||||
|
{currentReminder && (
|
||||||
|
<Button variant="outline" onClick={handleRemoveReminder}>
|
||||||
|
Remove Reminder
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="ghost" onClick={() => setShowReminderDialog(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleReminderSave}>
|
||||||
|
Set Reminder
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
269
keep-notes/components/note-grid.tsx
Normal file
@ -0,0 +1,269 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Note } from '@/lib/types'
|
||||||
|
import { NoteCard } from './note-card'
|
||||||
|
import { useState, useMemo, useEffect } from 'react'
|
||||||
|
import { NoteEditor } from './note-editor'
|
||||||
|
import { reorderNotes, getNotes } from '@/app/actions/notes'
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
DragEndEvent,
|
||||||
|
DragOverlay,
|
||||||
|
DragStartEvent,
|
||||||
|
MouseSensor,
|
||||||
|
TouchSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
closestCenter,
|
||||||
|
PointerSensor,
|
||||||
|
} from '@dnd-kit/core'
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
rectSortingStrategy,
|
||||||
|
useSortable,
|
||||||
|
arrayMove,
|
||||||
|
} from '@dnd-kit/sortable'
|
||||||
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
|
||||||
|
interface NoteGridProps {
|
||||||
|
notes: Note[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableNote({ note, onEdit }: { note: Note; onEdit: (note: Note) => void }) {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: note.id })
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
zIndex: isDragging ? 1000 : 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
data-note-id={note.id}
|
||||||
|
data-draggable="true"
|
||||||
|
>
|
||||||
|
<NoteCard note={note} onEdit={onEdit} isDragging={isDragging} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NoteGrid({ notes }: NoteGridProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [editingNote, setEditingNote] = useState<Note | null>(null)
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null)
|
||||||
|
const [localPinnedNotes, setLocalPinnedNotes] = useState<Note[]>([])
|
||||||
|
const [localUnpinnedNotes, setLocalUnpinnedNotes] = useState<Note[]>([])
|
||||||
|
|
||||||
|
// Sync local state with props
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalPinnedNotes(notes.filter(note => note.isPinned).sort((a, b) => a.order - b.order))
|
||||||
|
setLocalUnpinnedNotes(notes.filter(note => !note.isPinned).sort((a, b) => a.order - b.order))
|
||||||
|
}, [notes])
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 8,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
useSensor(MouseSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 8,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
useSensor(TouchSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
delay: 200,
|
||||||
|
tolerance: 6,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDragStart = (event: DragStartEvent) => {
|
||||||
|
console.log('[DND-DEBUG] Drag started:', {
|
||||||
|
activeId: event.active.id,
|
||||||
|
activeData: event.active.data.current
|
||||||
|
})
|
||||||
|
setActiveId(event.active.id as string)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragEnd = async (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event
|
||||||
|
console.log('[DND-DEBUG] Drag ended:', {
|
||||||
|
activeId: active.id,
|
||||||
|
overId: over?.id,
|
||||||
|
hasOver: !!over
|
||||||
|
})
|
||||||
|
setActiveId(null)
|
||||||
|
|
||||||
|
if (!over || active.id === over.id) {
|
||||||
|
console.log('[DND-DEBUG] Drag cancelled: no valid drop target or same element')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeIdStr = active.id as string
|
||||||
|
const overIdStr = over.id as string
|
||||||
|
|
||||||
|
// Determine which section the dragged note belongs to
|
||||||
|
const isInPinned = localPinnedNotes.some(n => n.id === activeIdStr)
|
||||||
|
const targetIsInPinned = localPinnedNotes.some(n => n.id === overIdStr)
|
||||||
|
|
||||||
|
console.log('[DND-DEBUG] Section check:', {
|
||||||
|
activeIdStr,
|
||||||
|
overIdStr,
|
||||||
|
isInPinned,
|
||||||
|
targetIsInPinned,
|
||||||
|
pinnedNotesCount: localPinnedNotes.length,
|
||||||
|
unpinnedNotesCount: localUnpinnedNotes.length
|
||||||
|
})
|
||||||
|
|
||||||
|
// Only allow reordering within the same section
|
||||||
|
if (isInPinned !== targetIsInPinned) {
|
||||||
|
console.log('[DND-DEBUG] Drag cancelled: crossing sections (pinned/unpinned)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInPinned) {
|
||||||
|
// Reorder pinned notes
|
||||||
|
const oldIndex = localPinnedNotes.findIndex(n => n.id === activeIdStr)
|
||||||
|
const newIndex = localPinnedNotes.findIndex(n => n.id === overIdStr)
|
||||||
|
|
||||||
|
console.log('[DND-DEBUG] Pinned reorder:', { oldIndex, newIndex })
|
||||||
|
|
||||||
|
if (oldIndex !== -1 && newIndex !== -1) {
|
||||||
|
const newOrder = arrayMove(localPinnedNotes, oldIndex, newIndex)
|
||||||
|
setLocalPinnedNotes(newOrder)
|
||||||
|
console.log('[DND-DEBUG] Calling reorderNotes for pinned notes')
|
||||||
|
await reorderNotes(activeIdStr, overIdStr)
|
||||||
|
|
||||||
|
// Refresh notes from server to sync state
|
||||||
|
console.log('[DND-DEBUG] Refreshing notes from server after reorder')
|
||||||
|
await refreshNotesFromServer()
|
||||||
|
} else {
|
||||||
|
console.log('[DND-DEBUG] Invalid indices for pinned reorder')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Reorder unpinned notes
|
||||||
|
const oldIndex = localUnpinnedNotes.findIndex(n => n.id === activeIdStr)
|
||||||
|
const newIndex = localUnpinnedNotes.findIndex(n => n.id === overIdStr)
|
||||||
|
|
||||||
|
console.log('[DND-DEBUG] Unpinned reorder:', { oldIndex, newIndex })
|
||||||
|
|
||||||
|
if (oldIndex !== -1 && newIndex !== -1) {
|
||||||
|
const newOrder = arrayMove(localUnpinnedNotes, oldIndex, newIndex)
|
||||||
|
setLocalUnpinnedNotes(newOrder)
|
||||||
|
console.log('[DND-DEBUG] Calling reorderNotes for unpinned notes')
|
||||||
|
await reorderNotes(activeIdStr, overIdStr)
|
||||||
|
|
||||||
|
// Refresh notes from server to sync state
|
||||||
|
console.log('[DND-DEBUG] Refreshing notes from server after reorder')
|
||||||
|
await refreshNotesFromServer()
|
||||||
|
} else {
|
||||||
|
console.log('[DND-DEBUG] Invalid indices for unpinned reorder')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to refresh notes from server without full page reload
|
||||||
|
const refreshNotesFromServer = async () => {
|
||||||
|
console.log('[DND-DEBUG] Fetching fresh notes from server...')
|
||||||
|
const freshNotes = await getNotes()
|
||||||
|
console.log('[DND-DEBUG] Received fresh notes:', freshNotes.length)
|
||||||
|
|
||||||
|
// Update local state with fresh data
|
||||||
|
const pinned = freshNotes.filter(note => note.isPinned).sort((a, b) => a.order - b.order)
|
||||||
|
const unpinned = freshNotes.filter(note => !note.isPinned).sort((a, b) => a.order - b.order)
|
||||||
|
|
||||||
|
setLocalPinnedNotes(pinned)
|
||||||
|
setLocalUnpinnedNotes(unpinned)
|
||||||
|
|
||||||
|
console.log('[DND-DEBUG] Local state updated with fresh server data')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find active note from either section
|
||||||
|
const activeNote = activeId
|
||||||
|
? localPinnedNotes.find(n => n.id === activeId) || localUnpinnedNotes.find(n => n.id === activeId)
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="space-y-8">
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
{localPinnedNotes.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3 px-2">
|
||||||
|
Pinned
|
||||||
|
</h2>
|
||||||
|
<SortableContext items={localPinnedNotes.map(n => n.id)} strategy={rectSortingStrategy}>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4 auto-rows-max">
|
||||||
|
{localPinnedNotes.map((note) => (
|
||||||
|
<SortableNote key={note.id} note={note} onEdit={setEditingNote} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{localUnpinnedNotes.length > 0 && (
|
||||||
|
<div>
|
||||||
|
{localPinnedNotes.length > 0 && (
|
||||||
|
<h2 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3 px-2">
|
||||||
|
Others
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
<SortableContext items={localUnpinnedNotes.map(n => n.id)} strategy={rectSortingStrategy}>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4 auto-rows-max">
|
||||||
|
{localUnpinnedNotes.map((note) => (
|
||||||
|
<SortableNote key={note.id} note={note} onEdit={setEditingNote} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DragOverlay>
|
||||||
|
{activeNote ? (
|
||||||
|
<div className="opacity-90 rotate-2 scale-105 shadow-2xl">
|
||||||
|
<NoteCard
|
||||||
|
note={activeNote}
|
||||||
|
onEdit={() => {}}
|
||||||
|
isDragging={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
|
||||||
|
{notes.length === 0 && (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 text-lg">No notes yet</p>
|
||||||
|
<p className="text-gray-400 dark:text-gray-500 text-sm mt-2">Create your first note to get started</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editingNote && (
|
||||||
|
<NoteEditor note={editingNote} onClose={() => setEditingNote(null)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
664
keep-notes/components/note-input.tsx
Normal file
@ -0,0 +1,664 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
CheckSquare,
|
||||||
|
X,
|
||||||
|
Bell,
|
||||||
|
Image,
|
||||||
|
UserPlus,
|
||||||
|
Palette,
|
||||||
|
Archive,
|
||||||
|
MoreVertical,
|
||||||
|
Undo2,
|
||||||
|
Redo2,
|
||||||
|
FileText,
|
||||||
|
Eye
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { createNote } from '@/app/actions/notes'
|
||||||
|
import { CheckItem, NOTE_COLORS, NoteColor } from '@/lib/types'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useToast } from '@/components/ui/toast'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||||
|
import { MarkdownContent } from './markdown-content'
|
||||||
|
|
||||||
|
interface HistoryState {
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NoteState {
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
checkItems: CheckItem[]
|
||||||
|
images: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NoteInput() {
|
||||||
|
const { addToast } = useToast()
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false)
|
||||||
|
const [type, setType] = useState<'text' | 'checklist'>('text')
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [color, setColor] = useState<NoteColor>('default')
|
||||||
|
const [isArchived, setIsArchived] = useState(false)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// Simple state without complex undo/redo - like Google Keep
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [content, setContent] = useState('')
|
||||||
|
const [checkItems, setCheckItems] = useState<CheckItem[]>([])
|
||||||
|
const [images, setImages] = useState<string[]>([])
|
||||||
|
const [isMarkdown, setIsMarkdown] = useState(false)
|
||||||
|
const [showMarkdownPreview, setShowMarkdownPreview] = useState(false)
|
||||||
|
|
||||||
|
// Undo/Redo history (title and content only)
|
||||||
|
const [history, setHistory] = useState<HistoryState[]>([{ title: '', content: '' }])
|
||||||
|
const [historyIndex, setHistoryIndex] = useState(0)
|
||||||
|
const isUndoingRef = useRef(false)
|
||||||
|
|
||||||
|
// Reminder dialog
|
||||||
|
const [showReminderDialog, setShowReminderDialog] = useState(false)
|
||||||
|
const [reminderDate, setReminderDate] = useState('')
|
||||||
|
const [reminderTime, setReminderTime] = useState('')
|
||||||
|
const [currentReminder, setCurrentReminder] = useState<Date | null>(null)
|
||||||
|
|
||||||
|
// Save to history after 1 second of inactivity
|
||||||
|
useEffect(() => {
|
||||||
|
if (isUndoingRef.current) {
|
||||||
|
isUndoingRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const currentState = { title, content }
|
||||||
|
const lastState = history[historyIndex]
|
||||||
|
|
||||||
|
if (lastState.title !== title || lastState.content !== content) {
|
||||||
|
const newHistory = history.slice(0, historyIndex + 1)
|
||||||
|
newHistory.push(currentState)
|
||||||
|
|
||||||
|
if (newHistory.length > 50) {
|
||||||
|
newHistory.shift()
|
||||||
|
} else {
|
||||||
|
setHistoryIndex(historyIndex + 1)
|
||||||
|
}
|
||||||
|
setHistory(newHistory)
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [title, content, history, historyIndex])
|
||||||
|
|
||||||
|
// Undo/Redo functions
|
||||||
|
const handleUndo = () => {
|
||||||
|
if (historyIndex > 0) {
|
||||||
|
isUndoingRef.current = true
|
||||||
|
const newIndex = historyIndex - 1
|
||||||
|
setHistoryIndex(newIndex)
|
||||||
|
setTitle(history[newIndex].title)
|
||||||
|
setContent(history[newIndex].content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRedo = () => {
|
||||||
|
if (historyIndex < history.length - 1) {
|
||||||
|
isUndoingRef.current = true
|
||||||
|
const newIndex = historyIndex + 1
|
||||||
|
setHistoryIndex(newIndex)
|
||||||
|
setTitle(history[newIndex].title)
|
||||||
|
setContent(history[newIndex].content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (!isExpanded) return
|
||||||
|
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleUndo()
|
||||||
|
}
|
||||||
|
if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleRedo()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [isExpanded, historyIndex, history])
|
||||||
|
|
||||||
|
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files
|
||||||
|
if (!files) return
|
||||||
|
|
||||||
|
// Validate file types
|
||||||
|
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
|
||||||
|
const maxSize = 5 * 1024 * 1024 // 5MB
|
||||||
|
|
||||||
|
Array.from(files).forEach(file => {
|
||||||
|
// Validation
|
||||||
|
if (!validTypes.includes(file.type)) {
|
||||||
|
addToast(`Invalid file type: ${file.name}. Only JPEG, PNG, GIF, and WebP allowed.`, 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
addToast(`File too large: ${file.name}. Maximum size is 5MB.`, 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onloadend = () => {
|
||||||
|
setImages([...images, reader.result as string])
|
||||||
|
}
|
||||||
|
reader.onerror = () => {
|
||||||
|
addToast(`Failed to read file: ${file.name}`, 'error')
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reset input
|
||||||
|
e.target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReminderOpen = () => {
|
||||||
|
const tomorrow = new Date(Date.now() + 86400000)
|
||||||
|
setReminderDate(tomorrow.toISOString().split('T')[0])
|
||||||
|
setReminderTime('09:00')
|
||||||
|
setShowReminderDialog(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReminderSave = () => {
|
||||||
|
if (!reminderDate || !reminderTime) {
|
||||||
|
addToast('Please enter date and time', 'warning')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateTimeString = `${reminderDate}T${reminderTime}`
|
||||||
|
const date = new Date(dateTimeString)
|
||||||
|
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
addToast('Invalid date or time', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date < new Date()) {
|
||||||
|
addToast('Reminder must be in the future', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentReminder(date)
|
||||||
|
addToast(`Reminder set for ${date.toLocaleString()}`, 'success')
|
||||||
|
setShowReminderDialog(false)
|
||||||
|
setReminderDate('')
|
||||||
|
setReminderTime('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
// Validation
|
||||||
|
if (type === 'text' && !content.trim()) {
|
||||||
|
addToast('Please enter some content', 'warning')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (type === 'checklist' && checkItems.length === 0) {
|
||||||
|
addToast('Please add at least one item', 'warning')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (type === 'checklist' && checkItems.every(item => !item.text.trim())) {
|
||||||
|
addToast('Checklist items cannot be empty', 'warning')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true)
|
||||||
|
try {
|
||||||
|
await createNote({
|
||||||
|
title: title.trim() || undefined,
|
||||||
|
content: type === 'text' ? content : '',
|
||||||
|
type,
|
||||||
|
checkItems: type === 'checklist' ? checkItems : undefined,
|
||||||
|
color,
|
||||||
|
isArchived,
|
||||||
|
images: images.length > 0 ? images : undefined,
|
||||||
|
reminder: currentReminder,
|
||||||
|
isMarkdown,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
setTitle('')
|
||||||
|
setContent('')
|
||||||
|
setCheckItems([])
|
||||||
|
setImages([])
|
||||||
|
setIsMarkdown(false)
|
||||||
|
setShowMarkdownPreview(false)
|
||||||
|
setHistory([{ title: '', content: '' }])
|
||||||
|
setHistoryIndex(0)
|
||||||
|
setIsExpanded(false)
|
||||||
|
setType('text')
|
||||||
|
setColor('default')
|
||||||
|
setIsArchived(false)
|
||||||
|
setCurrentReminder(null)
|
||||||
|
|
||||||
|
addToast('Note created successfully', 'success')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create note:', error)
|
||||||
|
addToast('Failed to create note', 'error')
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddCheckItem = () => {
|
||||||
|
setCheckItems([
|
||||||
|
...checkItems,
|
||||||
|
{ id: Date.now().toString(), text: '', checked: false },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
const handleUpdateCheckItem = (id: string, text: string) => {
|
||||||
|
setCheckItems(
|
||||||
|
checkItems.map(item => (item.id === id ? { ...item, text } : item))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveCheckItem = (id: string) => {
|
||||||
|
setCheckItems(checkItems.filter(item => item.id !== id))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setIsExpanded(false)
|
||||||
|
setTitle('')
|
||||||
|
setContent('')
|
||||||
|
setCheckItems([])
|
||||||
|
setImages([])
|
||||||
|
setHistory([{ title: '', content: '' }])
|
||||||
|
setHistoryIndex(0)
|
||||||
|
setType('text')
|
||||||
|
setColor('default')
|
||||||
|
setIsArchived(false)
|
||||||
|
setCurrentReminder(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isExpanded) {
|
||||||
|
return (
|
||||||
|
<Card className="p-4 max-w-2xl mx-auto mb-8 cursor-text shadow-md hover:shadow-lg transition-shadow">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Input
|
||||||
|
placeholder="Take a note..."
|
||||||
|
onClick={() => setIsExpanded(true)}
|
||||||
|
readOnly
|
||||||
|
value=""
|
||||||
|
className="border-0 focus-visible:ring-0 cursor-text"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setType('checklist')
|
||||||
|
setIsExpanded(true)
|
||||||
|
}}
|
||||||
|
title="New checklist"
|
||||||
|
>
|
||||||
|
<CheckSquare className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorClasses = NOTE_COLORS[color] || NOTE_COLORS.default
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className={cn(
|
||||||
|
"p-4 max-w-2xl mx-auto mb-8 shadow-lg border",
|
||||||
|
colorClasses.card
|
||||||
|
)}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Input
|
||||||
|
placeholder="Title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
className="border-0 focus-visible:ring-0 text-base font-semibold"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Image Preview */}
|
||||||
|
{images.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{images.map((img, idx) => (
|
||||||
|
<div key={idx} className="relative group">
|
||||||
|
<img
|
||||||
|
src={img}
|
||||||
|
alt={`Upload ${idx + 1}`}
|
||||||
|
className="max-w-full h-auto max-h-96 object-contain rounded-lg"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="absolute top-2 right-2 h-7 w-7 p-0 bg-white/90 hover:bg-white opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
onClick={() => setImages(images.filter((_, i) => i !== idx))}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{type === 'text' ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Markdown toggle button */}
|
||||||
|
{isMarkdown && (
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowMarkdownPreview(!showMarkdownPreview)}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
{showMarkdownPreview ? (
|
||||||
|
<>
|
||||||
|
<FileText className="h-3 w-3 mr-1" />
|
||||||
|
Edit
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Eye className="h-3 w-3 mr-1" />
|
||||||
|
Preview
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showMarkdownPreview && isMarkdown ? (
|
||||||
|
<MarkdownContent
|
||||||
|
content={content || '*No content*'}
|
||||||
|
className="min-h-[100px] p-3 rounded-md border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Textarea
|
||||||
|
placeholder={isMarkdown ? "Take a note... (Markdown supported)" : "Take a note..."}
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
className="border-0 focus-visible:ring-0 min-h-[100px] resize-none"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{checkItems.map((item) => (
|
||||||
|
<div key={item.id} className="flex items-start gap-2 group">
|
||||||
|
<Checkbox className="mt-2" />
|
||||||
|
<Input
|
||||||
|
value={item.text}
|
||||||
|
onChange={(e) => handleUpdateCheckItem(item.id, e.target.value)}
|
||||||
|
placeholder="List item"
|
||||||
|
className="flex-1 border-0 focus-visible:ring-0"
|
||||||
|
autoFocus={checkItems[checkItems.length - 1].id === item.id}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="opacity-0 group-hover:opacity-100 h-8 w-8 p-0"
|
||||||
|
onClick={() => handleRemoveCheckItem(item.id)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAddCheckItem}
|
||||||
|
className="text-gray-600 dark:text-gray-400 w-full justify-start"
|
||||||
|
>
|
||||||
|
+ List item
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-2">
|
||||||
|
<TooltipProvider>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-8",
|
||||||
|
currentReminder && "text-blue-600"
|
||||||
|
)}
|
||||||
|
title="Remind me"
|
||||||
|
onClick={handleReminderOpen}
|
||||||
|
>
|
||||||
|
<Bell className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Remind me</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-8",
|
||||||
|
isMarkdown && "text-blue-600"
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setIsMarkdown(!isMarkdown)
|
||||||
|
if (isMarkdown) setShowMarkdownPreview(false)
|
||||||
|
}}
|
||||||
|
title="Markdown"
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Markdown</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
title="Add image"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<Image className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Add image</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" title="Collaborator">
|
||||||
|
<UserPlus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Collaborator</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" title="Background options">
|
||||||
|
<Palette className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Background options</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<DropdownMenuContent align="start" className="w-40">
|
||||||
|
<div className="grid grid-cols-5 gap-2 p-2">
|
||||||
|
{Object.entries(NOTE_COLORS).map(([colorName, colorClass]) => (
|
||||||
|
<button
|
||||||
|
key={colorName}
|
||||||
|
onClick={() => setColor(colorName as NoteColor)}
|
||||||
|
className={cn(
|
||||||
|
'w-7 h-7 rounded-full border-2 hover:scale-110 transition-transform',
|
||||||
|
colorClass.bg,
|
||||||
|
color === colorName ? 'border-gray-900 dark:border-gray-100' : 'border-transparent'
|
||||||
|
)}
|
||||||
|
title={colorName}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-8",
|
||||||
|
isArchived && "text-yellow-600"
|
||||||
|
)}
|
||||||
|
onClick={() => setIsArchived(!isArchived)}
|
||||||
|
title="Archive"
|
||||||
|
>
|
||||||
|
<Archive className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{isArchived ? 'Unarchive' : 'Archive'}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" title="More">
|
||||||
|
<MoreVertical className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>More</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={handleUndo}
|
||||||
|
disabled={historyIndex === 0}
|
||||||
|
>
|
||||||
|
<Undo2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Undo (Ctrl+Z)</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={handleRedo}
|
||||||
|
disabled={historyIndex >= history.length - 1}
|
||||||
|
>
|
||||||
|
<Redo2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Redo (Ctrl+Y)</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Adding...' : 'Add'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleClose}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleImageUpload}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={showReminderDialog} onOpenChange={setShowReminderDialog}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Set Reminder</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="reminder-date" className="text-sm font-medium">
|
||||||
|
Date
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="reminder-date"
|
||||||
|
type="date"
|
||||||
|
value={reminderDate}
|
||||||
|
onChange={(e) => setReminderDate(e.target.value)}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="reminder-time" className="text-sm font-medium">
|
||||||
|
Time
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="reminder-time"
|
||||||
|
type="time"
|
||||||
|
value={reminderTime}
|
||||||
|
onChange={(e) => setReminderTime(e.target.value)}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => setShowReminderDialog(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleReminderSave}>
|
||||||
|
Set Reminder
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
46
keep-notes/components/ui/badge.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span"> &
|
||||||
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : "span"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="badge"
|
||||||
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
62
keep-notes/components/ui/button.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
"icon-sm": "size-8",
|
||||||
|
"icon-lg": "size-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
92
keep-notes/components/ui/card.tsx
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
32
keep-notes/components/ui/checkbox.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||||
|
import { CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Checkbox({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
data-slot="checkbox"
|
||||||
|
className={cn(
|
||||||
|
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
className="grid place-content-center text-current transition-none"
|
||||||
|
>
|
||||||
|
<CheckIcon className="size-3.5" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
143
keep-notes/components/ui/dialog.tsx
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal data-slot="dialog-portal">
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
257
keep-notes/components/ui/dropdown-menu.tsx
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function DropdownMenu({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
|
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Trigger
|
||||||
|
data-slot="dropdown-menu-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
variant?: "default" | "destructive"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioGroup
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="size-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||||
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto size-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
}
|
||||||
21
keep-notes/components/ui/input.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
48
keep-notes/components/ui/popover.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Popover({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||||
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||||
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
data-slot="popover-content"
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverAnchor({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||||
|
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||||
28
keep-notes/components/ui/separator.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
decorative = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
data-slot="separator"
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
18
keep-notes/components/ui/textarea.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
85
keep-notes/components/ui/toast.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export interface ToastProps {
|
||||||
|
id: string
|
||||||
|
message: string
|
||||||
|
type?: 'success' | 'error' | 'info' | 'warning'
|
||||||
|
duration?: number
|
||||||
|
onClose: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Toast({ id, message, type = 'info', duration = 3000, onClose }: ToastProps) {
|
||||||
|
React.useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
onClose(id)
|
||||||
|
}, duration)
|
||||||
|
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [id, duration, onClose])
|
||||||
|
|
||||||
|
const bgColors = {
|
||||||
|
success: 'bg-green-600',
|
||||||
|
error: 'bg-red-600',
|
||||||
|
info: 'bg-blue-600',
|
||||||
|
warning: 'bg-yellow-600'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 rounded-lg px-4 py-3 text-sm text-white shadow-lg animate-in slide-in-from-top-5",
|
||||||
|
bgColors[type]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex-1">{message}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => onClose(id)}
|
||||||
|
className="rounded-full p-1 hover:bg-white/20 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToastContextType {
|
||||||
|
addToast: (message: string, type?: 'success' | 'error' | 'info' | 'warning') => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastContext = React.createContext<ToastContextType | null>(null)
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [toasts, setToasts] = React.useState<Array<Omit<ToastProps, 'onClose'>>>([])
|
||||||
|
|
||||||
|
const addToast = React.useCallback((message: string, type: 'success' | 'error' | 'info' | 'warning' = 'info') => {
|
||||||
|
const id = Math.random().toString(36).substring(7)
|
||||||
|
setToasts(prev => [...prev, { id, message, type }])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const removeToast = React.useCallback((id: string) => {
|
||||||
|
setToasts(prev => prev.filter(toast => toast.id !== id))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={{ addToast }}>
|
||||||
|
{children}
|
||||||
|
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 w-80">
|
||||||
|
{toasts.map(toast => (
|
||||||
|
<Toast key={toast.id} {...toast} onClose={removeToast} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ToastContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
const context = React.useContext(ToastContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useToast must be used within ToastProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
61
keep-notes/components/ui/tooltip.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function TooltipProvider({
|
||||||
|
delayDuration = 0,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Provider
|
||||||
|
data-slot="tooltip-provider"
|
||||||
|
delayDuration={delayDuration}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||||
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 0,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||||
|
</TooltipPrimitive.Content>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
BIN
keep-notes/dev.db
Normal file
115
keep-notes/hooks/useUndoRedo.ts
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import { useState, useCallback, useRef } from 'react'
|
||||||
|
|
||||||
|
export interface UndoRedoState<T> {
|
||||||
|
past: T[]
|
||||||
|
present: T
|
||||||
|
future: T[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseUndoRedoReturn<T> {
|
||||||
|
state: T
|
||||||
|
setState: (newState: T | ((prev: T) => T)) => void
|
||||||
|
undo: () => void
|
||||||
|
redo: () => void
|
||||||
|
canUndo: boolean
|
||||||
|
canRedo: boolean
|
||||||
|
clear: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_HISTORY_SIZE = 50
|
||||||
|
|
||||||
|
export function useUndoRedo<T>(initialState: T): UseUndoRedoReturn<T> {
|
||||||
|
const [history, setHistory] = useState<UndoRedoState<T>>({
|
||||||
|
past: [],
|
||||||
|
present: initialState,
|
||||||
|
future: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Track if we're in an undo/redo operation to prevent adding to history
|
||||||
|
const isUndoRedoAction = useRef(false)
|
||||||
|
|
||||||
|
const setState = useCallback((newState: T | ((prev: T) => T)) => {
|
||||||
|
// Skip if this is an undo/redo action
|
||||||
|
if (isUndoRedoAction.current) {
|
||||||
|
isUndoRedoAction.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setHistory((currentHistory) => {
|
||||||
|
const resolvedNewState =
|
||||||
|
typeof newState === 'function'
|
||||||
|
? (newState as (prev: T) => T)(currentHistory.present)
|
||||||
|
: newState
|
||||||
|
|
||||||
|
// Don't add to history if state hasn't changed
|
||||||
|
if (JSON.stringify(resolvedNewState) === JSON.stringify(currentHistory.present)) {
|
||||||
|
return currentHistory
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPast = [...currentHistory.past, currentHistory.present]
|
||||||
|
|
||||||
|
// Limit history size
|
||||||
|
if (newPast.length > MAX_HISTORY_SIZE) {
|
||||||
|
newPast.shift()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
past: newPast,
|
||||||
|
present: resolvedNewState,
|
||||||
|
future: [], // Clear future on new action
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const undo = useCallback(() => {
|
||||||
|
setHistory((currentHistory) => {
|
||||||
|
if (currentHistory.past.length === 0) return currentHistory
|
||||||
|
|
||||||
|
const previous = currentHistory.past[currentHistory.past.length - 1]
|
||||||
|
const newPast = currentHistory.past.slice(0, currentHistory.past.length - 1)
|
||||||
|
|
||||||
|
isUndoRedoAction.current = true
|
||||||
|
|
||||||
|
return {
|
||||||
|
past: newPast,
|
||||||
|
present: previous,
|
||||||
|
future: [currentHistory.present, ...currentHistory.future],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const redo = useCallback(() => {
|
||||||
|
setHistory((currentHistory) => {
|
||||||
|
if (currentHistory.future.length === 0) return currentHistory
|
||||||
|
|
||||||
|
const next = currentHistory.future[0]
|
||||||
|
const newFuture = currentHistory.future.slice(1)
|
||||||
|
|
||||||
|
isUndoRedoAction.current = true
|
||||||
|
|
||||||
|
return {
|
||||||
|
past: [...currentHistory.past, currentHistory.present],
|
||||||
|
present: next,
|
||||||
|
future: newFuture,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const clear = useCallback(() => {
|
||||||
|
setHistory({
|
||||||
|
past: [],
|
||||||
|
present: initialState,
|
||||||
|
future: [],
|
||||||
|
})
|
||||||
|
}, [initialState])
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: history.present,
|
||||||
|
setState,
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
|
canUndo: history.past.length > 0,
|
||||||
|
canRedo: history.future.length > 0,
|
||||||
|
clear,
|
||||||
|
}
|
||||||
|
}
|
||||||
57
keep-notes/lib/label-storage.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { LabelColorName } from './types'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'memento-label-colors'
|
||||||
|
|
||||||
|
// Store label colors in localStorage
|
||||||
|
export function getLabelColor(label: string): LabelColorName {
|
||||||
|
if (typeof window === 'undefined') return 'gray'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!stored) return 'gray'
|
||||||
|
|
||||||
|
const colors = JSON.parse(stored) as Record<string, LabelColorName>
|
||||||
|
return colors[label] || 'gray'
|
||||||
|
} catch {
|
||||||
|
return 'gray'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLabelColor(label: string, color: LabelColorName) {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY)
|
||||||
|
const colors = stored ? JSON.parse(stored) as Record<string, LabelColorName> : {}
|
||||||
|
colors[label] = color
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(colors))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save label color:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllLabelColors(): Record<string, LabelColorName> {
|
||||||
|
if (typeof window === 'undefined') return {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY)
|
||||||
|
return stored ? JSON.parse(stored) : {}
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteLabelColor(label: string) {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!stored) return
|
||||||
|
|
||||||
|
const colors = JSON.parse(stored) as Record<string, LabelColorName>
|
||||||
|
delete colors[label]
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(colors))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete label color:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
15
keep-notes/lib/prisma.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
const prismaClientSingleton = () => {
|
||||||
|
return new PrismaClient()
|
||||||
|
}
|
||||||
|
|
||||||
|
declare const globalThis: {
|
||||||
|
prismaGlobal: ReturnType<typeof prismaClientSingleton>;
|
||||||
|
} & typeof global;
|
||||||
|
|
||||||
|
const prisma = globalThis.prismaGlobal ?? prismaClientSingleton()
|
||||||
|
|
||||||
|
export default prisma
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') globalThis.prismaGlobal = prisma
|
||||||
99
keep-notes/lib/types.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
export interface CheckItem {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
checked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Note {
|
||||||
|
id: string;
|
||||||
|
title: string | null;
|
||||||
|
content: string;
|
||||||
|
color: string;
|
||||||
|
isPinned: boolean;
|
||||||
|
isArchived: boolean;
|
||||||
|
type: 'text' | 'checklist';
|
||||||
|
checkItems: CheckItem[] | null;
|
||||||
|
labels: string[] | null;
|
||||||
|
images: string[] | null;
|
||||||
|
reminder: Date | null;
|
||||||
|
reminderRecurrence: string | null;
|
||||||
|
reminderLocation: string | null;
|
||||||
|
isMarkdown: boolean;
|
||||||
|
order: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LabelWithColor {
|
||||||
|
name: string;
|
||||||
|
color: LabelColorName;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LABEL_COLORS = {
|
||||||
|
gray: { bg: 'bg-gray-100 dark:bg-gray-800', text: 'text-gray-700 dark:text-gray-300', border: 'border-gray-300 dark:border-gray-600' },
|
||||||
|
red: { bg: 'bg-red-100 dark:bg-red-900/30', text: 'text-red-700 dark:text-red-300', border: 'border-red-300 dark:border-red-600' },
|
||||||
|
orange: { bg: 'bg-orange-100 dark:bg-orange-900/30', text: 'text-orange-700 dark:text-orange-300', border: 'border-orange-300 dark:border-orange-600' },
|
||||||
|
yellow: { bg: 'bg-yellow-100 dark:bg-yellow-900/30', text: 'text-yellow-700 dark:text-yellow-300', border: 'border-yellow-300 dark:border-yellow-600' },
|
||||||
|
green: { bg: 'bg-green-100 dark:bg-green-900/30', text: 'text-green-700 dark:text-green-300', border: 'border-green-300 dark:border-green-600' },
|
||||||
|
teal: { bg: 'bg-teal-100 dark:bg-teal-900/30', text: 'text-teal-700 dark:text-teal-300', border: 'border-teal-300 dark:border-teal-600' },
|
||||||
|
blue: { bg: 'bg-blue-100 dark:bg-blue-900/30', text: 'text-blue-700 dark:text-blue-300', border: 'border-blue-300 dark:border-blue-600' },
|
||||||
|
purple: { bg: 'bg-purple-100 dark:bg-purple-900/30', text: 'text-purple-700 dark:text-purple-300', border: 'border-purple-300 dark:border-purple-600' },
|
||||||
|
pink: { bg: 'bg-pink-100 dark:bg-pink-900/30', text: 'text-pink-700 dark:text-pink-300', border: 'border-pink-300 dark:border-pink-600' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LabelColorName = keyof typeof LABEL_COLORS
|
||||||
|
|
||||||
|
export const NOTE_COLORS = {
|
||||||
|
default: {
|
||||||
|
bg: 'bg-white dark:bg-zinc-900',
|
||||||
|
hover: 'hover:bg-gray-50 dark:hover:bg-zinc-800',
|
||||||
|
card: 'bg-white dark:bg-zinc-900 border-gray-200 dark:border-zinc-700'
|
||||||
|
},
|
||||||
|
red: {
|
||||||
|
bg: 'bg-red-50 dark:bg-red-950/30',
|
||||||
|
hover: 'hover:bg-red-100 dark:hover:bg-red-950/50',
|
||||||
|
card: 'bg-red-50 dark:bg-red-950/30 border-red-100 dark:border-red-900/50'
|
||||||
|
},
|
||||||
|
orange: {
|
||||||
|
bg: 'bg-orange-50 dark:bg-orange-950/30',
|
||||||
|
hover: 'hover:bg-orange-100 dark:hover:bg-orange-950/50',
|
||||||
|
card: 'bg-orange-50 dark:bg-orange-950/30 border-orange-100 dark:border-orange-900/50'
|
||||||
|
},
|
||||||
|
yellow: {
|
||||||
|
bg: 'bg-yellow-50 dark:bg-yellow-950/30',
|
||||||
|
hover: 'hover:bg-yellow-100 dark:hover:bg-yellow-950/50',
|
||||||
|
card: 'bg-yellow-50 dark:bg-yellow-950/30 border-yellow-100 dark:border-yellow-900/50'
|
||||||
|
},
|
||||||
|
green: {
|
||||||
|
bg: 'bg-green-50 dark:bg-green-950/30',
|
||||||
|
hover: 'hover:bg-green-100 dark:hover:bg-green-950/50',
|
||||||
|
card: 'bg-green-50 dark:bg-green-950/30 border-green-100 dark:border-green-900/50'
|
||||||
|
},
|
||||||
|
teal: {
|
||||||
|
bg: 'bg-teal-50 dark:bg-teal-950/30',
|
||||||
|
hover: 'hover:bg-teal-100 dark:hover:bg-teal-950/50',
|
||||||
|
card: 'bg-teal-50 dark:bg-teal-950/30 border-teal-100 dark:border-teal-900/50'
|
||||||
|
},
|
||||||
|
blue: {
|
||||||
|
bg: 'bg-blue-50 dark:bg-blue-950/30',
|
||||||
|
hover: 'hover:bg-blue-100 dark:hover:bg-blue-950/50',
|
||||||
|
card: 'bg-blue-50 dark:bg-blue-950/30 border-blue-100 dark:border-blue-900/50'
|
||||||
|
},
|
||||||
|
purple: {
|
||||||
|
bg: 'bg-purple-50 dark:bg-purple-950/30',
|
||||||
|
hover: 'hover:bg-purple-100 dark:hover:bg-purple-950/50',
|
||||||
|
card: 'bg-purple-50 dark:bg-purple-950/30 border-purple-100 dark:border-purple-900/50'
|
||||||
|
},
|
||||||
|
pink: {
|
||||||
|
bg: 'bg-pink-50 dark:bg-pink-950/30',
|
||||||
|
hover: 'hover:bg-pink-100 dark:hover:bg-pink-950/50',
|
||||||
|
card: 'bg-pink-50 dark:bg-pink-950/30 border-pink-100 dark:border-pink-900/50'
|
||||||
|
},
|
||||||
|
gray: {
|
||||||
|
bg: 'bg-gray-100 dark:bg-gray-800/50',
|
||||||
|
hover: 'hover:bg-gray-200 dark:hover:bg-gray-700/50',
|
||||||
|
card: 'bg-gray-100 dark:bg-gray-800/50 border-gray-200 dark:border-gray-700'
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type NoteColor = keyof typeof NOTE_COLORS;
|
||||||
6
keep-notes/lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
7
keep-notes/next.config.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
5788
keep-notes/package-lock.json
generated
Normal file
57
keep-notes/package.json
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"name": "memento",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"test": "playwright test",
|
||||||
|
"test:ui": "playwright test --ui",
|
||||||
|
"test:headed": "playwright test --headed"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@auth/prisma-adapter": "^2.11.1",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@libsql/client": "^0.15.15",
|
||||||
|
"@prisma/adapter-better-sqlite3": "^7.2.0",
|
||||||
|
"@prisma/adapter-libsql": "^7.2.0",
|
||||||
|
"@prisma/client": "5.22.0",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"better-sqlite3": "^12.5.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"lucide-react": "^0.562.0",
|
||||||
|
"next": "16.1.1",
|
||||||
|
"next-auth": "^5.0.0-beta.30",
|
||||||
|
"prisma": "5.22.0",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3",
|
||||||
|
"react-grid-layout": "^2.2.2",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-masonry-css": "^1.0.16",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"tailwind-merge": "^3.4.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.57.0",
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,478 @@
|
|||||||
|
# Page snapshot
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- generic [active] [ref=e1]:
|
||||||
|
- banner [ref=e2]:
|
||||||
|
- generic [ref=e3]:
|
||||||
|
- link "Memento" [ref=e4] [cursor=pointer]:
|
||||||
|
- /url: /
|
||||||
|
- img [ref=e5]
|
||||||
|
- generic [ref=e8]: Memento
|
||||||
|
- generic [ref=e10]:
|
||||||
|
- img [ref=e11]
|
||||||
|
- textbox "Search notes..." [ref=e14]
|
||||||
|
- button [ref=e15]:
|
||||||
|
- img
|
||||||
|
- navigation [ref=e16]:
|
||||||
|
- link "Notes" [ref=e17] [cursor=pointer]:
|
||||||
|
- /url: /
|
||||||
|
- img [ref=e18]
|
||||||
|
- text: Notes
|
||||||
|
- link "Archive" [ref=e21] [cursor=pointer]:
|
||||||
|
- /url: /archive
|
||||||
|
- img [ref=e22]
|
||||||
|
- text: Archive
|
||||||
|
- main [ref=e25]:
|
||||||
|
- generic [ref=e27]:
|
||||||
|
- textbox "Take a note..." [ref=e28]
|
||||||
|
- button "New checklist" [ref=e29]:
|
||||||
|
- img
|
||||||
|
- generic [ref=e30]:
|
||||||
|
- generic [ref=e31]:
|
||||||
|
- heading "Pinned" [level=2] [ref=e32]
|
||||||
|
- button "Updated Note avec image il y a environ 8 heures" [ref=e34]:
|
||||||
|
- generic [ref=e35]:
|
||||||
|
- img [ref=e36]
|
||||||
|
- heading "Updated" [level=3] [ref=e38]
|
||||||
|
- paragraph [ref=e39]: Note avec image
|
||||||
|
- generic [ref=e40]: il y a environ 8 heures
|
||||||
|
- generic [ref=e41]:
|
||||||
|
- button "Unpin" [ref=e42]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e43]:
|
||||||
|
- img
|
||||||
|
- button [ref=e44]:
|
||||||
|
- img
|
||||||
|
- generic [ref=e45]:
|
||||||
|
- heading "Others" [level=2] [ref=e46]
|
||||||
|
- generic [ref=e47]:
|
||||||
|
- button "test-1767557334587-Note 4 test-1767557334587-Content 4 il y a moins d’une minute" [ref=e48]:
|
||||||
|
- generic [ref=e49]:
|
||||||
|
- heading "test-1767557334587-Note 4" [level=3] [ref=e50]
|
||||||
|
- paragraph [ref=e51]: test-1767557334587-Content 4
|
||||||
|
- generic [ref=e52]: il y a moins d’une minute
|
||||||
|
- generic [ref=e53]:
|
||||||
|
- button "Pin" [ref=e54]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e55]:
|
||||||
|
- img
|
||||||
|
- button [ref=e56]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557334587-Note 3 test-1767557334587-Content 3 il y a moins d’une minute" [ref=e57]:
|
||||||
|
- generic [ref=e58]:
|
||||||
|
- heading "test-1767557334587-Note 3" [level=3] [ref=e59]
|
||||||
|
- paragraph [ref=e60]: test-1767557334587-Content 3
|
||||||
|
- generic [ref=e61]: il y a moins d’une minute
|
||||||
|
- generic [ref=e62]:
|
||||||
|
- button "Pin" [ref=e63]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e64]:
|
||||||
|
- img
|
||||||
|
- button [ref=e65]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557334587-Note 2 test-1767557334587-Content 2 il y a moins d’une minute" [ref=e66]:
|
||||||
|
- generic [ref=e67]:
|
||||||
|
- heading "test-1767557334587-Note 2" [level=3] [ref=e68]
|
||||||
|
- paragraph [ref=e69]: test-1767557334587-Content 2
|
||||||
|
- generic [ref=e70]: il y a moins d’une minute
|
||||||
|
- generic [ref=e71]:
|
||||||
|
- button "Pin" [ref=e72]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e73]:
|
||||||
|
- img
|
||||||
|
- button [ref=e74]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557334587-Note 1 test-1767557334587-Content 1 il y a moins d’une minute" [ref=e75]:
|
||||||
|
- generic [ref=e76]:
|
||||||
|
- heading "test-1767557334587-Note 1" [level=3] [ref=e77]
|
||||||
|
- paragraph [ref=e78]: test-1767557334587-Content 1
|
||||||
|
- generic [ref=e79]: il y a moins d’une minute
|
||||||
|
- generic [ref=e80]:
|
||||||
|
- button "Pin" [ref=e81]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e82]:
|
||||||
|
- img
|
||||||
|
- button [ref=e83]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557330820-Note 4 test-1767557330820-Content 4 il y a moins d’une minute" [ref=e84]:
|
||||||
|
- generic [ref=e85]:
|
||||||
|
- heading "test-1767557330820-Note 4" [level=3] [ref=e86]
|
||||||
|
- paragraph [ref=e87]: test-1767557330820-Content 4
|
||||||
|
- generic [ref=e88]: il y a moins d’une minute
|
||||||
|
- generic [ref=e89]:
|
||||||
|
- button "Pin" [ref=e90]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e91]:
|
||||||
|
- img
|
||||||
|
- button [ref=e92]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557330820-Note 3 test-1767557330820-Content 3 il y a moins d’une minute" [ref=e93]:
|
||||||
|
- generic [ref=e94]:
|
||||||
|
- heading "test-1767557330820-Note 3" [level=3] [ref=e95]
|
||||||
|
- paragraph [ref=e96]: test-1767557330820-Content 3
|
||||||
|
- generic [ref=e97]: il y a moins d’une minute
|
||||||
|
- generic [ref=e98]:
|
||||||
|
- button "Pin" [ref=e99]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e100]:
|
||||||
|
- img
|
||||||
|
- button [ref=e101]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557330820-Note 2 test-1767557330820-Content 2 il y a moins d’une minute" [ref=e102]:
|
||||||
|
- generic [ref=e103]:
|
||||||
|
- heading "test-1767557330820-Note 2" [level=3] [ref=e104]
|
||||||
|
- paragraph [ref=e105]: test-1767557330820-Content 2
|
||||||
|
- generic [ref=e106]: il y a moins d’une minute
|
||||||
|
- generic [ref=e107]:
|
||||||
|
- button "Pin" [ref=e108]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e109]:
|
||||||
|
- img
|
||||||
|
- button [ref=e110]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557330820-Note 1 test-1767557330820-Content 1 il y a moins d’une minute" [ref=e111]:
|
||||||
|
- generic [ref=e112]:
|
||||||
|
- heading "test-1767557330820-Note 1" [level=3] [ref=e113]
|
||||||
|
- paragraph [ref=e114]: test-1767557330820-Content 1
|
||||||
|
- generic [ref=e115]: il y a moins d’une minute
|
||||||
|
- generic [ref=e116]:
|
||||||
|
- button "Pin" [ref=e117]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e118]:
|
||||||
|
- img
|
||||||
|
- button [ref=e119]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557327567-Note 4 test-1767557327567-Content 4 il y a moins d’une minute" [ref=e120]:
|
||||||
|
- generic [ref=e121]:
|
||||||
|
- heading "test-1767557327567-Note 4" [level=3] [ref=e122]
|
||||||
|
- paragraph [ref=e123]: test-1767557327567-Content 4
|
||||||
|
- generic [ref=e124]: il y a moins d’une minute
|
||||||
|
- generic [ref=e125]:
|
||||||
|
- button "Pin" [ref=e126]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e127]:
|
||||||
|
- img
|
||||||
|
- button [ref=e128]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557327567-Note 3 test-1767557327567-Content 3 il y a moins d’une minute" [ref=e129]:
|
||||||
|
- generic [ref=e130]:
|
||||||
|
- heading "test-1767557327567-Note 3" [level=3] [ref=e131]
|
||||||
|
- paragraph [ref=e132]: test-1767557327567-Content 3
|
||||||
|
- generic [ref=e133]: il y a moins d’une minute
|
||||||
|
- generic [ref=e134]:
|
||||||
|
- button "Pin" [ref=e135]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e136]:
|
||||||
|
- img
|
||||||
|
- button [ref=e137]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557327567-Note 2 test-1767557327567-Content 2 il y a moins d’une minute" [ref=e138]:
|
||||||
|
- generic [ref=e139]:
|
||||||
|
- heading "test-1767557327567-Note 2" [level=3] [ref=e140]
|
||||||
|
- paragraph [ref=e141]: test-1767557327567-Content 2
|
||||||
|
- generic [ref=e142]: il y a moins d’une minute
|
||||||
|
- generic [ref=e143]:
|
||||||
|
- button "Pin" [ref=e144]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e145]:
|
||||||
|
- img
|
||||||
|
- button [ref=e146]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557327567-Note 1 test-1767557327567-Content 1 il y a moins d’une minute" [ref=e147]:
|
||||||
|
- generic [ref=e148]:
|
||||||
|
- heading "test-1767557327567-Note 1" [level=3] [ref=e149]
|
||||||
|
- paragraph [ref=e150]: test-1767557327567-Content 1
|
||||||
|
- generic [ref=e151]: il y a moins d’une minute
|
||||||
|
- generic [ref=e152]:
|
||||||
|
- button "Pin" [ref=e153]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e154]:
|
||||||
|
- img
|
||||||
|
- button [ref=e155]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557324248-Note 4 test-1767557324248-Content 4 il y a moins d’une minute" [ref=e156]:
|
||||||
|
- generic [ref=e157]:
|
||||||
|
- heading "test-1767557324248-Note 4" [level=3] [ref=e158]
|
||||||
|
- paragraph [ref=e159]: test-1767557324248-Content 4
|
||||||
|
- generic [ref=e160]: il y a moins d’une minute
|
||||||
|
- generic [ref=e161]:
|
||||||
|
- button "Pin" [ref=e162]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e163]:
|
||||||
|
- img
|
||||||
|
- button [ref=e164]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557324248-Note 3 test-1767557324248-Content 3 il y a moins d’une minute" [ref=e165]:
|
||||||
|
- generic [ref=e166]:
|
||||||
|
- heading "test-1767557324248-Note 3" [level=3] [ref=e167]
|
||||||
|
- paragraph [ref=e168]: test-1767557324248-Content 3
|
||||||
|
- generic [ref=e169]: il y a moins d’une minute
|
||||||
|
- generic [ref=e170]:
|
||||||
|
- button "Pin" [ref=e171]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e172]:
|
||||||
|
- img
|
||||||
|
- button [ref=e173]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557324248-Note 2 test-1767557324248-Content 2 il y a moins d’une minute" [ref=e174]:
|
||||||
|
- generic [ref=e175]:
|
||||||
|
- heading "test-1767557324248-Note 2" [level=3] [ref=e176]
|
||||||
|
- paragraph [ref=e177]: test-1767557324248-Content 2
|
||||||
|
- generic [ref=e178]: il y a moins d’une minute
|
||||||
|
- generic [ref=e179]:
|
||||||
|
- button "Pin" [ref=e180]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e181]:
|
||||||
|
- img
|
||||||
|
- button [ref=e182]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557324248-Note 1 test-1767557324248-Content 1 il y a moins d’une minute" [ref=e183]:
|
||||||
|
- generic [ref=e184]:
|
||||||
|
- heading "test-1767557324248-Note 1" [level=3] [ref=e185]
|
||||||
|
- paragraph [ref=e186]: test-1767557324248-Content 1
|
||||||
|
- generic [ref=e187]: il y a moins d’une minute
|
||||||
|
- generic [ref=e188]:
|
||||||
|
- button "Pin" [ref=e189]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e190]:
|
||||||
|
- img
|
||||||
|
- button [ref=e191]:
|
||||||
|
- img
|
||||||
|
- button "Test Note for Reminder This note will have a reminder il y a 26 minutes" [ref=e192]:
|
||||||
|
- generic [ref=e193]:
|
||||||
|
- heading "Test Note for Reminder" [level=3] [ref=e194]
|
||||||
|
- paragraph [ref=e195]: This note will have a reminder
|
||||||
|
- generic [ref=e196]: il y a 26 minutes
|
||||||
|
- generic [ref=e197]:
|
||||||
|
- button "Pin" [ref=e198]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e199]:
|
||||||
|
- img
|
||||||
|
- button [ref=e200]:
|
||||||
|
- img
|
||||||
|
- button "Test Note for Reminder This note will have a reminder il y a 26 minutes" [ref=e201]:
|
||||||
|
- generic [ref=e202]:
|
||||||
|
- heading "Test Note for Reminder" [level=3] [ref=e203]
|
||||||
|
- paragraph [ref=e204]: This note will have a reminder
|
||||||
|
- generic [ref=e205]: il y a 26 minutes
|
||||||
|
- generic [ref=e206]:
|
||||||
|
- button "Pin" [ref=e207]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e208]:
|
||||||
|
- img
|
||||||
|
- button [ref=e209]:
|
||||||
|
- img
|
||||||
|
- button "Test Note for Reminder This note will have a reminder il y a 26 minutes" [ref=e210]:
|
||||||
|
- generic [ref=e211]:
|
||||||
|
- heading "Test Note for Reminder" [level=3] [ref=e212]
|
||||||
|
- paragraph [ref=e213]: This note will have a reminder
|
||||||
|
- generic [ref=e214]: il y a 26 minutes
|
||||||
|
- generic [ref=e215]:
|
||||||
|
- button "Pin" [ref=e216]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e217]:
|
||||||
|
- img
|
||||||
|
- button [ref=e218]:
|
||||||
|
- img
|
||||||
|
- button "Test note il y a 26 minutes" [ref=e219]:
|
||||||
|
- generic [ref=e220]:
|
||||||
|
- paragraph [ref=e221]: Test note
|
||||||
|
- generic [ref=e222]: il y a 26 minutes
|
||||||
|
- generic [ref=e223]:
|
||||||
|
- button "Pin" [ref=e224]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e225]:
|
||||||
|
- img
|
||||||
|
- button [ref=e226]:
|
||||||
|
- img
|
||||||
|
- button "Test Note for Reminder This note will have a reminder il y a 26 minutes" [ref=e227]:
|
||||||
|
- generic [ref=e228]:
|
||||||
|
- heading "Test Note for Reminder" [level=3] [ref=e229]
|
||||||
|
- paragraph [ref=e230]: This note will have a reminder
|
||||||
|
- generic [ref=e231]: il y a 26 minutes
|
||||||
|
- generic [ref=e232]:
|
||||||
|
- button "Pin" [ref=e233]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e234]:
|
||||||
|
- img
|
||||||
|
- button [ref=e235]:
|
||||||
|
- img
|
||||||
|
- button "Test Note for Reminder This note will have a reminder il y a 26 minutes" [ref=e236]:
|
||||||
|
- generic [ref=e237]:
|
||||||
|
- heading "Test Note for Reminder" [level=3] [ref=e238]
|
||||||
|
- paragraph [ref=e239]: This note will have a reminder
|
||||||
|
- generic [ref=e240]: il y a 26 minutes
|
||||||
|
- generic [ref=e241]:
|
||||||
|
- button "Pin" [ref=e242]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e243]:
|
||||||
|
- img
|
||||||
|
- button [ref=e244]:
|
||||||
|
- img
|
||||||
|
- button "Test Note for Reminder This note will have a reminder il y a 26 minutes" [ref=e245]:
|
||||||
|
- generic [ref=e246]:
|
||||||
|
- heading "Test Note for Reminder" [level=3] [ref=e247]
|
||||||
|
- paragraph [ref=e248]: This note will have a reminder
|
||||||
|
- generic [ref=e249]: il y a 26 minutes
|
||||||
|
- generic [ref=e250]:
|
||||||
|
- button "Pin" [ref=e251]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e252]:
|
||||||
|
- img
|
||||||
|
- button [ref=e253]:
|
||||||
|
- img
|
||||||
|
- button "Test Note for Reminder This note will have a reminder il y a 26 minutes" [ref=e254]:
|
||||||
|
- generic [ref=e255]:
|
||||||
|
- img [ref=e256]
|
||||||
|
- heading "Test Note for Reminder" [level=3] [ref=e259]
|
||||||
|
- paragraph [ref=e260]: This note will have a reminder
|
||||||
|
- generic [ref=e261]: il y a 26 minutes
|
||||||
|
- generic [ref=e262]:
|
||||||
|
- button "Pin" [ref=e263]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e264]:
|
||||||
|
- img
|
||||||
|
- button [ref=e265]:
|
||||||
|
- img
|
||||||
|
- button "test sample file il y a environ 5 heures" [ref=e266]:
|
||||||
|
- generic [ref=e267]:
|
||||||
|
- heading "test" [level=3] [ref=e268]
|
||||||
|
- paragraph [ref=e270]: sample file
|
||||||
|
- generic [ref=e271]: il y a environ 5 heures
|
||||||
|
- generic [ref=e272]:
|
||||||
|
- button "Pin" [ref=e273]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e274]:
|
||||||
|
- img
|
||||||
|
- button [ref=e275]:
|
||||||
|
- img
|
||||||
|
- 'button "New AI Framework Released Lien: <www.example.com> Date: 2026-01-04 Résumé: A new AI framework designed for optimized training and deployment of machine learning models has been released. The framework supports a variety of neural networks and is engineered for performance and scalability. It includes features for simplifying model deployment and enhancing capabilities for real-time inference. Pourquoi c’est IA/DevIA: - Conception d''un framework pour l''IA - Optimisation de l''entraînement des modèles - Support pour les réseaux de neurones - Prise en charge de l''inférence en temps réel Tags: framework, mlops, gpu, ai, tech tech ai framework mlops gpu il y a environ 5 heures" [ref=e276]':
|
||||||
|
- generic [ref=e277]:
|
||||||
|
- heading "New AI Framework Released" [level=3] [ref=e278]
|
||||||
|
- paragraph [ref=e279]: "Lien: <www.example.com> Date: 2026-01-04 Résumé: A new AI framework designed for optimized training and deployment of machine learning models has been released. The framework supports a variety of neural networks and is engineered for performance and scalability. It includes features for simplifying model deployment and enhancing capabilities for real-time inference. Pourquoi c’est IA/DevIA: - Conception d'un framework pour l'IA - Optimisation de l'entraînement des modèles - Support pour les réseaux de neurones - Prise en charge de l'inférence en temps réel Tags: framework, mlops, gpu, ai, tech"
|
||||||
|
- generic [ref=e280]:
|
||||||
|
- generic [ref=e281]: tech
|
||||||
|
- generic [ref=e282]: ai
|
||||||
|
- generic [ref=e283]: framework
|
||||||
|
- generic [ref=e284]: mlops
|
||||||
|
- generic [ref=e285]: gpu
|
||||||
|
- generic [ref=e286]: il y a environ 5 heures
|
||||||
|
- generic [ref=e287]:
|
||||||
|
- button "Pin" [ref=e288]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e289]:
|
||||||
|
- img
|
||||||
|
- button [ref=e290]:
|
||||||
|
- img
|
||||||
|
- button "Test Image API Note avec image il y a environ 8 heures" [ref=e291]:
|
||||||
|
- generic [ref=e292]:
|
||||||
|
- heading "Test Image API" [level=3] [ref=e293]
|
||||||
|
- paragraph [ref=e295]: Note avec image
|
||||||
|
- generic [ref=e296]: il y a environ 8 heures
|
||||||
|
- generic [ref=e297]:
|
||||||
|
- button "Pin" [ref=e298]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e299]:
|
||||||
|
- img
|
||||||
|
- button [ref=e300]:
|
||||||
|
- img
|
||||||
|
- button "Test Markdown Titre Modifié Sous-titre édité Liste modifiée 1 Liste modifiée 2 Nouvelle liste 3 Texte gras modifié et italique édité console.log(\"Code modifié avec succès!\") il y a environ 5 heures" [ref=e301]:
|
||||||
|
- generic [ref=e302]:
|
||||||
|
- heading "Test Markdown" [level=3] [ref=e303]
|
||||||
|
- generic [ref=e305]:
|
||||||
|
- heading "Titre Modifié" [level=1] [ref=e306]
|
||||||
|
- heading "Sous-titre édité" [level=2] [ref=e307]
|
||||||
|
- list [ref=e308]:
|
||||||
|
- listitem [ref=e309]: Liste modifiée 1
|
||||||
|
- listitem [ref=e310]: Liste modifiée 2
|
||||||
|
- listitem [ref=e311]: Nouvelle liste 3
|
||||||
|
- paragraph [ref=e312]:
|
||||||
|
- strong [ref=e313]: Texte gras modifié
|
||||||
|
- text: et
|
||||||
|
- emphasis [ref=e314]: italique édité
|
||||||
|
- code [ref=e316]: console.log("Code modifié avec succès!")
|
||||||
|
- generic [ref=e317]: il y a environ 5 heures
|
||||||
|
- generic [ref=e318]:
|
||||||
|
- button "Pin" [ref=e319]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e320]:
|
||||||
|
- img
|
||||||
|
- button [ref=e321]:
|
||||||
|
- img
|
||||||
|
- button "Test Image Avec image il y a environ 8 heures" [ref=e322]:
|
||||||
|
- generic [ref=e323]:
|
||||||
|
- heading "Test Image" [level=3] [ref=e324]
|
||||||
|
- paragraph [ref=e325]: Avec image
|
||||||
|
- generic [ref=e326]: il y a environ 8 heures
|
||||||
|
- generic [ref=e327]:
|
||||||
|
- button "Pin" [ref=e328]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e329]:
|
||||||
|
- img
|
||||||
|
- button [ref=e330]:
|
||||||
|
- img
|
||||||
|
- 'button "New AI Framework Released Lien: <www.example.com> Date: 2026-01-04 Résumé: A new AI framework designed for optimized training and deployment of machine learning models has been released. The framework supports a variety of neural networks and includes features that enhance computational efficiency and model accuracy. This release aims to simplify the integration of AI tools into existing systems, making it easier for developers to build robust AI applications. Pourquoi c’est IA/DevIA: - Optimized training methods for machine learning models. - Supports deployment across various hardware configurations. - Focus on enhancing computational efficiency and model performance. - Intended for use in real-world AI applications and development. Tags: tech, ai, framework, mlops, gpu tech ai framework mlops gpu il y a environ 6 heures" [ref=e331]':
|
||||||
|
- generic [ref=e332]:
|
||||||
|
- heading "New AI Framework Released" [level=3] [ref=e333]
|
||||||
|
- paragraph [ref=e334]: "Lien: <www.example.com> Date: 2026-01-04 Résumé: A new AI framework designed for optimized training and deployment of machine learning models has been released. The framework supports a variety of neural networks and includes features that enhance computational efficiency and model accuracy. This release aims to simplify the integration of AI tools into existing systems, making it easier for developers to build robust AI applications. Pourquoi c’est IA/DevIA: - Optimized training methods for machine learning models. - Supports deployment across various hardware configurations. - Focus on enhancing computational efficiency and model performance. - Intended for use in real-world AI applications and development. Tags: tech, ai, framework, mlops, gpu"
|
||||||
|
- generic [ref=e335]:
|
||||||
|
- generic [ref=e336]: tech
|
||||||
|
- generic [ref=e337]: ai
|
||||||
|
- generic [ref=e338]: framework
|
||||||
|
- generic [ref=e339]: mlops
|
||||||
|
- generic [ref=e340]: gpu
|
||||||
|
- generic [ref=e341]: il y a environ 6 heures
|
||||||
|
- generic [ref=e342]:
|
||||||
|
- button "Pin" [ref=e343]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e344]:
|
||||||
|
- img
|
||||||
|
- button [ref=e345]:
|
||||||
|
- img
|
||||||
|
- button "Test Note This is my first note to test the Google Keep clone! il y a environ 10 heures" [ref=e346]:
|
||||||
|
- generic [ref=e347]:
|
||||||
|
- img [ref=e348]
|
||||||
|
- heading "Test Note" [level=3] [ref=e351]
|
||||||
|
- paragraph [ref=e352]: This is my first note to test the Google Keep clone!
|
||||||
|
- generic [ref=e353]: il y a environ 10 heures
|
||||||
|
- generic [ref=e354]:
|
||||||
|
- button "Pin" [ref=e355]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e356]:
|
||||||
|
- img
|
||||||
|
- button [ref=e357]:
|
||||||
|
- img
|
||||||
|
- button "Titre Modifié Contenu modifié avec succès! il y a environ 5 heures" [ref=e358]:
|
||||||
|
- generic [ref=e359]:
|
||||||
|
- heading "Titre Modifié" [level=3] [ref=e360]
|
||||||
|
- paragraph [ref=e361]: Contenu modifié avec succès!
|
||||||
|
- generic [ref=e362]: il y a environ 5 heures
|
||||||
|
- generic [ref=e363]:
|
||||||
|
- button "Pin" [ref=e364]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e365]:
|
||||||
|
- img
|
||||||
|
- button [ref=e366]:
|
||||||
|
- img
|
||||||
|
- status [ref=e367]
|
||||||
|
- generic [ref=e368]:
|
||||||
|
- generic [ref=e369]:
|
||||||
|
- generic [ref=e370]: Note created successfully
|
||||||
|
- button [ref=e371]:
|
||||||
|
- img [ref=e372]
|
||||||
|
- generic [ref=e375]:
|
||||||
|
- generic [ref=e376]: Note created successfully
|
||||||
|
- button [ref=e377]:
|
||||||
|
- img [ref=e378]
|
||||||
|
- generic [ref=e381]:
|
||||||
|
- generic [ref=e382]: Note created successfully
|
||||||
|
- button [ref=e383]:
|
||||||
|
- img [ref=e384]
|
||||||
|
- generic [ref=e387]:
|
||||||
|
- generic [ref=e388]: Note created successfully
|
||||||
|
- button [ref=e389]:
|
||||||
|
- img [ref=e390]
|
||||||
|
- button "Open Next.js Dev Tools" [ref=e398] [cursor=pointer]:
|
||||||
|
- img [ref=e399]
|
||||||
|
- alert [ref=e402]
|
||||||
|
```
|
||||||
@ -0,0 +1,509 @@
|
|||||||
|
# Page snapshot
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- generic [active] [ref=e1]:
|
||||||
|
- banner [ref=e2]:
|
||||||
|
- generic [ref=e3]:
|
||||||
|
- link "Memento" [ref=e4] [cursor=pointer]:
|
||||||
|
- /url: /
|
||||||
|
- img [ref=e5]
|
||||||
|
- generic [ref=e8]: Memento
|
||||||
|
- generic [ref=e10]:
|
||||||
|
- img [ref=e11]
|
||||||
|
- textbox "Search notes..." [ref=e14]
|
||||||
|
- button [ref=e15]:
|
||||||
|
- img
|
||||||
|
- navigation [ref=e16]:
|
||||||
|
- link "Notes" [ref=e17] [cursor=pointer]:
|
||||||
|
- /url: /
|
||||||
|
- img [ref=e18]
|
||||||
|
- text: Notes
|
||||||
|
- link "Archive" [ref=e21] [cursor=pointer]:
|
||||||
|
- /url: /archive
|
||||||
|
- img [ref=e22]
|
||||||
|
- text: Archive
|
||||||
|
- main [ref=e25]:
|
||||||
|
- generic [ref=e27]:
|
||||||
|
- textbox "Take a note..." [ref=e28]
|
||||||
|
- button "New checklist" [ref=e29]:
|
||||||
|
- img
|
||||||
|
- generic [ref=e30]:
|
||||||
|
- generic [ref=e31]:
|
||||||
|
- heading "Pinned" [level=2] [ref=e32]
|
||||||
|
- button "Updated Note avec image il y a environ 8 heures" [ref=e34]:
|
||||||
|
- generic [ref=e35]:
|
||||||
|
- img [ref=e36]
|
||||||
|
- heading "Updated" [level=3] [ref=e38]
|
||||||
|
- paragraph [ref=e39]: Note avec image
|
||||||
|
- generic [ref=e40]: il y a environ 8 heures
|
||||||
|
- generic [ref=e41]:
|
||||||
|
- button "Unpin" [ref=e42]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e43]:
|
||||||
|
- img
|
||||||
|
- button [ref=e44]:
|
||||||
|
- img
|
||||||
|
- generic [ref=e45]:
|
||||||
|
- heading "Others" [level=2] [ref=e46]
|
||||||
|
- generic [ref=e47]:
|
||||||
|
- button "test-1767557339218-Note 4 test-1767557339218-Content 4 il y a moins d’une minute" [ref=e48]:
|
||||||
|
- generic [ref=e49]:
|
||||||
|
- heading "test-1767557339218-Note 4" [level=3] [ref=e50]
|
||||||
|
- paragraph [ref=e51]: test-1767557339218-Content 4
|
||||||
|
- generic [ref=e52]: il y a moins d’une minute
|
||||||
|
- generic [ref=e53]:
|
||||||
|
- button "Pin" [ref=e54]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e55]:
|
||||||
|
- img
|
||||||
|
- button [ref=e56]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557339218-Note 3 test-1767557339218-Content 3 il y a moins d’une minute" [ref=e57]:
|
||||||
|
- generic [ref=e58]:
|
||||||
|
- heading "test-1767557339218-Note 3" [level=3] [ref=e59]
|
||||||
|
- paragraph [ref=e60]: test-1767557339218-Content 3
|
||||||
|
- generic [ref=e61]: il y a moins d’une minute
|
||||||
|
- generic [ref=e62]:
|
||||||
|
- button "Pin" [ref=e63]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e64]:
|
||||||
|
- img
|
||||||
|
- button [ref=e65]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557339218-Note 2 test-1767557339218-Content 2 il y a moins d’une minute" [ref=e66]:
|
||||||
|
- generic [ref=e67]:
|
||||||
|
- heading "test-1767557339218-Note 2" [level=3] [ref=e68]
|
||||||
|
- paragraph [ref=e69]: test-1767557339218-Content 2
|
||||||
|
- generic [ref=e70]: il y a moins d’une minute
|
||||||
|
- generic [ref=e71]:
|
||||||
|
- button "Pin" [ref=e72]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e73]:
|
||||||
|
- img
|
||||||
|
- button [ref=e74]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557339218-Note 1 test-1767557339218-Content 1 il y a moins d’une minute" [ref=e75]:
|
||||||
|
- generic [ref=e76]:
|
||||||
|
- heading "test-1767557339218-Note 1" [level=3] [ref=e77]
|
||||||
|
- paragraph [ref=e78]: test-1767557339218-Content 1
|
||||||
|
- generic [ref=e79]: il y a moins d’une minute
|
||||||
|
- generic [ref=e80]:
|
||||||
|
- button "Pin" [ref=e81]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e82]:
|
||||||
|
- img
|
||||||
|
- button [ref=e83]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557334587-Note 4 test-1767557334587-Content 4 il y a moins d’une minute" [ref=e84]:
|
||||||
|
- generic [ref=e85]:
|
||||||
|
- heading "test-1767557334587-Note 4" [level=3] [ref=e86]
|
||||||
|
- paragraph [ref=e87]: test-1767557334587-Content 4
|
||||||
|
- generic [ref=e88]: il y a moins d’une minute
|
||||||
|
- generic [ref=e89]:
|
||||||
|
- button "Pin" [ref=e90]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e91]:
|
||||||
|
- img
|
||||||
|
- button [ref=e92]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557334587-Note 3 test-1767557334587-Content 3 il y a moins d’une minute" [ref=e93]:
|
||||||
|
- generic [ref=e94]:
|
||||||
|
- heading "test-1767557334587-Note 3" [level=3] [ref=e95]
|
||||||
|
- paragraph [ref=e96]: test-1767557334587-Content 3
|
||||||
|
- generic [ref=e97]: il y a moins d’une minute
|
||||||
|
- generic [ref=e98]:
|
||||||
|
- button "Pin" [ref=e99]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e100]:
|
||||||
|
- img
|
||||||
|
- button [ref=e101]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557334587-Note 2 test-1767557334587-Content 2 il y a moins d’une minute" [ref=e102]:
|
||||||
|
- generic [ref=e103]:
|
||||||
|
- heading "test-1767557334587-Note 2" [level=3] [ref=e104]
|
||||||
|
- paragraph [ref=e105]: test-1767557334587-Content 2
|
||||||
|
- generic [ref=e106]: il y a moins d’une minute
|
||||||
|
- generic [ref=e107]:
|
||||||
|
- button "Pin" [ref=e108]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e109]:
|
||||||
|
- img
|
||||||
|
- button [ref=e110]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557334587-Note 1 test-1767557334587-Content 1 il y a moins d’une minute" [ref=e111]:
|
||||||
|
- generic [ref=e112]:
|
||||||
|
- heading "test-1767557334587-Note 1" [level=3] [ref=e113]
|
||||||
|
- paragraph [ref=e114]: test-1767557334587-Content 1
|
||||||
|
- generic [ref=e115]: il y a moins d’une minute
|
||||||
|
- generic [ref=e116]:
|
||||||
|
- button "Pin" [ref=e117]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e118]:
|
||||||
|
- img
|
||||||
|
- button [ref=e119]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557330820-Note 4 test-1767557330820-Content 4 il y a moins d’une minute" [ref=e120]:
|
||||||
|
- generic [ref=e121]:
|
||||||
|
- heading "test-1767557330820-Note 4" [level=3] [ref=e122]
|
||||||
|
- paragraph [ref=e123]: test-1767557330820-Content 4
|
||||||
|
- generic [ref=e124]: il y a moins d’une minute
|
||||||
|
- generic [ref=e125]:
|
||||||
|
- button "Pin" [ref=e126]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e127]:
|
||||||
|
- img
|
||||||
|
- button [ref=e128]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557330820-Note 3 test-1767557330820-Content 3 il y a moins d’une minute" [ref=e129]:
|
||||||
|
- generic [ref=e130]:
|
||||||
|
- heading "test-1767557330820-Note 3" [level=3] [ref=e131]
|
||||||
|
- paragraph [ref=e132]: test-1767557330820-Content 3
|
||||||
|
- generic [ref=e133]: il y a moins d’une minute
|
||||||
|
- generic [ref=e134]:
|
||||||
|
- button "Pin" [ref=e135]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e136]:
|
||||||
|
- img
|
||||||
|
- button [ref=e137]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557330820-Note 2 test-1767557330820-Content 2 il y a moins d’une minute" [ref=e138]:
|
||||||
|
- generic [ref=e139]:
|
||||||
|
- heading "test-1767557330820-Note 2" [level=3] [ref=e140]
|
||||||
|
- paragraph [ref=e141]: test-1767557330820-Content 2
|
||||||
|
- generic [ref=e142]: il y a moins d’une minute
|
||||||
|
- generic [ref=e143]:
|
||||||
|
- button "Pin" [ref=e144]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e145]:
|
||||||
|
- img
|
||||||
|
- button [ref=e146]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557330820-Note 1 test-1767557330820-Content 1 il y a moins d’une minute" [ref=e147]:
|
||||||
|
- generic [ref=e148]:
|
||||||
|
- heading "test-1767557330820-Note 1" [level=3] [ref=e149]
|
||||||
|
- paragraph [ref=e150]: test-1767557330820-Content 1
|
||||||
|
- generic [ref=e151]: il y a moins d’une minute
|
||||||
|
- generic [ref=e152]:
|
||||||
|
- button "Pin" [ref=e153]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e154]:
|
||||||
|
- img
|
||||||
|
- button [ref=e155]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557327567-Note 4 test-1767557327567-Content 4 il y a moins d’une minute" [ref=e156]:
|
||||||
|
- generic [ref=e157]:
|
||||||
|
- heading "test-1767557327567-Note 4" [level=3] [ref=e158]
|
||||||
|
- paragraph [ref=e159]: test-1767557327567-Content 4
|
||||||
|
- generic [ref=e160]: il y a moins d’une minute
|
||||||
|
- generic [ref=e161]:
|
||||||
|
- button "Pin" [ref=e162]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e163]:
|
||||||
|
- img
|
||||||
|
- button [ref=e164]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557327567-Note 3 test-1767557327567-Content 3 il y a moins d’une minute" [ref=e165]:
|
||||||
|
- generic [ref=e166]:
|
||||||
|
- heading "test-1767557327567-Note 3" [level=3] [ref=e167]
|
||||||
|
- paragraph [ref=e168]: test-1767557327567-Content 3
|
||||||
|
- generic [ref=e169]: il y a moins d’une minute
|
||||||
|
- generic [ref=e170]:
|
||||||
|
- button "Pin" [ref=e171]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e172]:
|
||||||
|
- img
|
||||||
|
- button [ref=e173]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557327567-Note 2 test-1767557327567-Content 2 il y a moins d’une minute" [ref=e174]:
|
||||||
|
- generic [ref=e175]:
|
||||||
|
- heading "test-1767557327567-Note 2" [level=3] [ref=e176]
|
||||||
|
- paragraph [ref=e177]: test-1767557327567-Content 2
|
||||||
|
- generic [ref=e178]: il y a moins d’une minute
|
||||||
|
- generic [ref=e179]:
|
||||||
|
- button "Pin" [ref=e180]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e181]:
|
||||||
|
- img
|
||||||
|
- button [ref=e182]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557327567-Note 1 test-1767557327567-Content 1 il y a moins d’une minute" [ref=e183]:
|
||||||
|
- generic [ref=e184]:
|
||||||
|
- heading "test-1767557327567-Note 1" [level=3] [ref=e185]
|
||||||
|
- paragraph [ref=e186]: test-1767557327567-Content 1
|
||||||
|
- generic [ref=e187]: il y a moins d’une minute
|
||||||
|
- generic [ref=e188]:
|
||||||
|
- button "Pin" [ref=e189]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e190]:
|
||||||
|
- img
|
||||||
|
- button [ref=e191]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557324248-Note 4 test-1767557324248-Content 4 il y a moins d’une minute" [ref=e192]:
|
||||||
|
- generic [ref=e193]:
|
||||||
|
- heading "test-1767557324248-Note 4" [level=3] [ref=e194]
|
||||||
|
- paragraph [ref=e195]: test-1767557324248-Content 4
|
||||||
|
- generic [ref=e196]: il y a moins d’une minute
|
||||||
|
- generic [ref=e197]:
|
||||||
|
- button "Pin" [ref=e198]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e199]:
|
||||||
|
- img
|
||||||
|
- button [ref=e200]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557324248-Note 3 test-1767557324248-Content 3 il y a moins d’une minute" [ref=e201]:
|
||||||
|
- generic [ref=e202]:
|
||||||
|
- heading "test-1767557324248-Note 3" [level=3] [ref=e203]
|
||||||
|
- paragraph [ref=e204]: test-1767557324248-Content 3
|
||||||
|
- generic [ref=e205]: il y a moins d’une minute
|
||||||
|
- generic [ref=e206]:
|
||||||
|
- button "Pin" [ref=e207]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e208]:
|
||||||
|
- img
|
||||||
|
- button [ref=e209]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557324248-Note 2 test-1767557324248-Content 2 il y a moins d’une minute" [ref=e210]:
|
||||||
|
- generic [ref=e211]:
|
||||||
|
- heading "test-1767557324248-Note 2" [level=3] [ref=e212]
|
||||||
|
- paragraph [ref=e213]: test-1767557324248-Content 2
|
||||||
|
- generic [ref=e214]: il y a moins d’une minute
|
||||||
|
- generic [ref=e215]:
|
||||||
|
- button "Pin" [ref=e216]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e217]:
|
||||||
|
- img
|
||||||
|
- button [ref=e218]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557324248-Note 1 test-1767557324248-Content 1 il y a moins d’une minute" [ref=e219]:
|
||||||
|
- generic [ref=e220]:
|
||||||
|
- heading "test-1767557324248-Note 1" [level=3] [ref=e221]
|
||||||
|
- paragraph [ref=e222]: test-1767557324248-Content 1
|
||||||
|
- generic [ref=e223]: il y a moins d’une minute
|
||||||
|
- generic [ref=e224]:
|
||||||
|
- button "Pin" [ref=e225]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e226]:
|
||||||
|
- img
|
||||||
|
- button [ref=e227]:
|
||||||
|
- img
|
||||||
|
- button "Test Note for Reminder This note will have a reminder il y a 26 minutes" [ref=e228]:
|
||||||
|
- generic [ref=e229]:
|
||||||
|
- heading "Test Note for Reminder" [level=3] [ref=e230]
|
||||||
|
- paragraph [ref=e231]: This note will have a reminder
|
||||||
|
- generic [ref=e232]: il y a 26 minutes
|
||||||
|
- generic [ref=e233]:
|
||||||
|
- button "Pin" [ref=e234]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e235]:
|
||||||
|
- img
|
||||||
|
- button [ref=e236]:
|
||||||
|
- img
|
||||||
|
- button "Test Note for Reminder This note will have a reminder il y a 26 minutes" [ref=e237]:
|
||||||
|
- generic [ref=e238]:
|
||||||
|
- heading "Test Note for Reminder" [level=3] [ref=e239]
|
||||||
|
- paragraph [ref=e240]: This note will have a reminder
|
||||||
|
- generic [ref=e241]: il y a 26 minutes
|
||||||
|
- generic [ref=e242]:
|
||||||
|
- button "Pin" [ref=e243]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e244]:
|
||||||
|
- img
|
||||||
|
- button [ref=e245]:
|
||||||
|
- img
|
||||||
|
- button "Test Note for Reminder This note will have a reminder il y a 26 minutes" [ref=e246]:
|
||||||
|
- generic [ref=e247]:
|
||||||
|
- heading "Test Note for Reminder" [level=3] [ref=e248]
|
||||||
|
- paragraph [ref=e249]: This note will have a reminder
|
||||||
|
- generic [ref=e250]: il y a 26 minutes
|
||||||
|
- generic [ref=e251]:
|
||||||
|
- button "Pin" [ref=e252]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e253]:
|
||||||
|
- img
|
||||||
|
- button [ref=e254]:
|
||||||
|
- img
|
||||||
|
- button "Test note il y a 26 minutes" [ref=e255]:
|
||||||
|
- generic [ref=e256]:
|
||||||
|
- paragraph [ref=e257]: Test note
|
||||||
|
- generic [ref=e258]: il y a 26 minutes
|
||||||
|
- generic [ref=e259]:
|
||||||
|
- button "Pin" [ref=e260]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e261]:
|
||||||
|
- img
|
||||||
|
- button [ref=e262]:
|
||||||
|
- img
|
||||||
|
- button "Test Note for Reminder This note will have a reminder il y a 26 minutes" [ref=e263]:
|
||||||
|
- generic [ref=e264]:
|
||||||
|
- heading "Test Note for Reminder" [level=3] [ref=e265]
|
||||||
|
- paragraph [ref=e266]: This note will have a reminder
|
||||||
|
- generic [ref=e267]: il y a 26 minutes
|
||||||
|
- generic [ref=e268]:
|
||||||
|
- button "Pin" [ref=e269]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e270]:
|
||||||
|
- img
|
||||||
|
- button [ref=e271]:
|
||||||
|
- img
|
||||||
|
- button "Test Note for Reminder This note will have a reminder il y a 26 minutes" [ref=e272]:
|
||||||
|
- generic [ref=e273]:
|
||||||
|
- heading "Test Note for Reminder" [level=3] [ref=e274]
|
||||||
|
- paragraph [ref=e275]: This note will have a reminder
|
||||||
|
- generic [ref=e276]: il y a 26 minutes
|
||||||
|
- generic [ref=e277]:
|
||||||
|
- button "Pin" [ref=e278]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e279]:
|
||||||
|
- img
|
||||||
|
- button [ref=e280]:
|
||||||
|
- img
|
||||||
|
- button "Test Note for Reminder This note will have a reminder il y a 26 minutes" [ref=e281]:
|
||||||
|
- generic [ref=e282]:
|
||||||
|
- heading "Test Note for Reminder" [level=3] [ref=e283]
|
||||||
|
- paragraph [ref=e284]: This note will have a reminder
|
||||||
|
- generic [ref=e285]: il y a 26 minutes
|
||||||
|
- generic [ref=e286]:
|
||||||
|
- button "Pin" [ref=e287]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e288]:
|
||||||
|
- img
|
||||||
|
- button [ref=e289]:
|
||||||
|
- img
|
||||||
|
- button "Test Note for Reminder This note will have a reminder il y a 26 minutes" [ref=e290]:
|
||||||
|
- generic [ref=e291]:
|
||||||
|
- img [ref=e292]
|
||||||
|
- heading "Test Note for Reminder" [level=3] [ref=e295]
|
||||||
|
- paragraph [ref=e296]: This note will have a reminder
|
||||||
|
- generic [ref=e297]: il y a 26 minutes
|
||||||
|
- generic [ref=e298]:
|
||||||
|
- button "Pin" [ref=e299]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e300]:
|
||||||
|
- img
|
||||||
|
- button [ref=e301]:
|
||||||
|
- img
|
||||||
|
- button "test sample file il y a environ 5 heures" [ref=e302]:
|
||||||
|
- generic [ref=e303]:
|
||||||
|
- heading "test" [level=3] [ref=e304]
|
||||||
|
- paragraph [ref=e306]: sample file
|
||||||
|
- generic [ref=e307]: il y a environ 5 heures
|
||||||
|
- generic [ref=e308]:
|
||||||
|
- button "Pin" [ref=e309]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e310]:
|
||||||
|
- img
|
||||||
|
- button [ref=e311]:
|
||||||
|
- img
|
||||||
|
- 'button "New AI Framework Released Lien: <www.example.com> Date: 2026-01-04 Résumé: A new AI framework designed for optimized training and deployment of machine learning models has been released. The framework supports a variety of neural networks and is engineered for performance and scalability. It includes features for simplifying model deployment and enhancing capabilities for real-time inference. Pourquoi c’est IA/DevIA: - Conception d''un framework pour l''IA - Optimisation de l''entraînement des modèles - Support pour les réseaux de neurones - Prise en charge de l''inférence en temps réel Tags: framework, mlops, gpu, ai, tech tech ai framework mlops gpu il y a environ 5 heures" [ref=e312]':
|
||||||
|
- generic [ref=e313]:
|
||||||
|
- heading "New AI Framework Released" [level=3] [ref=e314]
|
||||||
|
- paragraph [ref=e315]: "Lien: <www.example.com> Date: 2026-01-04 Résumé: A new AI framework designed for optimized training and deployment of machine learning models has been released. The framework supports a variety of neural networks and is engineered for performance and scalability. It includes features for simplifying model deployment and enhancing capabilities for real-time inference. Pourquoi c’est IA/DevIA: - Conception d'un framework pour l'IA - Optimisation de l'entraînement des modèles - Support pour les réseaux de neurones - Prise en charge de l'inférence en temps réel Tags: framework, mlops, gpu, ai, tech"
|
||||||
|
- generic [ref=e316]:
|
||||||
|
- generic [ref=e317]: tech
|
||||||
|
- generic [ref=e318]: ai
|
||||||
|
- generic [ref=e319]: framework
|
||||||
|
- generic [ref=e320]: mlops
|
||||||
|
- generic [ref=e321]: gpu
|
||||||
|
- generic [ref=e322]: il y a environ 5 heures
|
||||||
|
- generic [ref=e323]:
|
||||||
|
- button "Pin" [ref=e324]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e325]:
|
||||||
|
- img
|
||||||
|
- button [ref=e326]:
|
||||||
|
- img
|
||||||
|
- button "Test Image API Note avec image il y a environ 8 heures" [ref=e327]:
|
||||||
|
- generic [ref=e328]:
|
||||||
|
- heading "Test Image API" [level=3] [ref=e329]
|
||||||
|
- paragraph [ref=e331]: Note avec image
|
||||||
|
- generic [ref=e332]: il y a environ 8 heures
|
||||||
|
- generic [ref=e333]:
|
||||||
|
- button "Pin" [ref=e334]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e335]:
|
||||||
|
- img
|
||||||
|
- button [ref=e336]:
|
||||||
|
- img
|
||||||
|
- button "Test Markdown Titre Modifié Sous-titre édité Liste modifiée 1 Liste modifiée 2 Nouvelle liste 3 Texte gras modifié et italique édité console.log(\"Code modifié avec succès!\") il y a environ 5 heures" [ref=e337]:
|
||||||
|
- generic [ref=e338]:
|
||||||
|
- heading "Test Markdown" [level=3] [ref=e339]
|
||||||
|
- generic [ref=e341]:
|
||||||
|
- heading "Titre Modifié" [level=1] [ref=e342]
|
||||||
|
- heading "Sous-titre édité" [level=2] [ref=e343]
|
||||||
|
- list [ref=e344]:
|
||||||
|
- listitem [ref=e345]: Liste modifiée 1
|
||||||
|
- listitem [ref=e346]: Liste modifiée 2
|
||||||
|
- listitem [ref=e347]: Nouvelle liste 3
|
||||||
|
- paragraph [ref=e348]:
|
||||||
|
- strong [ref=e349]: Texte gras modifié
|
||||||
|
- text: et
|
||||||
|
- emphasis [ref=e350]: italique édité
|
||||||
|
- code [ref=e352]: console.log("Code modifié avec succès!")
|
||||||
|
- generic [ref=e353]: il y a environ 5 heures
|
||||||
|
- generic [ref=e354]:
|
||||||
|
- button "Pin" [ref=e355]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e356]:
|
||||||
|
- img
|
||||||
|
- button [ref=e357]:
|
||||||
|
- img
|
||||||
|
- button "Test Image Avec image il y a environ 8 heures" [ref=e358]:
|
||||||
|
- generic [ref=e359]:
|
||||||
|
- heading "Test Image" [level=3] [ref=e360]
|
||||||
|
- paragraph [ref=e361]: Avec image
|
||||||
|
- generic [ref=e362]: il y a environ 8 heures
|
||||||
|
- generic [ref=e363]:
|
||||||
|
- button "Pin" [ref=e364]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e365]:
|
||||||
|
- img
|
||||||
|
- button [ref=e366]:
|
||||||
|
- img
|
||||||
|
- 'button "New AI Framework Released Lien: <www.example.com> Date: 2026-01-04 Résumé: A new AI framework designed for optimized training and deployment of machine learning models has been released. The framework supports a variety of neural networks and includes features that enhance computational efficiency and model accuracy. This release aims to simplify the integration of AI tools into existing systems, making it easier for developers to build robust AI applications. Pourquoi c’est IA/DevIA: - Optimized training methods for machine learning models. - Supports deployment across various hardware configurations. - Focus on enhancing computational efficiency and model performance. - Intended for use in real-world AI applications and development. Tags: tech, ai, framework, mlops, gpu tech ai framework mlops gpu il y a environ 6 heures" [ref=e367]':
|
||||||
|
- generic [ref=e368]:
|
||||||
|
- heading "New AI Framework Released" [level=3] [ref=e369]
|
||||||
|
- paragraph [ref=e370]: "Lien: <www.example.com> Date: 2026-01-04 Résumé: A new AI framework designed for optimized training and deployment of machine learning models has been released. The framework supports a variety of neural networks and includes features that enhance computational efficiency and model accuracy. This release aims to simplify the integration of AI tools into existing systems, making it easier for developers to build robust AI applications. Pourquoi c’est IA/DevIA: - Optimized training methods for machine learning models. - Supports deployment across various hardware configurations. - Focus on enhancing computational efficiency and model performance. - Intended for use in real-world AI applications and development. Tags: tech, ai, framework, mlops, gpu"
|
||||||
|
- generic [ref=e371]:
|
||||||
|
- generic [ref=e372]: tech
|
||||||
|
- generic [ref=e373]: ai
|
||||||
|
- generic [ref=e374]: framework
|
||||||
|
- generic [ref=e375]: mlops
|
||||||
|
- generic [ref=e376]: gpu
|
||||||
|
- generic [ref=e377]: il y a environ 6 heures
|
||||||
|
- generic [ref=e378]:
|
||||||
|
- button "Pin" [ref=e379]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e380]:
|
||||||
|
- img
|
||||||
|
- button [ref=e381]:
|
||||||
|
- img
|
||||||
|
- button "Test Note This is my first note to test the Google Keep clone! il y a environ 10 heures" [ref=e382]:
|
||||||
|
- generic [ref=e383]:
|
||||||
|
- img [ref=e384]
|
||||||
|
- heading "Test Note" [level=3] [ref=e387]
|
||||||
|
- paragraph [ref=e388]: This is my first note to test the Google Keep clone!
|
||||||
|
- generic [ref=e389]: il y a environ 10 heures
|
||||||
|
- generic [ref=e390]:
|
||||||
|
- button "Pin" [ref=e391]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e392]:
|
||||||
|
- img
|
||||||
|
- button [ref=e393]:
|
||||||
|
- img
|
||||||
|
- button "Titre Modifié Contenu modifié avec succès! il y a environ 5 heures" [ref=e394]:
|
||||||
|
- generic [ref=e395]:
|
||||||
|
- heading "Titre Modifié" [level=3] [ref=e396]
|
||||||
|
- paragraph [ref=e397]: Contenu modifié avec succès!
|
||||||
|
- generic [ref=e398]: il y a environ 5 heures
|
||||||
|
- generic [ref=e399]:
|
||||||
|
- button "Pin" [ref=e400]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e401]:
|
||||||
|
- img
|
||||||
|
- button [ref=e402]:
|
||||||
|
- img
|
||||||
|
- status [ref=e403]
|
||||||
|
- button "Open Next.js Dev Tools" [ref=e409] [cursor=pointer]:
|
||||||
|
- img [ref=e410]
|
||||||
|
- alert [ref=e413]
|
||||||
|
```
|
||||||
@ -0,0 +1,557 @@
|
|||||||
|
# Page snapshot
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- generic [active] [ref=e1]:
|
||||||
|
- banner [ref=e2]:
|
||||||
|
- generic [ref=e3]:
|
||||||
|
- link "Memento" [ref=e4] [cursor=pointer]:
|
||||||
|
- /url: /
|
||||||
|
- img [ref=e5]
|
||||||
|
- generic [ref=e8]: Memento
|
||||||
|
- generic [ref=e10]:
|
||||||
|
- img [ref=e11]
|
||||||
|
- textbox "Search notes..." [ref=e14]
|
||||||
|
- button [ref=e15]:
|
||||||
|
- img
|
||||||
|
- navigation [ref=e16]:
|
||||||
|
- link "Notes" [ref=e17] [cursor=pointer]:
|
||||||
|
- /url: /
|
||||||
|
- img [ref=e18]
|
||||||
|
- text: Notes
|
||||||
|
- link "Archive" [ref=e21] [cursor=pointer]:
|
||||||
|
- /url: /archive
|
||||||
|
- img [ref=e22]
|
||||||
|
- text: Archive
|
||||||
|
- main [ref=e25]:
|
||||||
|
- generic [ref=e27]:
|
||||||
|
- textbox "Take a note..." [ref=e28]
|
||||||
|
- button "New checklist" [ref=e29]:
|
||||||
|
- img
|
||||||
|
- generic [ref=e30]:
|
||||||
|
- generic [ref=e31]:
|
||||||
|
- heading "Pinned" [level=2] [ref=e32]
|
||||||
|
- button "Updated Note avec image il y a environ 8 heures" [ref=e34]:
|
||||||
|
- generic [ref=e35]:
|
||||||
|
- img [ref=e36]
|
||||||
|
- heading "Updated" [level=3] [ref=e38]
|
||||||
|
- paragraph [ref=e39]: Note avec image
|
||||||
|
- generic [ref=e40]: il y a environ 8 heures
|
||||||
|
- generic [ref=e41]:
|
||||||
|
- button "Unpin" [ref=e42]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e43]:
|
||||||
|
- img
|
||||||
|
- button [ref=e44]:
|
||||||
|
- img
|
||||||
|
- generic [ref=e45]:
|
||||||
|
- heading "Others" [level=2] [ref=e46]
|
||||||
|
- generic [ref=e47]:
|
||||||
|
- button "test-1767557370056-Note 4 test-1767557370056-Content 4 il y a moins d’une minute" [ref=e48]:
|
||||||
|
- generic [ref=e49]:
|
||||||
|
- heading "test-1767557370056-Note 4" [level=3] [ref=e50]
|
||||||
|
- paragraph [ref=e51]: test-1767557370056-Content 4
|
||||||
|
- generic [ref=e52]: il y a moins d’une minute
|
||||||
|
- generic [ref=e53]:
|
||||||
|
- button "Pin" [ref=e54]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e55]:
|
||||||
|
- img
|
||||||
|
- button [ref=e56]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557370056-Note 3 test-1767557370056-Content 3 il y a moins d’une minute" [ref=e57]:
|
||||||
|
- generic [ref=e58]:
|
||||||
|
- heading "test-1767557370056-Note 3" [level=3] [ref=e59]
|
||||||
|
- paragraph [ref=e60]: test-1767557370056-Content 3
|
||||||
|
- generic [ref=e61]: il y a moins d’une minute
|
||||||
|
- generic [ref=e62]:
|
||||||
|
- button "Pin" [ref=e63]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e64]:
|
||||||
|
- img
|
||||||
|
- button [ref=e65]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557370056-Note 2 test-1767557370056-Content 2 il y a moins d’une minute" [ref=e66]:
|
||||||
|
- generic [ref=e67]:
|
||||||
|
- heading "test-1767557370056-Note 2" [level=3] [ref=e68]
|
||||||
|
- paragraph [ref=e69]: test-1767557370056-Content 2
|
||||||
|
- generic [ref=e70]: il y a moins d’une minute
|
||||||
|
- generic [ref=e71]:
|
||||||
|
- button "Pin" [ref=e72]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e73]:
|
||||||
|
- img
|
||||||
|
- button [ref=e74]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557370056-Note 1 test-1767557370056-Content 1 il y a moins d’une minute" [ref=e75]:
|
||||||
|
- generic [ref=e76]:
|
||||||
|
- heading "test-1767557370056-Note 1" [level=3] [ref=e77]
|
||||||
|
- paragraph [ref=e78]: test-1767557370056-Content 1
|
||||||
|
- generic [ref=e79]: il y a moins d’une minute
|
||||||
|
- generic [ref=e80]:
|
||||||
|
- button "Pin" [ref=e81]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e82]:
|
||||||
|
- img
|
||||||
|
- button [ref=e83]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557339218-Note 4 test-1767557339218-Content 4 il y a 1 minute" [ref=e84]:
|
||||||
|
- generic [ref=e85]:
|
||||||
|
- heading "test-1767557339218-Note 4" [level=3] [ref=e86]
|
||||||
|
- paragraph [ref=e87]: test-1767557339218-Content 4
|
||||||
|
- generic [ref=e88]: il y a 1 minute
|
||||||
|
- generic [ref=e89]:
|
||||||
|
- button "Pin" [ref=e90]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e91]:
|
||||||
|
- img
|
||||||
|
- button [ref=e92]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557339218-Note 3 test-1767557339218-Content 3 il y a 1 minute" [ref=e93]:
|
||||||
|
- generic [ref=e94]:
|
||||||
|
- heading "test-1767557339218-Note 3" [level=3] [ref=e95]
|
||||||
|
- paragraph [ref=e96]: test-1767557339218-Content 3
|
||||||
|
- generic [ref=e97]: il y a 1 minute
|
||||||
|
- generic [ref=e98]:
|
||||||
|
- button "Pin" [ref=e99]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e100]:
|
||||||
|
- img
|
||||||
|
- button [ref=e101]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557339218-Note 2 test-1767557339218-Content 2 il y a 1 minute" [ref=e102]:
|
||||||
|
- generic [ref=e103]:
|
||||||
|
- heading "test-1767557339218-Note 2" [level=3] [ref=e104]
|
||||||
|
- paragraph [ref=e105]: test-1767557339218-Content 2
|
||||||
|
- generic [ref=e106]: il y a 1 minute
|
||||||
|
- generic [ref=e107]:
|
||||||
|
- button "Pin" [ref=e108]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e109]:
|
||||||
|
- img
|
||||||
|
- button [ref=e110]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557339218-Note 1 test-1767557339218-Content 1 il y a 1 minute" [ref=e111]:
|
||||||
|
- generic [ref=e112]:
|
||||||
|
- heading "test-1767557339218-Note 1" [level=3] [ref=e113]
|
||||||
|
- paragraph [ref=e114]: test-1767557339218-Content 1
|
||||||
|
- generic [ref=e115]: il y a 1 minute
|
||||||
|
- generic [ref=e116]:
|
||||||
|
- button "Pin" [ref=e117]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e118]:
|
||||||
|
- img
|
||||||
|
- button [ref=e119]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557334587-Note 4 test-1767557334587-Content 4 il y a 1 minute" [ref=e120]:
|
||||||
|
- generic [ref=e121]:
|
||||||
|
- heading "test-1767557334587-Note 4" [level=3] [ref=e122]
|
||||||
|
- paragraph [ref=e123]: test-1767557334587-Content 4
|
||||||
|
- generic [ref=e124]: il y a 1 minute
|
||||||
|
- generic [ref=e125]:
|
||||||
|
- button "Pin" [ref=e126]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e127]:
|
||||||
|
- img
|
||||||
|
- button [ref=e128]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557334587-Note 3 test-1767557334587-Content 3 il y a 1 minute" [ref=e129]:
|
||||||
|
- generic [ref=e130]:
|
||||||
|
- heading "test-1767557334587-Note 3" [level=3] [ref=e131]
|
||||||
|
- paragraph [ref=e132]: test-1767557334587-Content 3
|
||||||
|
- generic [ref=e133]: il y a 1 minute
|
||||||
|
- generic [ref=e134]:
|
||||||
|
- button "Pin" [ref=e135]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e136]:
|
||||||
|
- img
|
||||||
|
- button [ref=e137]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557334587-Note 2 test-1767557334587-Content 2 il y a 1 minute" [ref=e138]:
|
||||||
|
- generic [ref=e139]:
|
||||||
|
- heading "test-1767557334587-Note 2" [level=3] [ref=e140]
|
||||||
|
- paragraph [ref=e141]: test-1767557334587-Content 2
|
||||||
|
- generic [ref=e142]: il y a 1 minute
|
||||||
|
- generic [ref=e143]:
|
||||||
|
- button "Pin" [ref=e144]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e145]:
|
||||||
|
- img
|
||||||
|
- button [ref=e146]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557334587-Note 1 test-1767557334587-Content 1 il y a 1 minute" [ref=e147]:
|
||||||
|
- generic [ref=e148]:
|
||||||
|
- heading "test-1767557334587-Note 1" [level=3] [ref=e149]
|
||||||
|
- paragraph [ref=e150]: test-1767557334587-Content 1
|
||||||
|
- generic [ref=e151]: il y a 1 minute
|
||||||
|
- generic [ref=e152]:
|
||||||
|
- button "Pin" [ref=e153]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e154]:
|
||||||
|
- img
|
||||||
|
- button [ref=e155]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557330820-Note 4 test-1767557330820-Content 4 il y a 1 minute" [ref=e156]:
|
||||||
|
- generic [ref=e157]:
|
||||||
|
- heading "test-1767557330820-Note 4" [level=3] [ref=e158]
|
||||||
|
- paragraph [ref=e159]: test-1767557330820-Content 4
|
||||||
|
- generic [ref=e160]: il y a 1 minute
|
||||||
|
- generic [ref=e161]:
|
||||||
|
- button "Pin" [ref=e162]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e163]:
|
||||||
|
- img
|
||||||
|
- button [ref=e164]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557330820-Note 3 test-1767557330820-Content 3 il y a 1 minute" [ref=e165]:
|
||||||
|
- generic [ref=e166]:
|
||||||
|
- heading "test-1767557330820-Note 3" [level=3] [ref=e167]
|
||||||
|
- paragraph [ref=e168]: test-1767557330820-Content 3
|
||||||
|
- generic [ref=e169]: il y a 1 minute
|
||||||
|
- generic [ref=e170]:
|
||||||
|
- button "Pin" [ref=e171]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e172]:
|
||||||
|
- img
|
||||||
|
- button [ref=e173]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557330820-Note 2 test-1767557330820-Content 2 il y a 1 minute" [ref=e174]:
|
||||||
|
- generic [ref=e175]:
|
||||||
|
- heading "test-1767557330820-Note 2" [level=3] [ref=e176]
|
||||||
|
- paragraph [ref=e177]: test-1767557330820-Content 2
|
||||||
|
- generic [ref=e178]: il y a 1 minute
|
||||||
|
- generic [ref=e179]:
|
||||||
|
- button "Pin" [ref=e180]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e181]:
|
||||||
|
- img
|
||||||
|
- button [ref=e182]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557330820-Note 1 test-1767557330820-Content 1 il y a 1 minute" [ref=e183]:
|
||||||
|
- generic [ref=e184]:
|
||||||
|
- heading "test-1767557330820-Note 1" [level=3] [ref=e185]
|
||||||
|
- paragraph [ref=e186]: test-1767557330820-Content 1
|
||||||
|
- generic [ref=e187]: il y a 1 minute
|
||||||
|
- generic [ref=e188]:
|
||||||
|
- button "Pin" [ref=e189]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e190]:
|
||||||
|
- img
|
||||||
|
- button [ref=e191]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557327567-Note 4 test-1767557327567-Content 4 il y a 1 minute" [ref=e192]:
|
||||||
|
- generic [ref=e193]:
|
||||||
|
- heading "test-1767557327567-Note 4" [level=3] [ref=e194]
|
||||||
|
- paragraph [ref=e195]: test-1767557327567-Content 4
|
||||||
|
- generic [ref=e196]: il y a 1 minute
|
||||||
|
- generic [ref=e197]:
|
||||||
|
- button "Pin" [ref=e198]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e199]:
|
||||||
|
- img
|
||||||
|
- button [ref=e200]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557327567-Note 3 test-1767557327567-Content 3 il y a 1 minute" [ref=e201]:
|
||||||
|
- generic [ref=e202]:
|
||||||
|
- heading "test-1767557327567-Note 3" [level=3] [ref=e203]
|
||||||
|
- paragraph [ref=e204]: test-1767557327567-Content 3
|
||||||
|
- generic [ref=e205]: il y a 1 minute
|
||||||
|
- generic [ref=e206]:
|
||||||
|
- button "Pin" [ref=e207]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e208]:
|
||||||
|
- img
|
||||||
|
- button [ref=e209]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557327567-Note 2 test-1767557327567-Content 2 il y a 1 minute" [ref=e210]:
|
||||||
|
- generic [ref=e211]:
|
||||||
|
- heading "test-1767557327567-Note 2" [level=3] [ref=e212]
|
||||||
|
- paragraph [ref=e213]: test-1767557327567-Content 2
|
||||||
|
- generic [ref=e214]: il y a 1 minute
|
||||||
|
- generic [ref=e215]:
|
||||||
|
- button "Pin" [ref=e216]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e217]:
|
||||||
|
- img
|
||||||
|
- button [ref=e218]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557327567-Note 1 test-1767557327567-Content 1 il y a 1 minute" [ref=e219]:
|
||||||
|
- generic [ref=e220]:
|
||||||
|
- heading "test-1767557327567-Note 1" [level=3] [ref=e221]
|
||||||
|
- paragraph [ref=e222]: test-1767557327567-Content 1
|
||||||
|
- generic [ref=e223]: il y a 1 minute
|
||||||
|
- generic [ref=e224]:
|
||||||
|
- button "Pin" [ref=e225]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e226]:
|
||||||
|
- img
|
||||||
|
- button [ref=e227]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557324248-Note 4 test-1767557324248-Content 4 il y a 1 minute" [ref=e228]:
|
||||||
|
- generic [ref=e229]:
|
||||||
|
- heading "test-1767557324248-Note 4" [level=3] [ref=e230]
|
||||||
|
- paragraph [ref=e231]: test-1767557324248-Content 4
|
||||||
|
- generic [ref=e232]: il y a 1 minute
|
||||||
|
- generic [ref=e233]:
|
||||||
|
- button "Pin" [ref=e234]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e235]:
|
||||||
|
- img
|
||||||
|
- button [ref=e236]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557324248-Note 3 test-1767557324248-Content 3 il y a 1 minute" [ref=e237]:
|
||||||
|
- generic [ref=e238]:
|
||||||
|
- heading "test-1767557324248-Note 3" [level=3] [ref=e239]
|
||||||
|
- paragraph [ref=e240]: test-1767557324248-Content 3
|
||||||
|
- generic [ref=e241]: il y a 1 minute
|
||||||
|
- generic [ref=e242]:
|
||||||
|
- button "Pin" [ref=e243]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e244]:
|
||||||
|
- img
|
||||||
|
- button [ref=e245]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557324248-Note 2 test-1767557324248-Content 2 il y a 1 minute" [ref=e246]:
|
||||||
|
- generic [ref=e247]:
|
||||||
|
- heading "test-1767557324248-Note 2" [level=3] [ref=e248]
|
||||||
|
- paragraph [ref=e249]: test-1767557324248-Content 2
|
||||||
|
- generic [ref=e250]: il y a 1 minute
|
||||||
|
- generic [ref=e251]:
|
||||||
|
- button "Pin" [ref=e252]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e253]:
|
||||||
|
- img
|
||||||
|
- button [ref=e254]:
|
||||||
|
- img
|
||||||
|
- button "test-1767557324248-Note 1 test-1767557324248-Content 1 il y a 1 minute" [ref=e255]:
|
||||||
|
- generic [ref=e256]:
|
||||||
|
- heading "test-1767557324248-Note 1" [level=3] [ref=e257]
|
||||||
|
- paragraph [ref=e258]: test-1767557324248-Content 1
|
||||||
|
- generic [ref=e259]: il y a 1 minute
|
||||||
|
- generic [ref=e260]:
|
||||||
|
- button "Pin" [ref=e261]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e262]:
|
||||||
|
- img
|
||||||
|
- button [ref=e263]:
|
||||||
|
- img
|
||||||
|
- button "Test Note for Reminder This note will have a reminder il y a 27 minutes" [ref=e264]:
|
||||||
|
- generic [ref=e265]:
|
||||||
|
- heading "Test Note for Reminder" [level=3] [ref=e266]
|
||||||
|
- paragraph [ref=e267]: This note will have a reminder
|
||||||
|
- generic [ref=e268]: il y a 27 minutes
|
||||||
|
- generic [ref=e269]:
|
||||||
|
- button "Pin" [ref=e270]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e271]:
|
||||||
|
- img
|
||||||
|
- button [ref=e272]:
|
||||||
|
- img
|
||||||
|
- button "Test Note for Reminder This note will have a reminder il y a 27 minutes" [ref=e273]:
|
||||||
|
- generic [ref=e274]:
|
||||||
|
- heading "Test Note for Reminder" [level=3] [ref=e275]
|
||||||
|
- paragraph [ref=e276]: This note will have a reminder
|
||||||
|
- generic [ref=e277]: il y a 27 minutes
|
||||||
|
- generic [ref=e278]:
|
||||||
|
- button "Pin" [ref=e279]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e280]:
|
||||||
|
- img
|
||||||
|
- button [ref=e281]:
|
||||||
|
- img
|
||||||
|
- button "Test Note for Reminder This note will have a reminder il y a 27 minutes" [ref=e282]:
|
||||||
|
- generic [ref=e283]:
|
||||||
|
- heading "Test Note for Reminder" [level=3] [ref=e284]
|
||||||
|
- paragraph [ref=e285]: This note will have a reminder
|
||||||
|
- generic [ref=e286]: il y a 27 minutes
|
||||||
|
- generic [ref=e287]:
|
||||||
|
- button "Pin" [ref=e288]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e289]:
|
||||||
|
- img
|
||||||
|
- button [ref=e290]:
|
||||||
|
- img
|
||||||
|
- button "Test note il y a 26 minutes" [ref=e291]:
|
||||||
|
- generic [ref=e292]:
|
||||||
|
- paragraph [ref=e293]: Test note
|
||||||
|
- generic [ref=e294]: il y a 26 minutes
|
||||||
|
- generic [ref=e295]:
|
||||||
|
- button "Pin" [ref=e296]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e297]:
|
||||||
|
- img
|
||||||
|
- button [ref=e298]:
|
||||||
|
- img
|
||||||
|
- button "Test Note for Reminder This note will have a reminder il y a 27 minutes" [ref=e299]:
|
||||||
|
- generic [ref=e300]:
|
||||||
|
- heading "Test Note for Reminder" [level=3] [ref=e301]
|
||||||
|
- paragraph [ref=e302]: This note will have a reminder
|
||||||
|
- generic [ref=e303]: il y a 27 minutes
|
||||||
|
- generic [ref=e304]:
|
||||||
|
- button "Pin" [ref=e305]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e306]:
|
||||||
|
- img
|
||||||
|
- button [ref=e307]:
|
||||||
|
- img
|
||||||
|
- button "Test Note for Reminder This note will have a reminder il y a 27 minutes" [ref=e308]:
|
||||||
|
- generic [ref=e309]:
|
||||||
|
- heading "Test Note for Reminder" [level=3] [ref=e310]
|
||||||
|
- paragraph [ref=e311]: This note will have a reminder
|
||||||
|
- generic [ref=e312]: il y a 27 minutes
|
||||||
|
- generic [ref=e313]:
|
||||||
|
- button "Pin" [ref=e314]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e315]:
|
||||||
|
- img
|
||||||
|
- button [ref=e316]:
|
||||||
|
- img
|
||||||
|
- button "Test Note for Reminder This note will have a reminder il y a 27 minutes" [ref=e317]:
|
||||||
|
- generic [ref=e318]:
|
||||||
|
- heading "Test Note for Reminder" [level=3] [ref=e319]
|
||||||
|
- paragraph [ref=e320]: This note will have a reminder
|
||||||
|
- generic [ref=e321]: il y a 27 minutes
|
||||||
|
- generic [ref=e322]:
|
||||||
|
- button "Pin" [ref=e323]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e324]:
|
||||||
|
- img
|
||||||
|
- button [ref=e325]:
|
||||||
|
- img
|
||||||
|
- button "Test Note for Reminder This note will have a reminder il y a 27 minutes" [ref=e326]:
|
||||||
|
- generic [ref=e327]:
|
||||||
|
- img [ref=e328]
|
||||||
|
- heading "Test Note for Reminder" [level=3] [ref=e331]
|
||||||
|
- paragraph [ref=e332]: This note will have a reminder
|
||||||
|
- generic [ref=e333]: il y a 27 minutes
|
||||||
|
- generic [ref=e334]:
|
||||||
|
- button "Pin" [ref=e335]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e336]:
|
||||||
|
- img
|
||||||
|
- button [ref=e337]:
|
||||||
|
- img
|
||||||
|
- button "test sample file il y a environ 5 heures" [ref=e338]:
|
||||||
|
- generic [ref=e339]:
|
||||||
|
- heading "test" [level=3] [ref=e340]
|
||||||
|
- paragraph [ref=e342]: sample file
|
||||||
|
- generic [ref=e343]: il y a environ 5 heures
|
||||||
|
- generic [ref=e344]:
|
||||||
|
- button "Pin" [ref=e345]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e346]:
|
||||||
|
- img
|
||||||
|
- button [ref=e347]:
|
||||||
|
- img
|
||||||
|
- 'button "New AI Framework Released Lien: <www.example.com> Date: 2026-01-04 Résumé: A new AI framework designed for optimized training and deployment of machine learning models has been released. The framework supports a variety of neural networks and is engineered for performance and scalability. It includes features for simplifying model deployment and enhancing capabilities for real-time inference. Pourquoi c’est IA/DevIA: - Conception d''un framework pour l''IA - Optimisation de l''entraînement des modèles - Support pour les réseaux de neurones - Prise en charge de l''inférence en temps réel Tags: framework, mlops, gpu, ai, tech tech ai framework mlops gpu il y a environ 5 heures" [ref=e348]':
|
||||||
|
- generic [ref=e349]:
|
||||||
|
- heading "New AI Framework Released" [level=3] [ref=e350]
|
||||||
|
- paragraph [ref=e351]: "Lien: <www.example.com> Date: 2026-01-04 Résumé: A new AI framework designed for optimized training and deployment of machine learning models has been released. The framework supports a variety of neural networks and is engineered for performance and scalability. It includes features for simplifying model deployment and enhancing capabilities for real-time inference. Pourquoi c’est IA/DevIA: - Conception d'un framework pour l'IA - Optimisation de l'entraînement des modèles - Support pour les réseaux de neurones - Prise en charge de l'inférence en temps réel Tags: framework, mlops, gpu, ai, tech"
|
||||||
|
- generic [ref=e352]:
|
||||||
|
- generic [ref=e353]: tech
|
||||||
|
- generic [ref=e354]: ai
|
||||||
|
- generic [ref=e355]: framework
|
||||||
|
- generic [ref=e356]: mlops
|
||||||
|
- generic [ref=e357]: gpu
|
||||||
|
- generic [ref=e358]: il y a environ 5 heures
|
||||||
|
- generic [ref=e359]:
|
||||||
|
- button "Pin" [ref=e360]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e361]:
|
||||||
|
- img
|
||||||
|
- button [ref=e362]:
|
||||||
|
- img
|
||||||
|
- button "Test Image API Note avec image il y a environ 8 heures" [ref=e363]:
|
||||||
|
- generic [ref=e364]:
|
||||||
|
- heading "Test Image API" [level=3] [ref=e365]
|
||||||
|
- paragraph [ref=e367]: Note avec image
|
||||||
|
- generic [ref=e368]: il y a environ 8 heures
|
||||||
|
- generic [ref=e369]:
|
||||||
|
- button "Pin" [ref=e370]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e371]:
|
||||||
|
- img
|
||||||
|
- button [ref=e372]:
|
||||||
|
- img
|
||||||
|
- button "Test Markdown Titre Modifié Sous-titre édité Liste modifiée 1 Liste modifiée 2 Nouvelle liste 3 Texte gras modifié et italique édité console.log(\"Code modifié avec succès!\") il y a environ 5 heures" [ref=e373]:
|
||||||
|
- generic [ref=e374]:
|
||||||
|
- heading "Test Markdown" [level=3] [ref=e375]
|
||||||
|
- generic [ref=e377]:
|
||||||
|
- heading "Titre Modifié" [level=1] [ref=e378]
|
||||||
|
- heading "Sous-titre édité" [level=2] [ref=e379]
|
||||||
|
- list [ref=e380]:
|
||||||
|
- listitem [ref=e381]: Liste modifiée 1
|
||||||
|
- listitem [ref=e382]: Liste modifiée 2
|
||||||
|
- listitem [ref=e383]: Nouvelle liste 3
|
||||||
|
- paragraph [ref=e384]:
|
||||||
|
- strong [ref=e385]: Texte gras modifié
|
||||||
|
- text: et
|
||||||
|
- emphasis [ref=e386]: italique édité
|
||||||
|
- code [ref=e388]: console.log("Code modifié avec succès!")
|
||||||
|
- generic [ref=e389]: il y a environ 5 heures
|
||||||
|
- generic [ref=e390]:
|
||||||
|
- button "Pin" [ref=e391]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e392]:
|
||||||
|
- img
|
||||||
|
- button [ref=e393]:
|
||||||
|
- img
|
||||||
|
- button "Test Image Avec image il y a environ 8 heures" [ref=e394]:
|
||||||
|
- generic [ref=e395]:
|
||||||
|
- heading "Test Image" [level=3] [ref=e396]
|
||||||
|
- paragraph [ref=e397]: Avec image
|
||||||
|
- generic [ref=e398]: il y a environ 8 heures
|
||||||
|
- generic [ref=e399]:
|
||||||
|
- button "Pin" [ref=e400]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e401]:
|
||||||
|
- img
|
||||||
|
- button [ref=e402]:
|
||||||
|
- img
|
||||||
|
- 'button "New AI Framework Released Lien: <www.example.com> Date: 2026-01-04 Résumé: A new AI framework designed for optimized training and deployment of machine learning models has been released. The framework supports a variety of neural networks and includes features that enhance computational efficiency and model accuracy. This release aims to simplify the integration of AI tools into existing systems, making it easier for developers to build robust AI applications. Pourquoi c’est IA/DevIA: - Optimized training methods for machine learning models. - Supports deployment across various hardware configurations. - Focus on enhancing computational efficiency and model performance. - Intended for use in real-world AI applications and development. Tags: tech, ai, framework, mlops, gpu tech ai framework mlops gpu il y a environ 6 heures" [ref=e403]':
|
||||||
|
- generic [ref=e404]:
|
||||||
|
- heading "New AI Framework Released" [level=3] [ref=e405]
|
||||||
|
- paragraph [ref=e406]: "Lien: <www.example.com> Date: 2026-01-04 Résumé: A new AI framework designed for optimized training and deployment of machine learning models has been released. The framework supports a variety of neural networks and includes features that enhance computational efficiency and model accuracy. This release aims to simplify the integration of AI tools into existing systems, making it easier for developers to build robust AI applications. Pourquoi c’est IA/DevIA: - Optimized training methods for machine learning models. - Supports deployment across various hardware configurations. - Focus on enhancing computational efficiency and model performance. - Intended for use in real-world AI applications and development. Tags: tech, ai, framework, mlops, gpu"
|
||||||
|
- generic [ref=e407]:
|
||||||
|
- generic [ref=e408]: tech
|
||||||
|
- generic [ref=e409]: ai
|
||||||
|
- generic [ref=e410]: framework
|
||||||
|
- generic [ref=e411]: mlops
|
||||||
|
- generic [ref=e412]: gpu
|
||||||
|
- generic [ref=e413]: il y a environ 6 heures
|
||||||
|
- generic [ref=e414]:
|
||||||
|
- button "Pin" [ref=e415]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e416]:
|
||||||
|
- img
|
||||||
|
- button [ref=e417]:
|
||||||
|
- img
|
||||||
|
- button "Test Note This is my first note to test the Google Keep clone! il y a environ 10 heures" [ref=e418]:
|
||||||
|
- generic [ref=e419]:
|
||||||
|
- img [ref=e420]
|
||||||
|
- heading "Test Note" [level=3] [ref=e423]
|
||||||
|
- paragraph [ref=e424]: This is my first note to test the Google Keep clone!
|
||||||
|
- generic [ref=e425]: il y a environ 10 heures
|
||||||
|
- generic [ref=e426]:
|
||||||
|
- button "Pin" [ref=e427]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e428]:
|
||||||
|
- img
|
||||||
|
- button [ref=e429]:
|
||||||
|
- img
|
||||||
|
- button "Titre Modifié Contenu modifié avec succès! il y a environ 5 heures" [ref=e430]:
|
||||||
|
- generic [ref=e431]:
|
||||||
|
- heading "Titre Modifié" [level=3] [ref=e432]
|
||||||
|
- paragraph [ref=e433]: Contenu modifié avec succès!
|
||||||
|
- generic [ref=e434]: il y a environ 5 heures
|
||||||
|
- generic [ref=e435]:
|
||||||
|
- button "Pin" [ref=e436]:
|
||||||
|
- img
|
||||||
|
- button "Change color" [ref=e437]:
|
||||||
|
- img
|
||||||
|
- button [ref=e438]:
|
||||||
|
- img
|
||||||
|
- status [ref=e439]
|
||||||
|
- button "Open Next.js Dev Tools" [ref=e445] [cursor=pointer]:
|
||||||
|
- img [ref=e446]
|
||||||
|
- alert [ref=e449]
|
||||||
|
```
|
||||||