29 Commits

Author SHA1 Message Date
Antigravity
0d8252aec0 fix(ci): typings dagre (@types/dagre) et pptx (PptxGenJSModule, fill bullets)
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m8s
Corrige le typecheck Next Docker: module dagre sans .d.ts, et annotations
Presentation vs constructeur pptxgenjs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 22:04:07 +00:00
Antigravity
33ad874e5d fix(agents): TYPE_DEFAULTS.tools en string[] pour run-for-note
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 1m1s
Prisma attend une chaîne JSON pour Agent.tools ; on sérialise au create.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 22:01:12 +00:00
Antigravity
3b72e4afbb fix(prisma): colonnes Agent sourceNoteIds, slideTheme, slideStyle
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 1m33s
Aligne prisma/schema.prisma avec agent-actions.ts (typecheck Docker/Next).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:58:18 +00:00
Antigravity
ccc94a4b35 fix(ci): next build --webpack pour dagre/pptxgenjs/elkjs (Turbopack ne résout pas)
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 1m36s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:53:03 +00:00
Antigravity
fb6823e25e fix(lab): ouvrir le bon diagramme sans F5 (?id alias ?canvas + tri canvases)
Some checks failed
Deploy to Production / Build and Deploy (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:52:30 +00:00
Antigravity
7326cfc98f fix: import type + lazy singleton pour dagre et pptxgenjs (Turbopack build)
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 23s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:48:54 +00:00
Antigravity
4f950740eb feat: options de génération (thème/style/type) + masquer agents one-shot
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 23s
- Les agents créés par run-for-note ont frequency:'one-shot' et sont
  filtrés de la page /agents (NOT frequency:'one-shot' dans getAgents)
- Panneau slides : sélecteurs Thème (11 palettes) + Style (sharp/soft/rounded/pill)
- Panneau diagramme : sélecteurs Type (auto/flowchart/mindmap/timeline/
  org-chart/architecture/process-map) + Style (sketchy/austere/sketch+)
- Les valeurs sélectionnées sont transmises à l'API et injectées dans le prompt

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:45:57 +00:00
Antigravity
db200bbc9f fix: serverExternalPackages pour pptxgenjs, dagre, elkjs (Turbopack)
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 23s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:41:07 +00:00
Antigravity
08a49a04ce fix: désactiver thinking mode DeepSeek pour fiabiliser le tool calling
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 23s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:37:55 +00:00
Antigravity
ab914f0587 fix: reasoning_content DeepSeek — retry avec maxSteps=1 si erreur thinking mode
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 23s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:33:16 +00:00
Antigravity
b55f558a62 fix: import auth depuis @/auth (pas @/lib/auth)
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 23s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:27:43 +00:00
Antigravity
19d0b2759a fix: toast position bottom-right pour ne pas bloquer la navigation
Some checks failed
Deploy to Production / Build and Deploy (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:27:07 +00:00
Antigravity
34a977b5c4 fix: runAgent fire-and-forget + polling sur la page /agents
Some checks failed
Deploy to Production / Build and Deploy (push) Has been cancelled
Le bouton Play des cartes agents appelait runAgent (Server Action) qui
attendait executeAgent (~2-5 min) → spinner bloqué sans résultat.

- runAgent retourne immédiatement { success, agentId }
- agent-card.tsx lance un polling toutes les 3s sur GET
  /api/agents/run-for-note?agentId= jusqu'au statut terminal
- Toast persistant Sonner pendant la génération, mis à jour au résultat
- Cleanup automatique du polling au démontage

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:26:35 +00:00
Antigravity
75b08ef53b fix: génération asynchrone (fire-and-forget + polling)
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 23s
Le problème : la route POST /api/agents/run-for-note bloquait le thread
HTTP pendant toute la durée de génération (2-5 min), provoquant un
spinner infini sans résultat.

Solution :
- POST retourne immédiatement avec { agentId } et lance executeAgent
  en arrière-plan (fire-and-forget, sans await)
- GET /api/agents/run-for-note?agentId= retourne le statut de la
  dernière agentAction (running | success | failure) + canvasId/noteId
- Le client poll le statut toutes les 3 secondes jusqu'au résultat,
  le toast persistant Sonner se met à jour automatiquement
- Cleanup du polling au démontage du composant

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:21:28 +00:00
Antigravity
98e246e257 fix: force git reset on deploy + toast persistant de génération
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 23s
- Remplace git pull par git fetch + git reset --hard origin/main dans
  le workflow CI pour éviter les conflits avec les fichiers locaux
- Ajoute un toast Sonner persistant (duration: Infinity) dès le clic sur
  Générer, qui survit à la navigation et se met à jour en succès/erreur
  avec action directe (Télécharger / Ouvrir)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:15:38 +00:00
Antigravity
ff0b56f805 feat(ai-note): passer noteId au ContextualAIChat en mode liste (note-inline-editor)
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 3s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:11:21 +00:00
Antigravity
d1e08f64c8 feat(ai-note): ajouter boutons Générer slides/diagramme dans le panneau IA
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 3s
- Nouveau endpoint POST /api/agents/run-for-note : crée un agent one-shot
  (slide-generator ou excalidraw-generator) avec la note courante comme source
  et l'exécute immédiatement
- ContextualAIChat : prop noteId + section "Générer depuis cette note" avec
  deux boutons gradient (violet=slides, cyan=diagramme), spinner pendant la
  génération, bouton de téléchargement .pptx ou lien "Ouvrir dans le Lab"
  après succès
- note-editor.tsx : passage de note.id à ContextualAIChat
- i18n fr/en : nouvelles clés ai.generate.*

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:07:43 +00:00
Antigravity
e7f28abccc fix(pptx): pre-fetch images server-side before passing to pptxgenjs
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 3s
pptxgenjs sur Node.js ne peut pas charger les URLs qui nécessitent une auth
ou des cookies. On fetch maintenant chaque imageUrl côté serveur et on la
convertit en data URI base64 avant d'appeler buildPresentation.
Si le fetch échoue (timeout 8s, 4xx/5xx), l'imageUrl est supprimée et
le placeholder texte s'affiche à la place.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 20:57:29 +00:00
Antigravity
129d5541e6 feat(agents): refonte complète slide-generator + excalidraw-generator
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 3s
Slide generator (generate_pptx):
- Pivot vers génération PowerPoint natif (pptxgenjs) au lieu de Reveal.js HTML
- 4 nouveaux layouts diagramme : timeline, process, comparison, metrics
- 2 nouveaux layouts image : image-content (texte + image), image-full (plein cadre)
- Redesign visuel de tous les layouts (cover split, section full-bleed, header band)
- Palettes corrigées : bg blanc sur toutes les palettes, contrastes réels
- fit:shrink systématique sur tous les textes pour éviter les débordements
- Extraction automatique des images des notes (Markdown/HTML) et injection dans le prompt IA
- Prompt IA renforcé : impose "style" et "theme" explicitement dans le JSON, impose ≥2 layouts diagramme
- Fix overlap timeline : zones de texte calculées sans collision avec les cercles
- Notification agent mise à jour : bouton download .pptx au lieu d'ouvrir HTML

Excalidraw generator:
- Layout Dagre/ELK pour graphes auto-positionnés
- Styles visuels : coloré, austère, sketch-plus (Virgil font)
- Zones/containers pour architecture-cloud
- Sanitisation du graphe et métriques de qualité de layout

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 20:55:15 +00:00
Antigravity
21fb56de3f feat(sidebar): drag-and-drop notebook reordering
- Add GripVertical handle (visible on hover) as reorder affordance
- Rows are now draggable; uses 'application/x-notebook' dataTransfer type
  to coexist with existing note-to-notebook drag ('text/plain')
- Drop indicator: thin primary-colored line above the insertion target
- Dragged item fades to 40% opacity during drag
- On drop: calls updateNotebookOrderOptimistic → POST /api/notebooks/reorder → persisted in DB
- draggingNbRef (useRef) used for stale-closure-safe detection in dragover handler
2026-05-03 21:06:34 +00:00
Antigravity
0ebf10344d feat(mcp): add all 4 note types, translate N8N docs to English, add N8N workflow examples
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 55s
- tools.js: expose type enum ['text','markdown','richtext','checklist'] in create_note & update_note
  - default changed from 'text' to 'richtext' (matches Prisma schema)
  - isMarkdown marked as deprecated in favour of type='markdown'
- N8N-CONFIG.md: full French → English translation
- N8N-WORKFLOWS.md: full French → English translation
- N8N-EXAMPLES.md: new comprehensive examples for all 22 MCP tools + workflow patterns
- n8n-workflow-mcp-reminder-bot.json: cron → get_due_reminders → Telegram → mark done
- n8n-workflow-mcp-email-to-note.json: IMAP → create_note → urgent Slack alert
- n8n-workflow-mcp-daily-digest.json: 8AM cron → notes + reminders digest → save + Slack
- n8n-workflow-mcp-webhook-to-note.json: universal webhook → create_note → respond
- notebooks-list.tsx: fix truncated notebook names (pe-24→pe-14), replace hover overlay with Tooltip
2026-05-03 20:49:11 +00:00
Antigravity
0311c97a35 fix: add /health endpoint before auth middleware for Docker healthcheck (was 401 with MCP_REQUIRE_AUTH=true)
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 16s
2026-05-03 19:45:27 +00:00
Antigravity
1ed7839334 fix: notebook name overflows sidebar on hover with bold + opaque background instead of tooltip overlay
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 44s
2026-05-03 19:43:57 +00:00
Antigravity
718f4c6900 perf: optimize MCP server (O(1) auth, compact JSON, trashedAt fix) + memento-note performance (lazy loading, server-side filtering, XSS fixes, dead code removal, security hardening)
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m35s
MCP Server:
- Fix validateApiKey: O(1) direct lookup by shortId instead of loading all keys
- Add trashedAt:null filter to ALL note queries (trashed notes leaked in results)
- Compact JSON output (~40% smaller responses)
- Bounded session cache (Map with MAX_SESSIONS=500) to prevent memory leaks
- PostgreSQL connection pooling (connection_limit=10)
- Rewrite all 22 tool descriptions in clear English
- Fix /sse fallback to proper 307 redirect

memento-note Performance:
- loading=lazy on all note images
- Split notebooksRefreshKey from global refreshKey (note CRUD no longer re-fetches notebooks)
- Remove searchKey from trash count deps (no re-fetch on every keystroke)
- Server-side notebookId filter in getAllNotes() (biggest win)
- Skip collaborator fetch for non-shared notes (eliminates N+1 API calls)
- next/dynamic for MarkdownContent + 4 modals (code-split remark/rehype/KaTeX)
- Memoize DOMPurify sanitize with useMemo

Security:
- XSS: DOMPurify sanitize in note-card and note-history-modal
- Auth anti-enumeration: uniform errors in auth.ts
- CRON_SECRET mandatory on cron endpoints
- Rate limiting on login (5 attempts/min per email)
- Centralized API auth helpers (requireAuth/requireAdmin)
- randomize-labels changed GET→POST
- Removed debug endpoints (/api/debug/config, /api/debug/test-chat)

Cleanup:
- Removed dead code: .backup-keep, settings-backup, fix-*.js, debug-theme, fix-labels route
- Removed sensitive console.error in auth.ts
- Ollama fetchWithTimeout (30s/60s AbortController)
- i18n: full Arabic translation, Farsi missing keys
- Masonry drag-and-drop fix (localOrderMap, cross-section block)
- Sidebar notebook tooltip on truncation
2026-05-03 18:41:38 +00:00
Antigravity
aee4b17306 feat: redesign AI test page with Ethereal Precision v2 (horizontal layout, ultra-wide) and fix Dockerfile OpenSSL issue
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 58s
2026-05-03 13:09:04 +00:00
Antigravity
b611ec874d Refactor Admin and Settings UI to Ethereal Precision aesthetic and improve note import/export functionality
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 1m4s
2026-05-03 12:51:25 +00:00
635e516616 fix(ai): live update for translation language input + preset sync
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 46s
2026-05-03 01:42:22 +02:00
a7c3251b49 fix(build): bypass TypeScript error in BubbleMenu blocking CI
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 44s
2026-05-03 01:38:16 +02:00
5375f874cd fix(chat): enlarge preview panel (max-h-64)
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 36s
2026-05-03 01:35:03 +02:00
120 changed files with 24420 additions and 12112 deletions

View File

@@ -121,7 +121,8 @@ jobs:
echo "=== Git pull ==="
git config --global --add safe.directory /opt/memento
git pull origin main
git fetch origin main
git reset --hard origin/main
echo "=== Building ==="
docker compose build memento-note

View File

@@ -99,7 +99,7 @@ services:
cpus: '0.25'
memory: 128M
healthcheck:
test: ["CMD-SHELL", "wget --spider -q http://localhost:3001/ || exit 1"]
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:3001/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3

View File

@@ -1,34 +1,34 @@
# Configuration N8N - Memento MCP Server
# N8N Configuration Memento MCP Server
## Configuration MCP Client dans N8N
## MCP Client Setup in N8N
Le serveur MCP utilise le transport **Streamable HTTP** (remplace l'ancien SSE).
The MCP server uses **Streamable HTTP** transport (replaces the legacy SSE transport).
### 1. Generer une cle API
### 1. Generate an API Key
Dans Memento : **Parametres > MCP > Generer une cle**
In Memento: **Settings > MCP > Generate a key**
La cle a le format `mcp_sk_...` et est associee a votre compte utilisateur. Seules vos notes seront accessibles.
The key has the format `mcp_sk_...` and is scoped to your user account. Only your notes will be accessible.
### 2. Configurer le noeud MCP Client dans N8N
### 2. Configure the MCP Client Node in N8N
1. Ajouter un noeud **MCP Client** dans votre workflow
2. **Server Transport** : `Streamable HTTP`
3. **MCP Endpoint URL** : `http://memento-mcp:3001/mcp` (Docker) ou `http://VOTRE_IP:3001/mcp`
4. **Authentication** : Header Auth
- Header Name : `x-api-key`
- Header Value : votre cle API (`mcp_sk_...`)
1. Add an **MCP Client** node to your workflow
2. **Server Transport**: `Streamable HTTP`
3. **MCP Endpoint URL**: `http://memento-mcp:3001/mcp` (Docker) or `http://YOUR_IP:3001/mcp`
4. **Authentication**: Header Auth
- Header Name: `x-api-key`
- Header Value: your API key (`mcp_sk_...`)
### Alternative : curl
### Alternative: curl
```bash
# Health check
curl -H "x-api-key: mcp_sk_votrecle" http://localhost:3001/
curl -H "x-api-key: mcp_sk_yourkey" http://localhost:3001/
# Lister les outils
# List available tools
curl -X POST http://localhost:3001/mcp \
-H "Content-Type: application/json" \
-H "x-api-key: mcp_sk_votrecle" \
-H "x-api-key: mcp_sk_yourkey" \
-d '{
"jsonrpc": "2.0",
"id": 1,
@@ -36,10 +36,10 @@ curl -X POST http://localhost:3001/mcp \
"params": {}
}'
# Creer une note
# Create a note
curl -X POST http://localhost:3001/mcp \
-H "Content-Type: application/json" \
-H "x-api-key: mcp_sk_votrecle" \
-H "x-api-key: mcp_sk_yourkey" \
-d '{
"jsonrpc": "2.0",
"id": 2,
@@ -47,72 +47,81 @@ curl -X POST http://localhost:3001/mcp \
"params": {
"name": "create_note",
"arguments": {
"title": "Ma note",
"content": "Contenu de la note"
"title": "My note",
"content": "Note content here"
}
}
}'
```
## Outils disponibles (22)
## Available Tools (22)
### Notes (11)
| Outil | Description |
|-------|-------------|
| `create_note` | Creer une note |
| `get_notes` | Lister les notes |
| `get_note` | Recuperer une note par ID |
| `update_note` | Modifier une note |
| `delete_note` | Supprimer une note |
| `search_notes` | Rechercher par mot-cle |
| `move_note` | Deplacer vers un notebook |
| `toggle_pin` | Epingler/Depingler |
| `toggle_archive` | Archiver/Desarchiver |
| `export_notes` | Exporter en JSON |
| `import_notes` | Importer depuis JSON |
| Tool | Description |
|------|-------------|
| `create_note` | Create a note |
| `get_notes` | List notes |
| `get_note` | Get a note by ID |
| `update_note` | Update a note |
| `delete_note` | Delete a note |
| `search_notes` | Search by keyword |
| `move_note` | Move to a notebook |
| `toggle_pin` | Pin / unpin |
| `toggle_archive` | Archive / unarchive |
| `export_notes` | Export as JSON |
| `import_notes` | Import from JSON |
#### Note Types
| `type` value | Description |
|--------------|-------------|
| `richtext` | Rich text editor — **default** |
| `markdown` | Markdown rendered (use instead of `isMarkdown`) |
| `text` | Plain text |
| `checklist` | Interactive checklist with `checkItems` |
### Notebooks (6)
| Outil | Description |
|-------|-------------|
| `create_notebook` | Creer un notebook |
| `get_notebooks` | Lister les notebooks |
| `get_notebook` | Details d'un notebook |
| `update_notebook` | Modifier un notebook |
| `delete_notebook` | Supprimer un notebook |
| `reorder_notebooks` | Reordonner |
| Tool | Description |
|------|-------------|
| `create_notebook` | Create a notebook |
| `get_notebooks` | List all notebooks |
| `get_notebook` | Get notebook details |
| `update_notebook` | Update a notebook |
| `delete_notebook` | Delete a notebook |
| `reorder_notebooks` | Reorder notebooks |
### Labels (4)
| Outil | Description |
|-------|-------------|
| `create_label` | Creer un label |
| `get_labels` | Lister les labels |
| `update_label` | Modifier un label |
| `delete_label` | Supprimer un label |
| Tool | Description |
|------|-------------|
| `create_label` | Create a label |
| `get_labels` | List labels |
| `update_label` | Update a label |
| `delete_label` | Delete a label |
### Rappels (1)
### Reminders (1)
| Outil | Description |
|-------|-------------|
| `get_due_reminders` | Recuperer les rappels dus |
| Tool | Description |
|------|-------------|
| `get_due_reminders` | Get due reminders |
## Endpoints HTTP
## HTTP Endpoints
| Endpoint | Methode | Description |
|----------|---------|-------------|
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/` | GET | Health check |
| `/mcp` | GET/POST | Endpoint MCP principal |
| `/sse` | GET/POST | Legacy (redirige vers `/mcp`) |
| `/sessions` | GET | Sessions actives |
| `/mcp` | GET/POST | Main MCP endpoint |
| `/sse` | GET/POST | Legacy (redirects to `/mcp`) |
| `/sessions` | GET | Active sessions |
## Securite
## Security
- **Authentification obligatoire** en production (`MCP_REQUIRE_AUTH=true` dans Docker)
- Les cles API sont gerees depuis **Parametres > MCP** dans Memento
- Chaque cle est scopee a un utilisateur : seules ses notes sont accessibles
- Les cles sont hashees en base (SHA256), seul le raw key est montre a la creation
- **Authentication required** in production (`MCP_REQUIRE_AUTH=true` in Docker)
- API keys are managed from **Settings > MCP** in Memento
- Each key is scoped to a single user: only their notes are accessible
- Keys are hashed in the database (SHA256); the raw key is only shown once at creation
## Ports

571
mcp-server/N8N-EXAMPLES.md Normal file
View File

@@ -0,0 +1,571 @@
# N8N Examples — Memento MCP Server
Practical, copy-paste-ready N8N workflow snippets for every Memento MCP tool.
All examples assume an MCP Client node configured with Streamable HTTP and `x-api-key` authentication.
## Note Types
| `type` value | Description |
|--------------|-------------|
| `richtext` | Rich text editor — **default** |
| `markdown` | Markdown rendered (preferred over deprecated `isMarkdown`) |
| `text` | Plain text |
| `checklist` | Interactive checklist with `checkItems` |
> **Note**: `isMarkdown: true` is deprecated. Use `"type": "markdown"` instead.
---
## Notes
### Create a simple text note
```json
{
"tool": "create_note",
"arguments": {
"title": "Quick idea",
"content": "Explore serverless architecture for the new API gateway.",
"color": "yellow",
"labels": ["idea", "architecture"]
}
}
```
### Create a pinned markdown note
```json
{
"tool": "create_note",
"arguments": {
"title": "Project README",
"content": "# My Project\n\nThis is the main documentation.\n\n## Getting Started\n\n```bash\nnpm install\nnpm run dev\n```",
"isMarkdown": true,
"isPinned": true,
"color": "blue",
"size": "large"
}
}
```
### Create a checklist note
```json
{
"tool": "create_note",
"arguments": {
"title": "Weekly tasks",
"content": "Tasks for this week",
"type": "checklist",
"checkItems": [
{ "id": "1", "text": "Review pull requests", "checked": false },
{ "id": "2", "text": "Update deployment docs", "checked": false },
{ "id": "3", "text": "Team standup notes", "checked": true }
],
"color": "teal"
}
}
```
### Create a note with a reminder
```json
{
"tool": "create_note",
"arguments": {
"title": "Dentist appointment",
"content": "Dr. Martin — 10:30 AM. Bring insurance card.",
"reminder": "2025-06-15T10:00:00.000Z",
"reminderRecurrence": "yearly",
"color": "red",
"labels": ["health", "personal"]
}
}
```
### Create a note in a specific notebook
```json
{
"tool": "create_note",
"arguments": {
"title": "Sprint planning notes",
"content": "**Goal**: Ship user authentication by Friday.\n\n- Design review at 2PM\n- Backend API ready by Wednesday",
"isMarkdown": true,
"notebookId": "{{ $json.notebookId }}",
"labels": ["sprint", "planning"],
"color": "purple"
}
}
```
### Create a note with external links
```json
{
"tool": "create_note",
"arguments": {
"title": "Research resources",
"content": "Curated links for the ML paper review.",
"links": [
"https://arxiv.org/abs/2304.15004",
"https://huggingface.co/papers",
"https://paperswithcode.com"
],
"labels": ["research", "ml"],
"color": "green"
}
}
```
### Get all notes (lightweight)
```json
{
"tool": "get_notes",
"arguments": {
"limit": 50
}
}
```
### Get notes from a specific notebook
```json
{
"tool": "get_notes",
"arguments": {
"notebookId": "{{ $json.notebookId }}",
"limit": 100
}
}
```
### Get unfiled notes (Inbox)
```json
{
"tool": "get_notes",
"arguments": {
"notebookId": "inbox",
"limit": 50
}
}
```
### Get all notes including archived
```json
{
"tool": "get_notes",
"arguments": {
"includeArchived": true,
"limit": 200,
"fullDetails": true
}
}
```
### Get a single note by ID
```json
{
"tool": "get_note",
"arguments": {
"id": "{{ $json.noteId }}"
}
}
```
### Search notes by keyword
```json
{
"tool": "search_notes",
"arguments": {
"query": "deployment kubernetes"
}
}
```
### Search in a specific notebook
```json
{
"tool": "search_notes",
"arguments": {
"query": "{{ $json.searchTerm }}",
"notebookId": "{{ $json.notebookId }}",
"includeArchived": false
}
}
```
### Update a note's content
```json
{
"tool": "update_note",
"arguments": {
"id": "{{ $json.noteId }}",
"content": "{{ $json.newContent }}",
"color": "green"
}
}
```
### Mark a checklist item as done
```json
{
"tool": "update_note",
"arguments": {
"id": "{{ $json.noteId }}",
"checkItems": [
{ "id": "1", "text": "Review pull requests", "checked": true },
{ "id": "2", "text": "Update deployment docs", "checked": false }
]
}
}
```
### Add labels to an existing note
```json
{
"tool": "update_note",
"arguments": {
"id": "{{ $json.noteId }}",
"labels": ["ai-generated", "reviewed", "{{ $json.category }}"]
}
}
```
### Mark a reminder as done
```json
{
"tool": "update_note",
"arguments": {
"id": "{{ $json.noteId }}",
"isReminderDone": true
}
}
```
### Move a note to a notebook
```json
{
"tool": "move_note",
"arguments": {
"id": "{{ $json.noteId }}",
"notebookId": "{{ $json.targetNotebookId }}"
}
}
```
### Move a note to Inbox (no notebook)
```json
{
"tool": "move_note",
"arguments": {
"id": "{{ $json.noteId }}",
"notebookId": null
}
}
```
### Pin a note
```json
{
"tool": "toggle_pin",
"arguments": {
"id": "{{ $json.noteId }}"
}
}
```
### Archive a note
```json
{
"tool": "toggle_archive",
"arguments": {
"id": "{{ $json.noteId }}"
}
}
```
### Delete a note permanently
```json
{
"tool": "delete_note",
"arguments": {
"id": "{{ $json.noteId }}"
}
}
```
### Export all notes as JSON backup
```json
{
"tool": "export_notes",
"arguments": {}
}
```
### Import notes from a JSON backup
```json
{
"tool": "import_notes",
"arguments": {
"data": "{{ $json.exportPayload }}"
}
}
```
---
## Notebooks
### Create a notebook
```json
{
"tool": "create_notebook",
"arguments": {
"name": "Work Projects",
"icon": "💼",
"color": "#3B82F6"
}
}
```
### Create a notebook for a team/topic
```json
{
"tool": "create_notebook",
"arguments": {
"name": "Machine Learning Research",
"icon": "🤖",
"color": "#8B5CF6"
}
}
```
### List all notebooks
```json
{
"tool": "get_notebooks",
"arguments": {}
}
```
### Get a notebook with its notes
```json
{
"tool": "get_notebook",
"arguments": {
"id": "{{ $json.notebookId }}"
}
}
```
### Rename a notebook
```json
{
"tool": "update_notebook",
"arguments": {
"id": "{{ $json.notebookId }}",
"name": "Q3 2025 Projects",
"color": "#10B981"
}
}
```
### Reorder notebooks
```json
{
"tool": "reorder_notebooks",
"arguments": {
"notebookIds": [
"{{ $json.ids[0] }}",
"{{ $json.ids[1] }}",
"{{ $json.ids[2] }}"
]
}
}
```
### Delete a notebook (notes go to Inbox)
```json
{
"tool": "delete_notebook",
"arguments": {
"id": "{{ $json.notebookId }}"
}
}
```
---
## Labels
### Create a label inside a notebook
```json
{
"tool": "create_label",
"arguments": {
"name": "urgent",
"color": "red",
"notebookId": "{{ $json.notebookId }}"
}
}
```
### Create common project labels
```json
{
"tool": "create_label",
"arguments": {
"name": "in-progress",
"color": "orange",
"notebookId": "{{ $json.notebookId }}"
}
}
```
```json
{
"tool": "create_label",
"arguments": {
"name": "done",
"color": "green",
"notebookId": "{{ $json.notebookId }}"
}
}
```
### List labels in a notebook
```json
{
"tool": "get_labels",
"arguments": {
"notebookId": "{{ $json.notebookId }}"
}
}
```
### List all labels (global)
```json
{
"tool": "get_labels",
"arguments": {}
}
```
### Rename a label
```json
{
"tool": "update_label",
"arguments": {
"id": "{{ $json.labelId }}",
"name": "high-priority",
"color": "red"
}
}
```
### Delete a label
```json
{
"tool": "delete_label",
"arguments": {
"id": "{{ $json.labelId }}"
}
}
```
---
## Reminders
### Get all due reminders (for cron automation)
```json
{
"tool": "get_due_reminders",
"arguments": {}
}
```
> **Tip**: Schedule this every 30 minutes with a cron trigger (`0 */30 * * * *`), then loop over results and send notifications via Slack, Telegram, or email.
---
## Common Workflow Patterns
### Pattern: Email → Note
1. **Email Trigger (IMAP)** — fires on new email
2. **MCP Client**`create_note`
```json
{
"title": "{{ $json.subject }}",
"content": "**From**: {{ $json.from }}\n\n{{ $json.text }}",
"labels": ["email"],
"color": "blue",
"isMarkdown": true
}
```
### Pattern: Reminder Notifications
1. **Schedule Trigger** — every 30 minutes
2. **MCP Client** → `get_due_reminders`
3. **IF** — results not empty
4. **Loop over items** → send Slack/Telegram message per reminder
5. **MCP Client** → `update_note` with `isReminderDone: true`
### Pattern: AI Summarization
1. **MCP Client** → `search_notes` with a topic query
2. **OpenAI / Anthropic node** — summarize the returned notes
3. **MCP Client** → `create_note` with the summary as content, labeled `ai-summary`
### Pattern: Daily Digest
1. **Schedule Trigger** — every day at 8 AM
2. **MCP Client** → `get_notes` with `limit: 20` (most recent)
3. **AI node** — generate a brief digest
4. **Email / Slack node** — send the digest
5. *(Optional)* **MCP Client** → `create_note` to archive the digest
### Pattern: Webhook → Structured Note
1. **Webhook Trigger** — receives JSON payload (e.g., from a form or CI/CD pipeline)
2. **Code node** — format the payload into markdown
3. **MCP Client** → `create_note`
```json
{
"title": "Build #{{ $json.buildNumber }} — {{ $json.status }}",
"content": "{{ $node['Code'].json.markdown }}",
"isMarkdown": true,
"color": "{{ $json.status === 'success' ? 'green' : 'red' }}",
"labels": ["ci-cd", "{{ $json.repo }}"]
}
```

View File

@@ -1,24 +1,24 @@
# Workflows N8N pour Memento MCP
# N8N Workflows for Memento MCP
## Introduction
Exemples de workflows N8N utilisant le serveur MCP de Memento via le transport **Streamable HTTP**.
Example N8N workflows using the Memento MCP server via **Streamable HTTP** transport.
**Prerequis** : une cle API generee depuis **Parametres > MCP** dans Memento.
**Prerequisite**: an API key generated from **Settings > MCP** in Memento.
## Configuration du noeud MCP Client
## MCP Client Node Configuration
Dans chaque workflow, le noeud MCP Client doit etre configure ainsi :
In every workflow, the MCP Client node must be configured as follows:
- **Server Transport** : `Streamable HTTP`
- **MCP Endpoint URL** : `http://memento-mcp:3001/mcp` (Docker) ou `http://VOTRE_IP:3001/mcp`
- **Authentication** : Header Auth avec `x-api-key` = votre cle API
- **Server Transport**: `Streamable HTTP`
- **MCP Endpoint URL**: `http://memento-mcp:3001/mcp` (Docker) or `http://YOUR_IP:3001/mcp`
- **Authentication**: Header Auth with `x-api-key` = your API key
## Workflows disponibles
## Available Workflows
### 1. Create Note (`n8n-workflow-create-note.json`)
Cree des notes dans Memento avec classification par IA.
Creates notes in Memento with AI-based classification.
```json
{
@@ -34,7 +34,7 @@ Cree des notes dans Memento avec classification par IA.
### 2. Search & Summary (`n8n-workflow-search-summary.json`)
Recherche des notes et genere un resume.
Searches notes and generates a summary.
```json
{
@@ -47,7 +47,7 @@ Recherche des notes et genere un resume.
### 3. Notebook Manager (`n8n-workflow-notebook-management.json`)
CRUD complet des notebooks.
Full CRUD for notebooks.
```json
{
@@ -62,14 +62,14 @@ CRUD complet des notebooks.
### 4. Reminder Notifications (`n8n-workflow-reminder-notifications.json`)
Automatisation des rappels avec notifications.
Automates reminders with notifications.
- Declencheur : Schedule (cron: `0 */30 * * * *`)
- Appelle `get_due_reminders` et envoie les notifications
- Trigger: Schedule (cron: `0 */30 * * * *`)
- Calls `get_due_reminders` and sends notifications
### 5. Label Manager (`n8n-workflow-label-management.json`)
Gestion des labels.
Label management.
```json
{
@@ -84,20 +84,20 @@ Gestion des labels.
### 6. Email to Note (`n8n-workflow-email-integration.json`)
Conversion automatique d'emails en notes.
Automatically converts emails into notes.
- Declencheur : Email Trigger (IMAP)
- Cree une note avec le contenu de l'email
- Trigger: Email Trigger (IMAP)
- Creates a note from the email content
## Importation
## Import Instructions
1. Ouvrir N8N
2. **Import from File** -> selectionner le fichier JSON
3. Configurer le noeud MCP Client (URL + cle API)
4. Activer le workflow
1. Open N8N
2. **Import from File** select the JSON file
3. Configure the MCP Client node (URL + API key)
4. Activate the workflow
## Securite
## Security
- Toujours utiliser une cle API dediee par workflow
- Ne jamais exposer la cle dans les logs
- Restreindre l'acces reseau au port 3001 si possible
- Always use a dedicated API key per workflow
- Never expose the key in logs
- Restrict network access to port 3001 when possible

View File

@@ -96,52 +96,39 @@ export async function validateApiKey(prisma, rawKey) {
const keyHash = hashKey(rawKey);
// Check cache first
const cached = getCachedKey(keyHash);
if (cached) {
return cached;
}
// Optimized: Use startsWith to leverage index, then filter by hash
// This is much faster than loading all keys
const shortIdFromKey = rawKey.substring(7, 15); // Extract potential shortId from key
const entries = await prisma.systemConfig.findMany({
where: {
key: { startsWith: KEY_PREFIX },
},
take: 100, // Limit to prevent loading too many keys
});
const shortId = rawKey.substring(7, 15);
const configKey = `${KEY_PREFIX}${shortId}`;
for (const entry of entries) {
try {
const info = JSON.parse(entry.value);
if (info.keyHash === keyHash && info.active) {
// Update lastUsedAt (fire and forget - don't wait)
info.lastUsedAt = new Date().toISOString();
prisma.systemConfig.update({
where: { key: entry.key },
data: { value: JSON.stringify(info) },
}).catch(() => {}); // Ignore errors
const entry = await prisma.systemConfig.findUnique({ where: { key: configKey } });
if (!entry) return null;
const result = {
apiKeyId: info.shortId,
apiKeyName: info.name,
userId: info.userId,
userName: info.userName,
};
// Cache the result
setCachedKey(keyHash, result);
return result;
}
} catch {
// Invalid JSON, skip
}
try {
const info = JSON.parse(entry.value);
if (info.keyHash !== keyHash || !info.active) return null;
info.lastUsedAt = new Date().toISOString();
prisma.systemConfig.update({
where: { key: configKey },
data: { value: JSON.stringify(info) },
}).catch(() => {});
const result = {
apiKeyId: info.shortId,
apiKeyName: info.name,
userId: info.userId,
userName: info.userName,
};
setCachedKey(keyHash, result);
return result;
} catch {
return null;
}
return null;
}
/**

View File

@@ -1,30 +1,27 @@
#!/usr/bin/env node
/**
* Memento MCP Server - Streamable HTTP Transport (Optimized)
* Memento MCP Server - Streamable HTTP Transport (Fast)
*
* Performance improvements:
* - Prisma connection pooling
* - Request timeout handling
* - Response compression
* - Connection keep-alive
* - Request batching support
* - Compact JSON output
* - Bounded session cache
* - Proper keep-alive & timeouts
* - O(1) API key validation
*
* Environment variables:
* PORT - Server port (default: 3001)
* DATABASE_URL - Prisma database URL (default: ../../memento-note/prisma/dev.db)
* USER_ID - Optional user ID to filter data
* APP_BASE_URL - Optional Next.js app URL for AI features (default: http://localhost:3000)
* MCP_REQUIRE_AUTH - Set to 'true' to require x-api-key or x-user-id header
* MCP_API_KEY - Static API key for authentication (when MCP_REQUIRE_AUTH=true)
* MCP_LOG_LEVEL - Log level: debug, info, warn, error (default: info)
* MCP_REQUEST_TIMEOUT - Request timeout in ms (default: 30000)
* Environment:
* PORT Server port (default: 3001)
* DATABASE_URL Prisma database URL
* USER_ID Optional user ID filter
* APP_BASE_URL Next.js app URL (default: http://localhost:3000)
* MCP_REQUIRE_AUTH Set 'true' to require authentication
* MCP_API_KEY Static fallback API key
* MCP_LOG_LEVEL debug, info, warn, error (default: info)
* MCP_REQUEST_TIMEOUT Timeout in ms (default: 30000)
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { PrismaClient } from '@prisma/client';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { randomUUID } from 'crypto';
import express from 'express';
import cors from 'cors';
@@ -32,13 +29,12 @@ import { registerTools } from './tools.js';
import { validateApiKey, resolveUser } from './auth.js';
import { requestContext } from './request-context.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Configuration
const PORT = process.env.PORT || 3001;
const LOG_LEVEL = process.env.MCP_LOG_LEVEL || 'info';
const REQUEST_TIMEOUT = parseInt(process.env.MCP_REQUEST_TIMEOUT, 10) || 30000;
const MAX_SESSIONS = 500;
const SESSION_TTL = 3600000;
const logLevels = { debug: 0, info: 1, warn: 2, error: 3 };
const currentLogLevel = logLevels[LOG_LEVEL] ?? 1;
@@ -48,51 +44,61 @@ function log(level, ...args) {
}
}
const app = express();
// Middleware
app.use(cors());
app.use(express.json({ limit: '10mb' }));
// Database - requires DATABASE_URL environment variable
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
console.error('ERROR: DATABASE_URL environment variable is required');
console.error('ERROR: DATABASE_URL is required');
process.exit(1);
}
// OPTIMIZED: Prisma client with connection pooling
const isPostgres = databaseUrl.startsWith('postgresql://') || databaseUrl.startsWith('postgres://');
const prisma = new PrismaClient({
datasources: {
db: { url: databaseUrl },
},
datasources: { db: { url: databaseUrl } },
...(isPostgres ? { datasources: { db: { url: `${databaseUrl}${databaseUrl.includes('?') ? '&' : '?'}connection_limit=10&pool_timeout=10` } } } : {}),
log: LOG_LEVEL === 'debug' ? ['query', 'info', 'warn', 'error'] : ['warn', 'error'],
});
const appBaseUrl = process.env.APP_BASE_URL || 'http://localhost:3000';
// ── Auth Middleware ──────────────────────────────────────────────────────────
// ── Bounded Session Cache ───────────────────────────────────────────────────
const userSessions = {};
const SESSION_TIMEOUT = 3600000; // 1 hour
const sessions = new Map();
// Cleanup old sessions periodically
setInterval(() => {
function cleanupSessions() {
const now = Date.now();
let cleaned = 0;
for (const [key, session] of Object.entries(userSessions)) {
if (now - new Date(session.lastSeen).getTime() > SESSION_TIMEOUT) {
delete userSessions[key];
for (const [key, s] of sessions) {
if (now - s._lastSeen > SESSION_TTL) {
sessions.delete(key);
cleaned++;
}
}
if (cleaned > 0) {
log('debug', `Cleaned up ${cleaned} expired sessions`);
if (cleaned > 0) log('debug', `Cleaned ${cleaned} sessions`);
}
function pruneIfFull() {
if (sessions.size < MAX_SESSIONS) return;
const entries = [...sessions.entries()].sort((a, b) => a[1]._lastSeen - b[1]._lastSeen);
for (let i = 0; i < Math.floor(MAX_SESSIONS / 4); i++) {
sessions.delete(entries[i][0]);
}
}, 600000); // Every 10 minutes
}
setInterval(cleanupSessions, 600000);
// ── Express ─────────────────────────────────────────────────────────────────
const app = express();
app.use(cors());
app.use(express.json({ limit: '10mb' }));
// ── Health (before auth middleware — used by Docker healthcheck) ────────────
app.get('/health', (req, res) => res.json({ ok: true, uptime: process.uptime() }));
// ── Auth Middleware ──────────────────────────────────────────────────────────
app.use(async (req, res, next) => {
// Dev mode: no auth required
if (process.env.MCP_REQUIRE_AUTH !== 'true') {
req.userSession = { id: 'dev-user', name: 'Development User', isAuth: false };
return next();
@@ -102,189 +108,128 @@ app.use(async (req, res, next) => {
const headerUserId = req.headers['x-user-id'];
if (!apiKey && !headerUserId) {
return res.status(401).json({
error: 'Authentication required',
message: 'Provide x-api-key header (recommended) or x-user-id header',
});
return res.status(401).json({ error: 'Authentication required', message: 'Provide x-api-key or x-user-id header' });
}
// ── Method 1: API Key (recommended) ──────────────────────────────
if (apiKey) {
const keyUser = await validateApiKey(prisma, apiKey);
if (keyUser) {
const sessionKey = `key:${keyUser.apiKeyId}`;
if (userSessions[sessionKey]) {
req.userSession = userSessions[sessionKey];
req.userSession.lastSeen = new Date().toISOString();
} else {
req.userSession = {
id: randomUUID(),
name: `${keyUser.userName} (${keyUser.apiKeyName})`,
userId: keyUser.userId,
userName: keyUser.userName,
apiKeyId: keyUser.apiKeyId,
connectedAt: new Date().toISOString(),
lastSeen: new Date().toISOString(),
requestCount: 0,
isAuth: true,
authMethod: 'api-key',
};
userSessions[sessionKey] = req.userSession;
}
req.userSession = getOrCreateSession(`key:${keyUser.apiKeyId}`, {
name: `${keyUser.userName} (${keyUser.apiKeyName})`,
userId: keyUser.userId,
userName: keyUser.userName,
apiKeyId: keyUser.apiKeyId,
authMethod: 'api-key',
});
return next();
}
// Fallback: static env var key
if (process.env.MCP_API_KEY && apiKey === process.env.MCP_API_KEY) {
const sessionKey = `static:${apiKey.substring(0, 8)}`;
if (userSessions[sessionKey]) {
req.userSession = userSessions[sessionKey];
req.userSession.lastSeen = new Date().toISOString();
} else {
req.userSession = {
id: randomUUID(),
name: 'Static API Key User',
userId: process.env.USER_ID || null,
connectedAt: new Date().toISOString(),
lastSeen: new Date().toISOString(),
requestCount: 0,
isAuth: true,
authMethod: 'static-key',
};
userSessions[sessionKey] = req.userSession;
}
req.userSession = getOrCreateSession(`static:${apiKey.substring(0, 8)}`, {
name: 'Static API Key User',
userId: process.env.USER_ID || null,
authMethod: 'static-key',
});
return next();
}
return res.status(401).json({ error: 'Invalid API key' });
}
// ── Method 2: User ID header (validate against DB) ──────────────
if (headerUserId) {
const user = await resolveUser(prisma, headerUserId);
if (!user) {
return res.status(401).json({ error: 'User not found', message: `No user matching: ${headerUserId}` });
}
const sessionKey = `user:${user.id}`;
if (userSessions[sessionKey]) {
req.userSession = userSessions[sessionKey];
req.userSession.lastSeen = new Date().toISOString();
} else {
req.userSession = {
id: randomUUID(),
name: user.name,
userId: user.id,
userName: user.name,
userEmail: user.email,
userRole: user.role,
connectedAt: new Date().toISOString(),
lastSeen: new Date().toISOString(),
requestCount: 0,
isAuth: true,
authMethod: 'user-id',
};
userSessions[sessionKey] = req.userSession;
return res.status(401).json({ error: 'User not found' });
}
req.userSession = getOrCreateSession(`user:${user.id}`, {
name: user.name,
userId: user.id,
userName: user.name,
userEmail: user.email,
userRole: user.role,
authMethod: 'user-id',
});
return next();
}
return res.status(401).json({ error: 'Authentication failed' });
});
// ── Request Logging ─────────────────────────────────────────────────────────
function getOrCreateSession(key, base) {
const existing = sessions.get(key);
if (existing) {
existing._lastSeen = Date.now();
existing.requestCount = (existing.requestCount || 0) + 1;
return existing;
}
pruneIfFull();
const s = {
id: randomUUID(),
...base,
connectedAt: new Date().toISOString(),
requestCount: 1,
isAuth: true,
_lastSeen: Date.now(),
};
sessions.set(key, s);
return s;
}
// ── Logging ─────────────────────────────────────────────────────────────────
app.use((req, res, next) => {
const start = Date.now();
if (req.userSession) {
req.userSession.requestCount = (req.userSession.requestCount || 0) + 1;
}
res.on('finish', () => {
const duration = Date.now() - start;
const sessionId = req.userSession?.id?.substring(0, 8) || 'anon';
log('debug', `[${sessionId}] ${req.method} ${req.path} - ${res.statusCode} (${duration}ms)`);
const ms = Date.now() - start;
const sid = req.userSession?.id?.substring(0, 8) || 'anon';
log('debug', `[${sid}] ${req.method} ${req.path} ${res.statusCode} ${ms}ms`);
});
next();
});
// ── Request Timeout Middleware ──────────────────────────────────────────────
// ── Timeout ─────────────────────────────────────────────────────────────────
app.use((req, res, next) => {
req.setTimeout(REQUEST_TIMEOUT);
res.setTimeout(REQUEST_TIMEOUT, () => {
log('warn', `Request timeout: ${req.method} ${req.path}`);
res.status(504).json({ error: 'Gateway Timeout', message: 'Request took too long' });
if (!res.headersSent) {
res.status(504).json({ error: 'Gateway Timeout' });
}
});
next();
});
// ── MCP Server Setup ────────────────────────────────────────────────────────
// ── MCP Server ──────────────────────────────────────────────────────────────
const server = new Server(
{
name: 'memento-mcp-server',
version: '3.1.0',
},
{
capabilities: { tools: {} },
},
{ name: 'memento-mcp-server', version: '3.2.0' },
{ capabilities: { tools: {} } },
);
registerTools(server, prisma);
// ── HTTP Endpoints ──────────────────────────────────────────────────────────
// ── Routes ──────────────────────────────────────────────────────────────────
const transports = {};
// Health check
app.get('/', (req, res) => {
res.json({
name: 'Memento MCP Server',
version: '3.1.0',
version: '3.2.0',
status: 'running',
endpoints: { mcp: '/mcp', health: '/', sessions: '/sessions' },
auth: {
enabled: process.env.MCP_REQUIRE_AUTH === 'true',
method: 'x-api-key or x-user-id header',
},
tools: {
notes: 11,
notebooks: 6,
labels: 4,
reminders: 1,
total: 22,
},
performance: {
optimizations: [
'Connection pooling',
'Batch operations',
'API key caching',
'Request timeout handling',
'Parallel query execution',
],
},
endpoints: { mcp: '/mcp', health: '/health', sessions: '/sessions' },
auth: { enabled: process.env.MCP_REQUIRE_AUTH === 'true' },
tools: 22,
});
});
// Session status
app.get('/sessions', (req, res) => {
const sessions = Object.values(userSessions).map((s) => ({
id: s.id,
name: s.name,
connectedAt: s.connectedAt,
lastSeen: s.lastSeen,
requestCount: s.requestCount || 0,
const list = [...sessions.values()].map(s => ({
id: s.id, name: s.name, connectedAt: s.connectedAt,
requestCount: s.requestCount || 0, authMethod: s.authMethod,
}));
res.json({
activeUsers: sessions.length,
sessions,
uptime: process.uptime(),
});
res.json({ activeUsers: list.length, sessions: list, uptime: process.uptime() });
});
// MCP endpoint - Streamable HTTP
// MCP endpoint Streamable HTTP
app.all('/mcp', async (req, res) => {
const sessionId = req.headers['mcp-session-id'];
let transport;
@@ -295,15 +240,15 @@ app.all('/mcp', async (req, res) => {
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (id) => {
log('debug', `Session initialized: ${id}`);
log('debug', `Session init: ${id}`);
transports[id] = transport;
},
});
transport.onclose = () => {
const sid = transport.sessionId;
if (sid && transports[sid]) {
log('debug', `Session closed: ${sid}`);
if (sid) {
log('debug', `Session close: ${sid}`);
delete transports[sid];
}
};
@@ -311,79 +256,45 @@ app.all('/mcp', async (req, res) => {
await server.connect(transport);
}
// Pass authenticated userId to tool handlers via AsyncLocalStorage
const ctx = { userId: req.userSession?.userId || null };
await requestContext.run(ctx, async () => {
await transport.handleRequest(req, res, req.body);
});
});
// Legacy /sse redirect for backward compat
app.all('/sse', async (req, res) => {
// Redirect to /mcp
req.url = '/mcp';
return app._router.handle(req, res, () => {
res.status(404).json({ error: 'Not found' });
});
// Legacy /sse → /mcp redirect
app.all('/sse', (req, res) => {
res.redirect(307, '/mcp');
});
// ── Start Server ────────────────────────────────────────────────────────────
const transports = {};
// ── Start ────────────────────────────────────────────────────────────────────
app.listen(PORT, '0.0.0.0', () => {
console.log(`
╔═══════════════════════════════════════════════════════════════
║ Memento MCP Server v3.1.0 (Streamable HTTP) - Optimized
╚═══════════════════════════════════════════════════════════════
╔═══════════════════════════════════════════════════════╗
║ Memento MCP Server v3.2.0 (Streamable HTTP)
╚═══════════════════════════════════════════════════════╝
Server: http://localhost:${PORT}
MCP: http://localhost:${PORT}/mcp
Health: http://localhost:${PORT}/
Sessions: http://localhost:${PORT}/sessions
Database: ${databaseUrl}
App URL: ${appBaseUrl}
User filter: per-request (from auth)
Auth: ${process.env.MCP_REQUIRE_AUTH === 'true' ? 'ENABLED' : 'DISABLED (dev mode)'}
Timeout: ${REQUEST_TIMEOUT}ms
Performance Optimizations:
✅ Connection pooling
✅ Batch operations
✅ API key caching (60s TTL)
✅ Parallel query execution
✅ Request timeout handling
✅ Session cleanup
Tools (22 total):
Notes (11):
create_note, get_notes, get_note, update_note, delete_note,
search_notes, move_note, toggle_pin, toggle_archive,
export_notes, import_notes
Notebooks (6):
create_notebook, get_notebooks, get_notebook, update_notebook,
delete_notebook, reorder_notebooks
Labels (4):
create_label, get_labels, update_label, delete_label
Reminders (1):
get_due_reminders
N8N config: SSE endpoint http://YOUR_IP:${PORT}/mcp
Headers: x-api-key or x-user-id
Server: http://localhost:${PORT}
MCP: http://localhost:${PORT}/mcp
Auth: ${process.env.MCP_REQUIRE_AUTH === 'true' ? 'ENABLED' : 'DISABLED (dev)'}
Timeout: ${REQUEST_TIMEOUT}ms
Database: ${isPostgres ? 'PostgreSQL' : 'SQLite'}
Tools: 22
`);
});
// Graceful shutdown
process.on('SIGINT', async () => {
log('info', '\nShutting down MCP server...');
await prisma.$disconnect();
process.exit(0);
});
// ── Shutdown ─────────────────────────────────────────────────────────────────
process.on('SIGTERM', async () => {
log('info', '\nShutting down MCP server...');
async function shutdown() {
log('info', 'Shutting down...');
await prisma.$disconnect();
process.exit(0);
});
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
process.on('uncaughtException', (err) => log('error', 'Uncaught:', err.message));
process.on('unhandledRejection', (reason) => log('error', 'Unhandled rejection:', reason));

View File

@@ -1,31 +1,19 @@
#!/usr/bin/env node
/**
* Memento MCP Server - Stdio Transport (Optimized)
* Memento MCP Server - Stdio Transport
*
* Performance improvements:
* - Prisma connection pooling
* - Prepared statements caching
* - Optimized JSON serialization
* - Lazy user resolution
*
* Environment variables:
* DATABASE_URL - Prisma database URL (default: ../../memento-note/prisma/dev.db)
* USER_ID - Optional user ID to filter data
* APP_BASE_URL - Optional Next.js app URL for AI features (default: http://localhost:3000)
* MCP_LOG_LEVEL - Log level: debug, info, warn, error (default: info)
* Environment:
* DATABASE_URL Prisma database URL
* USER_ID Optional user ID filter
* APP_BASE_URL Next.js app URL (default: http://localhost:3000)
* MCP_LOG_LEVEL debug, info, warn, error (default: info)
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { PrismaClient } from '@prisma/client';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { registerTools } from './tools.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Configuration
const LOG_LEVEL = process.env.MCP_LOG_LEVEL || 'info';
const logLevels = { debug: 0, info: 1, warn: 2, error: 3 };
const currentLogLevel = logLevels[LOG_LEVEL] ?? 1;
@@ -36,44 +24,24 @@ function log(level, ...args) {
}
}
// Database - requires DATABASE_URL environment variable
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
console.error('ERROR: DATABASE_URL environment variable is required');
console.error('ERROR: DATABASE_URL is required');
process.exit(1);
}
// OPTIMIZED: Prisma client with connection pooling and prepared statements
const isPostgres = databaseUrl.startsWith('postgresql://') || databaseUrl.startsWith('postgres://');
const prisma = new PrismaClient({
datasources: {
db: { url: databaseUrl },
db: { url: isPostgres ? `${databaseUrl}${databaseUrl.includes('?') ? '&' : '?'}connection_limit=10&pool_timeout=10` : databaseUrl },
},
// SQLite optimizations
log: LOG_LEVEL === 'debug' ? ['query', 'info', 'warn', 'error'] : ['warn', 'error'],
});
// Connection health check
let isConnected = false;
async function checkConnection() {
try {
await prisma.$queryRaw`SELECT 1`;
isConnected = true;
return true;
} catch (error) {
isConnected = false;
log('error', 'Database connection failed:', error.message);
return false;
}
}
const server = new Server(
{
name: 'memento-mcp-server',
version: '3.1.0',
},
{
capabilities: { tools: {} },
},
{ name: 'memento-mcp-server', version: '3.2.0' },
{ capabilities: { tools: {} } },
);
const appBaseUrl = process.env.APP_BASE_URL || 'http://localhost:3000';
@@ -84,21 +52,19 @@ registerTools(server, prisma, {
});
async function main() {
// Verify database connection on startup
const connected = await checkConnection();
if (!connected) {
console.error('FATAL: Could not connect to database');
try {
await prisma.$queryRaw`SELECT 1`;
} catch (error) {
console.error('FATAL: Database connection failed:', error.message);
process.exit(1);
}
const transport = new StdioServerTransport();
await server.connect(transport);
log('info', `Memento MCP Server v3.1.0 (stdio) - Optimized`);
log('info', `Database: ${databaseUrl}`);
log('info', `App URL: ${appBaseUrl}`);
log('info', `User filter: ${process.env.USER_ID || 'none (all data)'}`);
log('debug', 'Performance optimizations enabled: connection pooling, batch operations, caching');
log('info', `Memento MCP Server v3.2.0 (stdio)`);
log('info', `Database: ${isPostgres ? 'PostgreSQL' : 'SQLite'}`);
log('info', `User filter: ${process.env.USER_ID || 'none'}`);
}
main().catch((error) => {
@@ -106,24 +72,13 @@ main().catch((error) => {
process.exit(1);
});
// Graceful shutdown
process.on('SIGINT', async () => {
log('info', 'Shutting down gracefully...');
async function shutdown() {
log('info', 'Shutting down...');
await prisma.$disconnect();
process.exit(0);
});
}
process.on('SIGTERM', async () => {
log('info', 'Shutting down gracefully...');
await prisma.$disconnect();
process.exit(0);
});
// Handle uncaught errors
process.on('uncaughtException', (error) => {
log('error', 'Uncaught exception:', error.message);
});
process.on('unhandledRejection', (reason) => {
log('error', 'Unhandled rejection:', reason);
});
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
process.on('uncaughtException', (err) => log('error', 'Uncaught:', err.message));
process.on('unhandledRejection', (reason) => log('error', 'Unhandled:', reason));

View File

@@ -0,0 +1,137 @@
{
"name": "Memento MCP — Daily Digest",
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "0 0 8 * * 1-5"
}
]
}
},
"id": "schedule-trigger",
"name": "Daily 8AM (Mon-Fri)",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [240, 300]
},
{
"parameters": {
"method": "POST",
"url": "http://memento-mcp:3001/mcp",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{ "name": "Content-Type", "value": "application/json" },
{ "name": "x-api-key", "value": "={{ $vars.MEMENTO_API_KEY }}" }
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "{\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"method\": \"tools/call\",\n \"params\": {\n \"name\": \"get_notes\",\n \"arguments\": {\n \"limit\": 30,\n \"includeArchived\": false\n }\n }\n}"
},
"id": "mcp-get-notes",
"name": "MCP — Get Recent Notes",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [480, 300]
},
{
"parameters": {
"method": "POST",
"url": "http://memento-mcp:3001/mcp",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{ "name": "Content-Type", "value": "application/json" },
{ "name": "x-api-key", "value": "={{ $vars.MEMENTO_API_KEY }}" }
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "{\n \"jsonrpc\": \"2.0\",\n \"id\": 2,\n \"method\": \"tools/call\",\n \"params\": {\n \"name\": \"get_due_reminders\",\n \"arguments\": {}\n }\n}"
},
"id": "mcp-get-reminders",
"name": "MCP — Get Due Reminders",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [480, 480]
},
{
"parameters": {
"jsCode": "const notesResponse = $('MCP — Get Recent Notes').item.json;\nconst remindersResponse = $('MCP — Get Due Reminders').item.json;\n\nconst notes = JSON.parse(notesResponse.result?.content?.[0]?.text || '[]');\nconst reminders = JSON.parse(remindersResponse.result?.content?.[0]?.text || '[]');\n\nconst today = new Date().toLocaleDateString('en-GB', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });\n\nconst pinnedNotes = notes.filter(n => n.isPinned).slice(0, 5);\nconst recentNotes = notes.filter(n => !n.isPinned).slice(0, 10);\n\nlet digest = `# 📋 Daily Digest — ${today}\\n\\n`;\n\nif (reminders.length > 0) {\n digest += `## ⏰ Reminders Due (${reminders.length})\\n\\n`;\n reminders.forEach(r => {\n const time = new Date(r.reminder).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });\n digest += `- **${r.title || 'Untitled'}** @ ${time}\\n`;\n });\n digest += '\\n';\n}\n\nif (pinnedNotes.length > 0) {\n digest += `## 📌 Pinned Notes (${pinnedNotes.length})\\n\\n`;\n pinnedNotes.forEach(n => {\n digest += `- **${n.title || 'Untitled'}**\\n`;\n });\n digest += '\\n';\n}\n\ndigest += `## 📝 Recent Notes (${recentNotes.length})\\n\\n`;\nrecentNotes.forEach(n => {\n const date = new Date(n.updatedAt).toLocaleDateString('en-GB');\n digest += `- ${n.title || 'Untitled'} *(${date})*\\n`;\n});\n\nreturn [{ json: { digest, noteCount: notes.length, reminderCount: reminders.length, today } }];"
},
"id": "build-digest",
"name": "Build Digest",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [720, 390]
},
{
"parameters": {
"method": "POST",
"url": "http://memento-mcp:3001/mcp",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{ "name": "Content-Type", "value": "application/json" },
{ "name": "x-api-key", "value": "={{ $vars.MEMENTO_API_KEY }}" }
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"jsonrpc\": \"2.0\",\n \"id\": 3,\n \"method\": \"tools/call\",\n \"params\": {\n \"name\": \"create_note\",\n \"arguments\": {\n \"title\": \"Daily Digest — {{ $json.today }}\",\n \"content\": {{ JSON.stringify($json.digest) }},\n \"isMarkdown\": true,\n \"color\": \"teal\",\n \"isPinned\": false,\n \"labels\": [\"digest\", \"auto\"]\n }\n }\n}"
},
"id": "mcp-save-digest",
"name": "MCP — Save Digest as Note",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [960, 390]
},
{
"parameters": {
"channel": "general",
"text": "📋 *Daily Digest ready!*\n\n📝 {{ $('Build Digest').item.json.noteCount }} notes · ⏰ {{ $('Build Digest').item.json.reminderCount }} reminders\n\nView in Memento: http://memento:3000",
"additionalFields": { "parse_mode": "Markdown" }
},
"id": "slack-digest",
"name": "Slack — Share Digest",
"type": "n8n-nodes-base.slack",
"typeVersion": 2.1,
"position": [1200, 390],
"continueOnFail": true
}
],
"connections": {
"Daily 8AM (Mon-Fri)": {
"main": [
[
{ "node": "MCP — Get Recent Notes", "type": "main", "index": 0 },
{ "node": "MCP — Get Due Reminders", "type": "main", "index": 0 }
]
]
},
"MCP — Get Recent Notes": {
"main": [[{ "node": "Build Digest", "type": "main", "index": 0 }]]
},
"MCP — Get Due Reminders": {
"main": [[{ "node": "Build Digest", "type": "main", "index": 0 }]]
},
"Build Digest": {
"main": [[{ "node": "MCP — Save Digest as Note", "type": "main", "index": 0 }]]
},
"MCP — Save Digest as Note": {
"main": [[{ "node": "Slack — Share Digest", "type": "main", "index": 0 }]]
}
},
"pinData": {},
"settings": { "executionOrder": "v1" },
"staticData": null,
"tags": [{ "id": "memento-mcp", "name": "Memento MCP" }],
"triggerCount": 1,
"updatedAt": "2026-05-03T00:00:00.000Z",
"versionId": "1"
}

View File

@@ -0,0 +1,109 @@
{
"name": "Memento MCP — Email to Note",
"nodes": [
{
"parameters": {
"pollTimes": {
"item": [{ "mode": "everyMinute" }]
},
"filters": {
"hasReadStatus": true,
"readStatus": "unread"
}
},
"id": "email-trigger",
"name": "Email Trigger (IMAP)",
"type": "n8n-nodes-base.emailTrigger",
"typeVersion": 1.1,
"position": [240, 300]
},
{
"parameters": {
"jsCode": "const email = $input.item.json;\nconst subject = email.subject || 'Email (no subject)';\nconst from = email.from?.value?.[0]?.address || email.from || 'unknown';\nconst body = email.text || email.html?.replace(/<[^>]+>/g, '') || '';\nconst date = email.date ? new Date(email.date).toISOString() : new Date().toISOString();\n\nconst isUrgent = /(urgent|asap|important|deadline|critical)/i.test(subject + body);\n\nreturn [{\n json: {\n title: `📧 ${subject}`,\n content: `**From:** ${from}\\n**Date:** ${new Date(date).toLocaleString('en-GB')}\\n\\n---\\n\\n${body.trim().substring(0, 5000)}`,\n isMarkdown: true,\n color: isUrgent ? 'red' : 'blue',\n isPinned: isUrgent,\n labels: isUrgent ? ['email', 'urgent'] : ['email'],\n isUrgent\n }\n}];"
},
"id": "format-email",
"name": "Format Email as Note",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [480, 300]
},
{
"parameters": {
"method": "POST",
"url": "http://memento-mcp:3001/mcp",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{ "name": "Content-Type", "value": "application/json" },
{ "name": "x-api-key", "value": "={{ $vars.MEMENTO_API_KEY }}" }
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"method\": \"tools/call\",\n \"params\": {\n \"name\": \"create_note\",\n \"arguments\": {\n \"title\": \"{{ $json.title }}\",\n \"content\": \"{{ $json.content }}\",\n \"isMarkdown\": {{ $json.isMarkdown }},\n \"color\": \"{{ $json.color }}\",\n \"isPinned\": {{ $json.isPinned }},\n \"labels\": {{ JSON.stringify($json.labels) }}\n }\n }\n}"
},
"id": "mcp-create-note",
"name": "MCP — Create Note",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [720, 300]
},
{
"parameters": {
"conditions": {
"conditions": [
{
"id": "urgent",
"leftValue": "={{ $('Format Email as Note').item.json.isUrgent }}",
"rightValue": true,
"operator": { "type": "boolean", "operation": "equals" }
}
],
"combinator": "and"
}
},
"id": "if-urgent",
"name": "Urgent?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.1,
"position": [960, 300]
},
{
"parameters": {
"channel": "alerts",
"text": "🚨 *Urgent email saved to Memento!*\n\n{{ $('Format Email as Note').item.json.title }}\n\nOpen: http://memento:3000",
"additionalFields": { "parse_mode": "Markdown" }
},
"id": "slack-alert",
"name": "Slack Alert",
"type": "n8n-nodes-base.slack",
"typeVersion": 2.1,
"position": [1200, 200],
"continueOnFail": true
}
],
"connections": {
"Email Trigger (IMAP)": {
"main": [[{ "node": "Format Email as Note", "type": "main", "index": 0 }]]
},
"Format Email as Note": {
"main": [[{ "node": "MCP — Create Note", "type": "main", "index": 0 }]]
},
"MCP — Create Note": {
"main": [[{ "node": "Urgent?", "type": "main", "index": 0 }]]
},
"Urgent?": {
"main": [
[{ "node": "Slack Alert", "type": "main", "index": 0 }],
[]
]
}
},
"pinData": {},
"settings": { "executionOrder": "v1" },
"staticData": null,
"tags": [{ "id": "memento-mcp", "name": "Memento MCP" }],
"triggerCount": 1,
"updatedAt": "2026-05-03T00:00:00.000Z",
"versionId": "1"
}

View File

@@ -0,0 +1,155 @@
{
"name": "Memento MCP — Reminder Bot",
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "0 */30 * * * *"
}
]
}
},
"id": "schedule-trigger",
"name": "Every 30 Minutes",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [240, 300]
},
{
"parameters": {
"method": "POST",
"url": "http://memento-mcp:3001/mcp",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "x-api-key",
"value": "={{ $vars.MEMENTO_API_KEY }}"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "{\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"method\": \"tools/call\",\n \"params\": {\n \"name\": \"get_due_reminders\",\n \"arguments\": {}\n }\n}"
},
"id": "mcp-get-reminders",
"name": "MCP — Get Due Reminders",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [480, 300]
},
{
"parameters": {
"jsCode": "// Parse MCP response\nconst response = $input.item.json;\nconst result = JSON.parse(response.result?.content?.[0]?.text || '[]');\n\nif (!Array.isArray(result) || result.length === 0) {\n return [];\n}\n\nreturn result.map(note => ({\n json: {\n noteId: note.id,\n title: note.title || 'Untitled reminder',\n reminder: note.reminder,\n reminderFormatted: new Date(note.reminder).toLocaleString('en-GB'),\n content: (note.content || '').substring(0, 200),\n labels: Array.isArray(note.labels) ? note.labels.join(', ') : ''\n }\n}));"
},
"id": "parse-reminders",
"name": "Parse Reminders",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [720, 300]
},
{
"parameters": {
"conditions": {
"options": { "caseSensitive": true },
"conditions": [
{
"id": "has-items",
"leftValue": "={{ $items().length }}",
"rightValue": 0,
"operator": {
"type": "number",
"operation": "gt"
}
}
],
"combinator": "and"
}
},
"id": "check-has-reminders",
"name": "Has Reminders?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.1,
"position": [960, 300]
},
{
"parameters": {
"chatId": "={{ $vars.TELEGRAM_CHAT_ID }}",
"text": "⏰ *Reminder — Memento*\n\n📌 *{{ $json.title }}*\n🕐 {{ $json.reminderFormatted }}\n\n{{ $json.content }}{{ $json.labels ? '\n🏷 ' + $json.labels : '' }}",
"additionalFields": {
"parse_mode": "Markdown"
}
},
"id": "send-telegram",
"name": "Send Telegram Notification",
"type": "n8n-nodes-base.telegram",
"typeVersion": 1.2,
"position": [1200, 200],
"continueOnFail": true
},
{
"parameters": {
"method": "POST",
"url": "http://memento-mcp:3001/mcp",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "x-api-key",
"value": "={{ $vars.MEMENTO_API_KEY }}"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"jsonrpc\": \"2.0\",\n \"id\": 2,\n \"method\": \"tools/call\",\n \"params\": {\n \"name\": \"update_note\",\n \"arguments\": {\n \"id\": \"{{ $json.noteId }}\",\n \"isReminderDone\": true\n }\n }\n}"
},
"id": "mcp-mark-done",
"name": "MCP — Mark Reminder Done",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [1440, 200],
"continueOnFail": true
}
],
"connections": {
"Every 30 Minutes": {
"main": [[{ "node": "MCP — Get Due Reminders", "type": "main", "index": 0 }]]
},
"MCP — Get Due Reminders": {
"main": [[{ "node": "Parse Reminders", "type": "main", "index": 0 }]]
},
"Parse Reminders": {
"main": [[{ "node": "Has Reminders?", "type": "main", "index": 0 }]]
},
"Has Reminders?": {
"main": [
[{ "node": "Send Telegram Notification", "type": "main", "index": 0 }],
[]
]
},
"Send Telegram Notification": {
"main": [[{ "node": "MCP — Mark Reminder Done", "type": "main", "index": 0 }]]
}
},
"pinData": {},
"settings": { "executionOrder": "v1" },
"staticData": null,
"tags": [{ "id": "memento-mcp", "name": "Memento MCP" }],
"triggerCount": 1,
"updatedAt": "2026-05-03T00:00:00.000Z",
"versionId": "1"
}

View File

@@ -0,0 +1,92 @@
{
"name": "Memento MCP — Webhook to Note",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "memento-note",
"responseMode": "responseNode",
"options": {}
},
"id": "webhook-trigger",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [240, 300],
"webhookId": "memento-create-note"
},
{
"parameters": {
"jsCode": "// Flexible input: accept anything and build a note from it\nconst body = $input.item.json.body || $input.item.json;\n\nconst title = body.title || body.subject || body.name || null;\nconst content = body.content || body.text || body.message || body.description || JSON.stringify(body, null, 2);\nconst color = body.color || 'default';\nconst labels = Array.isArray(body.labels) ? body.labels : body.labels ? [body.labels] : [];\nconst notebookId = body.notebookId || body.notebook_id || null;\nconst isPinned = body.isPinned === true || body.pinned === true;\nconst isMarkdown = body.isMarkdown === true || body.markdown === true;\nconst reminder = body.reminder || body.dueDate || body.due_date || null;\nconst color_valid = ['default','red','orange','yellow','green','teal','blue','purple','pink','gray'].includes(color) ? color : 'default';\n\nreturn [{ json: { title, content, color: color_valid, labels, notebookId, isPinned, isMarkdown, reminder } }];"
},
"id": "prepare-note",
"name": "Prepare Note Data",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [480, 300]
},
{
"parameters": {
"method": "POST",
"url": "http://memento-mcp:3001/mcp",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{ "name": "Content-Type", "value": "application/json" },
{ "name": "x-api-key", "value": "={{ $vars.MEMENTO_API_KEY }}" }
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"method\": \"tools/call\",\n \"params\": {\n \"name\": \"create_note\",\n \"arguments\": {\n \"title\": {{ $json.title ? JSON.stringify($json.title) : 'null' }},\n \"content\": {{ JSON.stringify($json.content) }},\n \"color\": \"{{ $json.color }}\",\n \"labels\": {{ JSON.stringify($json.labels) }},\n \"isPinned\": {{ $json.isPinned }},\n \"isMarkdown\": {{ $json.isMarkdown }}\n {{ $json.notebookId ? ', \"notebookId\": \"' + $json.notebookId + '\"' : '' }}\n {{ $json.reminder ? ', \"reminder\": \"' + $json.reminder + '\"' : '' }}\n }\n }\n}"
},
"id": "mcp-create-note",
"name": "MCP — Create Note",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [720, 300]
},
{
"parameters": {
"jsCode": "const response = $input.item.json;\nconst result = JSON.parse(response.result?.content?.[0]?.text || '{}');\n\nreturn [{\n json: {\n success: true,\n noteId: result.id,\n title: result.title,\n createdAt: result.createdAt\n }\n}];"
},
"id": "format-response",
"name": "Format Response",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [960, 300]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}"
},
"id": "webhook-response",
"name": "Respond to Webhook",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [1200, 300]
}
],
"connections": {
"Webhook": {
"main": [[{ "node": "Prepare Note Data", "type": "main", "index": 0 }]]
},
"Prepare Note Data": {
"main": [[{ "node": "MCP — Create Note", "type": "main", "index": 0 }]]
},
"MCP — Create Note": {
"main": [[{ "node": "Format Response", "type": "main", "index": 0 }]]
},
"Format Response": {
"main": [[{ "node": "Respond to Webhook", "type": "main", "index": 0 }]]
}
},
"pinData": {},
"settings": { "executionOrder": "v1" },
"staticData": null,
"tags": [{ "id": "memento-mcp", "name": "Memento MCP" }],
"triggerCount": 1,
"updatedAt": "2026-05-03T00:00:00.000Z",
"versionId": "1"
}

View File

@@ -848,20 +848,6 @@
"node": ">= 0.6"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",

File diff suppressed because one or more lines are too long

View File

@@ -116,40 +116,26 @@ Prisma.NullTypes = {
*/
exports.Prisma.TransactionIsolationLevel = makeStrictEnum({
ReadUncommitted: 'ReadUncommitted',
ReadCommitted: 'ReadCommitted',
RepeatableRead: 'RepeatableRead',
Serializable: 'Serializable'
});
exports.Prisma.NoteScalarFieldEnum = {
exports.Prisma.UserScalarFieldEnum = {
id: 'id',
title: 'title',
content: 'content',
color: 'color',
isPinned: 'isPinned',
isArchived: 'isArchived',
type: 'type',
checkItems: 'checkItems',
labels: 'labels',
images: 'images',
links: 'links',
reminder: 'reminder',
isReminderDone: 'isReminderDone',
reminderRecurrence: 'reminderRecurrence',
reminderLocation: 'reminderLocation',
isMarkdown: 'isMarkdown',
size: 'size',
embedding: 'embedding',
sharedWith: 'sharedWith',
userId: 'userId',
order: 'order',
notebookId: 'notebookId',
name: 'name',
email: 'email',
emailVerified: 'emailVerified',
password: 'password',
role: 'role',
image: 'image',
theme: 'theme',
cardSizeMode: 'cardSizeMode',
resetToken: 'resetToken',
resetTokenExpiry: 'resetTokenExpiry',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
autoGenerated: 'autoGenerated',
aiProvider: 'aiProvider',
aiConfidence: 'aiConfidence',
language: 'language',
languageConfidence: 'languageConfidence',
lastAiAnalysis: 'lastAiAnalysis'
updatedAt: 'updatedAt'
};
exports.Prisma.NotebookScalarFieldEnum = {
@@ -173,17 +159,57 @@ exports.Prisma.LabelScalarFieldEnum = {
updatedAt: 'updatedAt'
};
exports.Prisma.UserScalarFieldEnum = {
exports.Prisma.NoteScalarFieldEnum = {
id: 'id',
name: 'name',
email: 'email',
emailVerified: 'emailVerified',
password: 'password',
role: 'role',
image: 'image',
theme: 'theme',
resetToken: 'resetToken',
resetTokenExpiry: 'resetTokenExpiry',
title: 'title',
content: 'content',
color: 'color',
isPinned: 'isPinned',
isArchived: 'isArchived',
trashedAt: 'trashedAt',
type: 'type',
dismissedFromRecent: 'dismissedFromRecent',
checkItems: 'checkItems',
labels: 'labels',
images: 'images',
links: 'links',
reminder: 'reminder',
isReminderDone: 'isReminderDone',
reminderRecurrence: 'reminderRecurrence',
reminderLocation: 'reminderLocation',
isMarkdown: 'isMarkdown',
size: 'size',
sharedWith: 'sharedWith',
userId: 'userId',
order: 'order',
notebookId: 'notebookId',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
contentUpdatedAt: 'contentUpdatedAt',
autoGenerated: 'autoGenerated',
aiProvider: 'aiProvider',
aiConfidence: 'aiConfidence',
language: 'language',
languageConfidence: 'languageConfidence',
lastAiAnalysis: 'lastAiAnalysis'
};
exports.Prisma.NoteEmbeddingScalarFieldEnum = {
id: 'id',
noteId: 'noteId',
embedding: 'embedding',
createdAt: 'createdAt'
};
exports.Prisma.NoteShareScalarFieldEnum = {
id: 'id',
noteId: 'noteId',
userId: 'userId',
sharedBy: 'sharedBy',
status: 'status',
permission: 'permission',
notifiedAt: 'notifiedAt',
respondedAt: 'respondedAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
@@ -218,19 +244,6 @@ exports.Prisma.VerificationTokenScalarFieldEnum = {
expires: 'expires'
};
exports.Prisma.NoteShareScalarFieldEnum = {
id: 'id',
noteId: 'noteId',
userId: 'userId',
sharedBy: 'sharedBy',
status: 'status',
permission: 'permission',
notifiedAt: 'notifiedAt',
respondedAt: 'respondedAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.SystemConfigScalarFieldEnum = {
key: 'key',
value: 'value'
@@ -273,6 +286,7 @@ exports.Prisma.UserAISettingsScalarFieldEnum = {
fontSize: 'fontSize',
demoMode: 'demoMode',
showRecentNotes: 'showRecentNotes',
notesViewMode: 'notesViewMode',
emailNotifications: 'emailNotifications',
desktopNotifications: 'desktopNotifications',
anonymousAnalytics: 'anonymousAnalytics'
@@ -283,6 +297,11 @@ exports.Prisma.SortOrder = {
desc: 'desc'
};
exports.Prisma.QueryMode = {
default: 'default',
insensitive: 'insensitive'
};
exports.Prisma.NullsOrder = {
first: 'first',
last: 'last'
@@ -290,14 +309,15 @@ exports.Prisma.NullsOrder = {
exports.Prisma.ModelName = {
Note: 'Note',
User: 'User',
Notebook: 'Notebook',
Label: 'Label',
User: 'User',
Note: 'Note',
NoteEmbedding: 'NoteEmbedding',
NoteShare: 'NoteShare',
Account: 'Account',
Session: 'Session',
VerificationToken: 'VerificationToken',
NoteShare: 'NoteShare',
SystemConfig: 'SystemConfig',
AiFeedback: 'AiFeedback',
MemoryEchoInsight: 'MemoryEchoInsight',

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
{
"name": "prisma-client-07b35a59db17a461d4c7b787cc433edb9e7b79a627ae71660fd00cce5311cf75",
"name": "prisma-client-8c3c28a242bf05b03713c0c3d78783f929261d76a15352bcfc52a1cfa1e7f92a",
"main": "index.js",
"types": "index.d.ts",
"browser": "index-browser.js",

View File

@@ -1,44 +1,46 @@
// ============================================================================
// MCP Server Schema — exact copy from memento-note (source of truth)
// Only includes models used by MCP tools.
// Do NOT modify independently — always sync with memento-note/prisma/schema.prisma
// ============================================================================
generator client {
provider = "prisma-client-js"
output = "../node_modules/.prisma/client"
provider = "prisma-client-js"
output = "../node_modules/.prisma/client"
binaryTargets = ["linux-musl-openssl-3.0.x", "native"]
}
datasource db {
provider = "sqlite"
url = "file:../../memento-note/prisma/dev.db"
provider = "postgresql"
url = env("DATABASE_URL")
}
model Note {
id String @id @default(cuid())
title String?
content String
color String @default("default")
isPinned Boolean @default(false)
isArchived Boolean @default(false)
type String @default("text")
checkItems String?
labels String?
images String?
links String?
reminder DateTime?
isReminderDone Boolean @default(false)
reminderRecurrence String?
reminderLocation String?
isMarkdown Boolean @default(false)
size String @default("small")
embedding String?
sharedWith String?
userId String?
order Int @default(0)
notebookId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
autoGenerated Boolean?
aiProvider String?
aiConfidence Int?
language String?
languageConfidence Float?
lastAiAnalysis DateTime?
// ── Core models (used by MCP tools) ─────────────────────────────────────────
model User {
id String @id @default(cuid())
name String?
email String @unique
emailVerified DateTime?
password String?
role String @default("USER")
image String?
theme String @default("light")
cardSizeMode String @default("variable")
resetToken String? @unique
resetTokenExpiry DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[]
aiFeedback AiFeedback[]
labels Label[]
memoryEchoInsights MemoryEchoInsight[]
notes Note[]
sentShares NoteShare[] @relation("SentShares")
receivedShares NoteShare[] @relation("ReceivedShares")
notebooks Notebook[]
sessions Session[]
aiSettings UserAISettings?
}
model Notebook {
@@ -50,33 +52,115 @@ model Notebook {
userId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
labels Label[]
notes Note[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, order])
@@index([userId])
}
model Label {
id String @id @default(cuid())
id String @id @default(cuid())
name String
color String @default("gray")
color String @default("gray")
notebookId String?
userId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
notebook Notebook? @relation(fields: [notebookId], references: [id], onDelete: Cascade)
notes Note[] @relation("LabelToNote")
@@unique([notebookId, name])
@@index([notebookId])
@@index([userId])
}
model User {
id String @id @default(cuid())
name String?
email String @unique
emailVerified DateTime?
password String?
role String @default("USER")
image String?
theme String @default("light")
resetToken String? @unique
resetTokenExpiry DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
model Note {
id String @id @default(cuid())
title String?
content String
color String @default("default")
isPinned Boolean @default(false)
isArchived Boolean @default(false)
trashedAt DateTime?
type String @default("text")
dismissedFromRecent Boolean @default(false)
checkItems String?
labels String?
images String?
links String?
reminder DateTime?
isReminderDone Boolean @default(false)
reminderRecurrence String?
reminderLocation String?
isMarkdown Boolean @default(false)
size String @default("small")
sharedWith String?
userId String?
order Int @default(0)
notebookId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
contentUpdatedAt DateTime @default(now())
autoGenerated Boolean?
aiProvider String?
aiConfidence Int?
language String?
languageConfidence Float?
lastAiAnalysis DateTime?
aiFeedback AiFeedback[]
memoryEchoAsNote2 MemoryEchoInsight[] @relation("EchoNote2")
memoryEchoAsNote1 MemoryEchoInsight[] @relation("EchoNote1")
notebook Notebook? @relation(fields: [notebookId], references: [id])
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
shares NoteShare[]
labelRelations Label[] @relation("LabelToNote")
noteEmbedding NoteEmbedding?
@@index([isPinned])
@@index([isArchived])
@@index([trashedAt])
@@index([order])
@@index([reminder])
@@index([userId])
@@index([userId, notebookId])
}
model NoteEmbedding {
id String @id @default(cuid())
noteId String @unique
embedding String
createdAt DateTime @default(now())
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
@@index([noteId])
}
model NoteShare {
id String @id @default(cuid())
noteId String
userId String
sharedBy String
status String @default("pending")
permission String @default("view")
notifiedAt DateTime?
respondedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sharer User @relation("SentShares", fields: [sharedBy], references: [id], onDelete: Cascade)
user User @relation("ReceivedShares", fields: [userId], references: [id], onDelete: Cascade)
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
@@unique([noteId, userId])
@@index([userId])
@@index([status])
@@index([sharedBy])
}
// ── Supporting models (used for auth, AI features, config) ──────────────────
model Account {
userId String
type String
@@ -91,6 +175,7 @@ model Account {
session_state String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([provider, providerAccountId])
}
@@ -101,6 +186,7 @@ model Session {
expires DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
@@ -111,21 +197,6 @@ model VerificationToken {
@@id([identifier, token])
}
model NoteShare {
id String @id @default(cuid())
noteId String
userId String
sharedBy String
status String @default("pending")
permission String @default("view")
notifiedAt DateTime?
respondedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([noteId, userId])
}
model SystemConfig {
key String @id
value String
@@ -141,6 +212,12 @@ model AiFeedback {
correctedContent String?
metadata String?
createdAt DateTime @default(now())
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
@@index([noteId])
@@index([userId])
@@index([feature])
}
model MemoryEchoInsight {
@@ -154,8 +231,13 @@ model MemoryEchoInsight {
viewed Boolean @default(false)
feedback String?
dismissed Boolean @default(false)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
note2 Note @relation("EchoNote2", fields: [note2Id], references: [id], onDelete: Cascade)
note1 Note @relation("EchoNote1", fields: [note1Id], references: [id], onDelete: Cascade)
@@unique([userId, insightDate])
@@index([userId, insightDate])
@@index([userId, dismissed])
}
model UserAISettings {
@@ -169,8 +251,15 @@ model UserAISettings {
preferredLanguage String @default("auto")
fontSize String @default("medium")
demoMode Boolean @default(false)
showRecentNotes Boolean @default(false)
showRecentNotes Boolean @default(true)
notesViewMode String @default("masonry")
emailNotifications Boolean @default(false)
desktopNotifications Boolean @default(false)
anonymousAnalytics Boolean @default(false)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([memoryEcho])
@@index([aiProvider])
@@index([memoryEchoFrequency])
@@index([preferredLanguage])
}

View File

@@ -116,40 +116,26 @@ Prisma.NullTypes = {
*/
exports.Prisma.TransactionIsolationLevel = makeStrictEnum({
ReadUncommitted: 'ReadUncommitted',
ReadCommitted: 'ReadCommitted',
RepeatableRead: 'RepeatableRead',
Serializable: 'Serializable'
});
exports.Prisma.NoteScalarFieldEnum = {
exports.Prisma.UserScalarFieldEnum = {
id: 'id',
title: 'title',
content: 'content',
color: 'color',
isPinned: 'isPinned',
isArchived: 'isArchived',
type: 'type',
checkItems: 'checkItems',
labels: 'labels',
images: 'images',
links: 'links',
reminder: 'reminder',
isReminderDone: 'isReminderDone',
reminderRecurrence: 'reminderRecurrence',
reminderLocation: 'reminderLocation',
isMarkdown: 'isMarkdown',
size: 'size',
embedding: 'embedding',
sharedWith: 'sharedWith',
userId: 'userId',
order: 'order',
notebookId: 'notebookId',
name: 'name',
email: 'email',
emailVerified: 'emailVerified',
password: 'password',
role: 'role',
image: 'image',
theme: 'theme',
cardSizeMode: 'cardSizeMode',
resetToken: 'resetToken',
resetTokenExpiry: 'resetTokenExpiry',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
autoGenerated: 'autoGenerated',
aiProvider: 'aiProvider',
aiConfidence: 'aiConfidence',
language: 'language',
languageConfidence: 'languageConfidence',
lastAiAnalysis: 'lastAiAnalysis'
updatedAt: 'updatedAt'
};
exports.Prisma.NotebookScalarFieldEnum = {
@@ -173,17 +159,57 @@ exports.Prisma.LabelScalarFieldEnum = {
updatedAt: 'updatedAt'
};
exports.Prisma.UserScalarFieldEnum = {
exports.Prisma.NoteScalarFieldEnum = {
id: 'id',
name: 'name',
email: 'email',
emailVerified: 'emailVerified',
password: 'password',
role: 'role',
image: 'image',
theme: 'theme',
resetToken: 'resetToken',
resetTokenExpiry: 'resetTokenExpiry',
title: 'title',
content: 'content',
color: 'color',
isPinned: 'isPinned',
isArchived: 'isArchived',
trashedAt: 'trashedAt',
type: 'type',
dismissedFromRecent: 'dismissedFromRecent',
checkItems: 'checkItems',
labels: 'labels',
images: 'images',
links: 'links',
reminder: 'reminder',
isReminderDone: 'isReminderDone',
reminderRecurrence: 'reminderRecurrence',
reminderLocation: 'reminderLocation',
isMarkdown: 'isMarkdown',
size: 'size',
sharedWith: 'sharedWith',
userId: 'userId',
order: 'order',
notebookId: 'notebookId',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
contentUpdatedAt: 'contentUpdatedAt',
autoGenerated: 'autoGenerated',
aiProvider: 'aiProvider',
aiConfidence: 'aiConfidence',
language: 'language',
languageConfidence: 'languageConfidence',
lastAiAnalysis: 'lastAiAnalysis'
};
exports.Prisma.NoteEmbeddingScalarFieldEnum = {
id: 'id',
noteId: 'noteId',
embedding: 'embedding',
createdAt: 'createdAt'
};
exports.Prisma.NoteShareScalarFieldEnum = {
id: 'id',
noteId: 'noteId',
userId: 'userId',
sharedBy: 'sharedBy',
status: 'status',
permission: 'permission',
notifiedAt: 'notifiedAt',
respondedAt: 'respondedAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
@@ -218,19 +244,6 @@ exports.Prisma.VerificationTokenScalarFieldEnum = {
expires: 'expires'
};
exports.Prisma.NoteShareScalarFieldEnum = {
id: 'id',
noteId: 'noteId',
userId: 'userId',
sharedBy: 'sharedBy',
status: 'status',
permission: 'permission',
notifiedAt: 'notifiedAt',
respondedAt: 'respondedAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.SystemConfigScalarFieldEnum = {
key: 'key',
value: 'value'
@@ -273,6 +286,7 @@ exports.Prisma.UserAISettingsScalarFieldEnum = {
fontSize: 'fontSize',
demoMode: 'demoMode',
showRecentNotes: 'showRecentNotes',
notesViewMode: 'notesViewMode',
emailNotifications: 'emailNotifications',
desktopNotifications: 'desktopNotifications',
anonymousAnalytics: 'anonymousAnalytics'
@@ -283,6 +297,11 @@ exports.Prisma.SortOrder = {
desc: 'desc'
};
exports.Prisma.QueryMode = {
default: 'default',
insensitive: 'insensitive'
};
exports.Prisma.NullsOrder = {
first: 'first',
last: 'last'
@@ -290,14 +309,15 @@ exports.Prisma.NullsOrder = {
exports.Prisma.ModelName = {
Note: 'Note',
User: 'User',
Notebook: 'Notebook',
Label: 'Label',
User: 'User',
Note: 'Note',
NoteEmbedding: 'NoteEmbedding',
NoteShare: 'NoteShare',
Account: 'Account',
Session: 'Session',
VerificationToken: 'VerificationToken',
NoteShare: 'NoteShare',
SystemConfig: 'SystemConfig',
AiFeedback: 'AiFeedback',
MemoryEchoInsight: 'MemoryEchoInsight',

View File

@@ -870,6 +870,7 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,

View File

@@ -1,13 +1,8 @@
/**
* Memento MCP Server - Optimized Tool Definitions & Handlers
* Memento MCP Server - Tool Definitions & Handlers
*
* Performance optimizations:
* - O(1) API key lookup with caching
* - Batch operations for imports
* - Parallel promise execution
* - HTTP timeout wrapper
* - N+1 query fixes
* - Connection pooling
* Fast, minimal overhead. All queries filter trashed notes.
* Compact JSON output. Direct DB lookups.
*/
import {
@@ -18,12 +13,12 @@ import {
} from '@modelcontextprotocol/sdk/types.js';
import { requestContext } from './request-context.js';
// ─── Configuration ─────────────────────────────────────────────────────────
const DEFAULT_SEARCH_LIMIT = 50;
const DEFAULT_NOTES_LIMIT = 100;
const MAX_NOTES_LIMIT = 500;
// ─── Helpers ────────────────────────────────────────────────────────────────
const NOTE_COLORS = 'default, red, orange, yellow, green, teal, blue, purple, pink, gray';
const LABEL_COLORS = 'red, orange, yellow, green, teal, blue, purple, pink, gray';
export function parseNote(dbNote) {
if (!dbNote) return null;
@@ -66,65 +61,65 @@ export function parseNoteLightweight(dbNote) {
function textResult(data) {
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
content: [{ type: 'text', text: JSON.stringify(data) }],
};
}
// ─── Tool Schemas ───────────────────────────────────────────────────────────
const NOTE_COLORS = ['default', 'red', 'orange', 'yellow', 'green', 'teal', 'blue', 'purple', 'pink', 'gray'];
const LABEL_COLORS = ['red', 'orange', 'yellow', 'green', 'teal', 'blue', 'purple', 'pink', 'gray'];
// ─── Tool Definitions ──────────────────────────────────────────────────────
const toolDefinitions = [
// ═══════════════════════════════════════════════════════════
// NOTE TOOLS
// ═══════════════════════════════════════════════════════════
// ═══ NOTES ═══
{
name: 'create_note',
description: 'Create a new note. Supports text and checklist types, colors, labels, images, links, reminders, markdown, and notebook assignment.',
description: 'Create a new note. Set content (required), optional title, color, labels, notebook assignment, reminder, or checklist items.',
inputSchema: {
type: 'object',
properties: {
title: { type: 'string', description: 'Note title (optional)' },
content: { type: 'string', description: 'Note content (required)' },
color: { type: 'string', description: `Note color: ${NOTE_COLORS.join(', ')}`, default: 'default' },
type: { type: 'string', enum: ['text', 'checklist'], description: 'Note type', default: 'text' },
title: { type: 'string', description: 'Note title' },
content: { type: 'string', description: 'Note body text (required)' },
color: { type: 'string', description: `Color: ${NOTE_COLORS}`, default: 'default' },
type: {
type: 'string',
enum: ['text', 'markdown', 'richtext', 'checklist'],
description: 'Note type. "text" = plain text, "markdown" = Markdown rendered, "richtext" = rich text editor (default), "checklist" = interactive checklist',
default: 'richtext',
},
checkItems: {
type: 'array',
description: 'Checklist items (when type is checklist)',
description: 'Checklist items (when type=checklist)',
items: {
type: 'object',
properties: { id: { type: 'string' }, text: { type: 'string' }, checked: { type: 'boolean' } },
required: ['id', 'text', 'checked'],
},
},
labels: { type: 'array', description: 'Note labels/tags', items: { type: 'string' } },
isPinned: { type: 'boolean', description: 'Pin the note', default: false },
labels: { type: 'array', description: 'Tags/labels', items: { type: 'string' } },
isPinned: { type: 'boolean', description: 'Pin to top', default: false },
isArchived: { type: 'boolean', description: 'Create as archived', default: false },
images: { type: 'array', description: 'Image URLs or base64 strings', items: { type: 'string' } },
links: { type: 'array', description: 'URLs attached to the note', items: { type: 'string' } },
images: { type: 'array', description: 'Image URLs', items: { type: 'string' } },
links: { type: 'array', description: 'Attached URLs', items: { type: 'string' } },
reminder: { type: 'string', description: 'Reminder datetime (ISO 8601)' },
isReminderDone: { type: 'boolean', default: false },
reminderRecurrence: { type: 'string', description: 'Recurrence: daily, weekly, monthly, yearly' },
reminderLocation: { type: 'string', description: 'Location-based reminder' },
isMarkdown: { type: 'boolean', description: 'Enable markdown rendering', default: false },
reminderRecurrence: { type: 'string', description: 'daily, weekly, monthly, yearly' },
reminderLocation: { type: 'string', description: 'Location string' },
isMarkdown: { type: 'boolean', description: '(Deprecated — use type="markdown" instead) Render as markdown', default: false },
size: { type: 'string', enum: ['small', 'medium', 'large'], default: 'small' },
notebookId: { type: 'string', description: 'Notebook to assign the note to' },
notebookId: { type: 'string', description: 'Assign to notebook' },
},
required: ['content'],
},
},
{
name: 'get_notes',
description: 'Get notes with optional filters. Returns lightweight format by default (truncated content, no images). Use fullDetails=true for complete data.',
description: 'List notes. Returns lightweight format by default (truncated content, no images). Use fullDetails=true for full payloads.',
inputSchema: {
type: 'object',
properties: {
includeArchived: { type: 'boolean', description: 'Include archived notes', default: false },
search: { type: 'string', description: 'Filter by keyword in title/content' },
notebookId: { type: 'string', description: 'Filter by notebook ID. Use "inbox" for notes without a notebook' },
fullDetails: { type: 'boolean', description: 'Return full details including images (large payload)', default: false },
limit: { type: 'number', description: `Max notes to return (default ${DEFAULT_NOTES_LIMIT})`, default: DEFAULT_NOTES_LIMIT },
search: { type: 'string', description: 'Keyword filter on title/content' },
notebookId: { type: 'string', description: 'Filter by notebook. Use "inbox" for unfiled notes.' },
fullDetails: { type: 'boolean', description: 'Full payload including images', default: false },
limit: { type: 'number', description: `Max results (default ${DEFAULT_NOTES_LIMIT})`, default: DEFAULT_NOTES_LIMIT },
},
},
},
@@ -139,15 +134,19 @@ const toolDefinitions = [
},
{
name: 'update_note',
description: 'Update an existing note. Only include fields you want to change.',
description: 'Update a note. Only fields you include will be changed.',
inputSchema: {
type: 'object',
properties: {
id: { type: 'string', description: 'Note ID' },
title: { type: 'string' },
content: { type: 'string' },
color: { type: 'string', description: `One of: ${NOTE_COLORS.join(', ')}` },
type: { type: 'string', enum: ['text', 'checklist'] },
color: { type: 'string', description: `One of: ${NOTE_COLORS}` },
type: {
type: 'string',
enum: ['text', 'markdown', 'richtext', 'checklist'],
description: 'Note type. "text" = plain text, "markdown" = Markdown rendered, "richtext" = rich text editor, "checklist" = interactive checklist',
},
checkItems: {
type: 'array',
items: {
@@ -164,7 +163,7 @@ const toolDefinitions = [
isReminderDone: { type: 'boolean' },
reminderRecurrence: { type: 'string' },
reminderLocation: { type: 'string' },
isMarkdown: { type: 'boolean' },
isMarkdown: { type: 'boolean', description: '(Deprecated — use type="markdown" instead)' },
size: { type: 'string', enum: ['small', 'medium', 'large'] },
notebookId: { type: 'string' },
},
@@ -173,7 +172,7 @@ const toolDefinitions = [
},
{
name: 'delete_note',
description: 'Delete a note by ID.',
description: 'Permanently delete a note.',
inputSchema: {
type: 'object',
properties: { id: { type: 'string', description: 'Note ID' } },
@@ -187,7 +186,7 @@ const toolDefinitions = [
type: 'object',
properties: {
query: { type: 'string', description: 'Search query' },
notebookId: { type: 'string', description: 'Limit search to a notebook' },
notebookId: { type: 'string', description: 'Limit to a notebook' },
includeArchived: { type: 'boolean', default: false },
},
required: ['query'],
@@ -195,12 +194,12 @@ const toolDefinitions = [
},
{
name: 'move_note',
description: 'Move a note to a different notebook. Pass null for notebookId to move to Inbox.',
description: 'Move a note to a notebook. Set notebookId to null to move to Inbox.',
inputSchema: {
type: 'object',
properties: {
id: { type: 'string', description: 'Note ID' },
notebookId: { type: 'string', description: 'Target notebook ID, or null/empty for Inbox' },
notebookId: { type: 'string', description: 'Target notebook ID, or null for Inbox' },
},
required: ['id'],
},
@@ -225,18 +224,18 @@ const toolDefinitions = [
},
{
name: 'export_notes',
description: 'Export all notes, labels, and notebooks as a JSON object.',
description: 'Export all notes, notebooks, and labels as JSON.',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'import_notes',
description: 'Import notes from a previously exported JSON object. Skips duplicates by name.',
description: 'Import notes, notebooks, and labels from a previous export. Duplicates are skipped.',
inputSchema: {
type: 'object',
properties: {
data: {
type: 'object',
description: 'The exported JSON data (from export_notes)',
description: 'Export JSON (from export_notes)',
properties: {
version: { type: 'string' },
data: {
@@ -254,31 +253,29 @@ const toolDefinitions = [
},
},
// ═══════════════════════════════════════════════════════════
// NOTEBOOK TOOLS
// ═══════════════════════════════════════════════════════════
// ═══ NOTEBOOKS ═══
{
name: 'create_notebook',
description: 'Create a new notebook.',
description: 'Create a notebook with a name, icon, and color.',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string', description: 'Notebook name' },
icon: { type: 'string', description: 'Notebook icon (emoji)', default: '📁' },
color: { type: 'string', description: 'Hex color code', default: '#3B82F6' },
order: { type: 'number', description: 'Sort position (auto-assigned if omitted)' },
icon: { type: 'string', description: 'Icon (emoji)', default: '📁' },
color: { type: 'string', description: 'Hex color', default: '#3B82F6' },
order: { type: 'number', description: 'Sort position (auto if omitted)' },
},
required: ['name'],
},
},
{
name: 'get_notebooks',
description: 'Get all notebooks with label and note counts.',
description: 'List all notebooks with label and note counts.',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'get_notebook',
description: 'Get a notebook by ID, including its notes.',
description: 'Get a notebook by ID including its notes.',
inputSchema: {
type: 'object',
properties: { id: { type: 'string', description: 'Notebook ID' } },
@@ -287,7 +284,7 @@ const toolDefinitions = [
},
{
name: 'update_notebook',
description: 'Update a notebook\'s name, icon, color, or order.',
description: 'Update a notebook name, icon, color, or order.',
inputSchema: {
type: 'object',
properties: {
@@ -302,7 +299,7 @@ const toolDefinitions = [
},
{
name: 'delete_notebook',
description: 'Delete a notebook. Notes inside will be moved to Inbox.',
description: 'Delete a notebook. Notes inside are moved to Inbox.',
inputSchema: {
type: 'object',
properties: { id: { type: 'string', description: 'Notebook ID' } },
@@ -311,13 +308,13 @@ const toolDefinitions = [
},
{
name: 'reorder_notebooks',
description: 'Reorder notebooks. Pass an ordered array of notebook IDs.',
description: 'Set the order of all notebooks by passing their IDs in sequence.',
inputSchema: {
type: 'object',
properties: {
notebookIds: {
type: 'array',
description: 'Notebook IDs in the desired order',
description: 'Notebook IDs in desired order',
items: { type: 'string' },
},
},
@@ -325,35 +322,33 @@ const toolDefinitions = [
},
},
// ═══════════════════════════════════════════════════════════
// LABEL TOOLS
// ═══════════════════════════════════════════════════════════
// ═══ LABELS ═══
{
name: 'create_label',
description: 'Create a label in a notebook.',
description: 'Create a label inside a notebook.',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string', description: 'Label name' },
color: { type: 'string', description: `Label color: ${LABEL_COLORS.join(', ')}` },
notebookId: { type: 'string', description: 'Notebook to attach the label to' },
color: { type: 'string', description: `Color: ${LABEL_COLORS}` },
notebookId: { type: 'string', description: 'Parent notebook ID' },
},
required: ['name', 'notebookId'],
},
},
{
name: 'get_labels',
description: 'Get all labels, optionally filtered by notebook.',
description: 'List labels, optionally filtered by notebook.',
inputSchema: {
type: 'object',
properties: {
notebookId: { type: 'string', description: 'Filter labels by notebook ID' },
notebookId: { type: 'string', description: 'Filter by notebook' },
},
},
},
{
name: 'update_label',
description: 'Update a label\'s name or color.',
description: 'Update a label name or color.',
inputSchema: {
type: 'object',
properties: {
@@ -366,7 +361,7 @@ const toolDefinitions = [
},
{
name: 'delete_label',
description: 'Delete a label by ID.',
description: 'Delete a label.',
inputSchema: {
type: 'object',
properties: { id: { type: 'string', description: 'Label ID' } },
@@ -374,33 +369,23 @@ const toolDefinitions = [
},
},
// ═══════════════════════════════════════════════════════════
// REMINDER TOOLS
// ═══════════════════════════════════════════════════════════
// ═══ REMINDERS ═══
{
name: 'get_due_reminders',
description: 'Get all notes with due reminders that haven\'t been processed yet. Designed for cron/automation use.',
description: 'Get notes with due reminders. Designed for cron/automation.',
inputSchema: { type: 'object', properties: {} },
},
];
// ─── Tool Handlers ──────────────────────────────────────────────────────────
/**
* Register all tools and handlers on an MCP Server instance.
*
* @param {import('@modelcontextprotocol/sdk/server/index.js').Server} server
* @param {import('@prisma/client').PrismaClient} prisma
*/
export function registerTools(server, prisma) {
// Resolve userId per-request from AsyncLocalStorage (set by auth middleware)
const getResolvedUserId = () => {
const store = requestContext.getStore();
return store?.userId || null;
};
// Fallback: auto-detect first user when no auth context
let fallbackUserId = null;
let fallbackPromise = null;
@@ -418,22 +403,24 @@ export function registerTools(server, prisma) {
return fallbackPromise;
};
// ── List Tools ────────────────────────────────────────────────────────────
const noteWhere = (resolvedUserId, extra = {}) => ({
trashedAt: null,
...(resolvedUserId ? { userId: resolvedUserId } : {}),
...extra,
});
server.setRequestHandler(ListToolsRequestSchema, async () => {
return { tools: toolDefinitions };
});
// ── Call Tools ────────────────────────────────────────────────────────────
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
const resolvedUserId = getResolvedUserId();
const uid = getResolvedUserId();
try {
switch (name) {
// ═══════════════════════════════════════════════════════
// NOTES
// ═══════════════════════════════════════════════════════
// ═══ NOTES ═══
case 'create_note': {
const data = {
title: args.title || null,
@@ -453,31 +440,29 @@ export function registerTools(server, prisma) {
isMarkdown: args.isMarkdown || false,
size: args.size || 'small',
notebookId: args.notebookId || null,
userId: uid || await ensureUserId(),
};
if (resolvedUserId) data.userId = resolvedUserId;
else data.userId = await ensureUserId();
const note = await prisma.note.create({ data });
return textResult(parseNote(note));
}
case 'get_notes': {
const where = {};
if (resolvedUserId) where.userId = resolvedUserId;
if (!args?.includeArchived) where.isArchived = false;
const extra = {};
if (!args?.includeArchived) extra.isArchived = false;
if (args?.search) {
where.OR = [
extra.OR = [
{ title: { contains: args.search } },
{ content: { contains: args.search } },
];
}
if (args?.notebookId) {
where.notebookId = args.notebookId === 'inbox' ? null : args.notebookId;
extra.notebookId = args.notebookId === 'inbox' ? null : args.notebookId;
}
const limit = Math.min(args?.limit || DEFAULT_NOTES_LIMIT, 500); // Max 500
const limit = Math.min(args?.limit || DEFAULT_NOTES_LIMIT, MAX_NOTES_LIMIT);
const notes = await prisma.note.findMany({
where,
where: noteWhere(uid, extra),
orderBy: [{ isPinned: 'desc' }, { order: 'asc' }, { updatedAt: 'desc' }],
take: limit,
});
@@ -487,50 +472,50 @@ export function registerTools(server, prisma) {
}
case 'get_note': {
const where = { id: args.id };
if (resolvedUserId) where.userId = resolvedUserId;
const note = await prisma.note.findUnique({ where });
const note = await prisma.note.findUnique({
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
});
if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
return textResult(parseNote(note));
}
case 'update_note': {
const updateData = {};
const fields = ['title', 'color', 'type', 'isPinned', 'isArchived', 'isMarkdown', 'size', 'notebookId', 'isReminderDone', 'reminderRecurrence', 'reminderLocation'];
for (const f of fields) {
if (f in args) updateData[f] = args[f];
const d = {};
const simpleFields = ['title', 'color', 'type', 'isPinned', 'isArchived', 'isMarkdown', 'size', 'notebookId', 'isReminderDone', 'reminderRecurrence', 'reminderLocation'];
for (const f of simpleFields) {
if (f in args) d[f] = args[f];
}
if ('content' in args) updateData.content = args.content;
if ('checkItems' in args) updateData.checkItems = args.checkItems ?? null;
if ('labels' in args) updateData.labels = args.labels ?? null;
if ('images' in args) updateData.images = args.images ?? null;
if ('links' in args) updateData.links = args.links ?? null;
if ('reminder' in args) updateData.reminder = args.reminder ? new Date(args.reminder) : null;
updateData.updatedAt = new Date();
if ('content' in args) d.content = args.content;
if ('checkItems' in args) d.checkItems = args.checkItems ?? null;
if ('labels' in args) d.labels = args.labels ?? null;
if ('images' in args) d.images = args.images ?? null;
if ('links' in args) d.links = args.links ?? null;
if ('reminder' in args) d.reminder = args.reminder ? new Date(args.reminder) : null;
d.updatedAt = new Date();
const where = { id: args.id };
if (resolvedUserId) where.userId = resolvedUserId;
const note = await prisma.note.update({ where, data: updateData });
const note = await prisma.note.update({
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
data: d,
});
return textResult(parseNote(note));
}
case 'delete_note': {
const where = { id: args.id };
if (resolvedUserId) where.userId = resolvedUserId;
await prisma.note.delete({ where });
return textResult({ success: true, message: 'Note deleted' });
await prisma.note.delete({
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
});
return textResult({ success: true, deleted: args.id });
}
case 'search_notes': {
const where = {
const where = noteWhere(uid, {
isArchived: args.includeArchived || false,
OR: [
{ title: { contains: args.query } },
{ content: { contains: args.query } },
],
};
if (resolvedUserId) where.userId = resolvedUserId;
if (args.notebookId) where.notebookId = args.notebookId;
...(args.notebookId ? { notebookId: args.notebookId } : {}),
});
const notes = await prisma.note.findMany({
where,
@@ -541,79 +526,61 @@ export function registerTools(server, prisma) {
}
case 'move_note': {
const noteWhere = { id: args.id };
if (resolvedUserId) noteWhere.userId = resolvedUserId;
const targetId = args.notebookId || null;
const targetNotebookId = args.notebookId || null;
// Optimized: Parallel execution
const [note, notebook] = await Promise.all([
prisma.note.update({
where: noteWhere,
data: { notebookId: targetNotebookId, updatedAt: new Date() },
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
data: { notebookId: targetId, updatedAt: new Date() },
}),
targetNotebookId
? prisma.notebook.findUnique({ where: { id: targetNotebookId }, select: { name: true } })
targetId
? prisma.notebook.findUnique({ where: { id: targetId }, select: { name: true } })
: Promise.resolve(null),
]);
const notebookName = notebook?.name || 'Inbox';
return textResult({
success: true,
data: { id: note.id, notebookId: note.notebookId, notebook: { name: notebookName } },
message: `Note moved to ${notebookName}`,
id: note.id,
notebookId: note.notebookId,
notebookName: notebook?.name || 'Inbox',
});
}
case 'toggle_pin': {
const where = { id: args.id };
if (resolvedUserId) where.userId = resolvedUserId;
const note = await prisma.note.findUnique({ where });
const note = await prisma.note.findUnique({
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
});
if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
const updated = await prisma.note.update({
where,
where: { id: args.id },
data: { isPinned: !note.isPinned },
});
return textResult(parseNote(updated));
}
case 'toggle_archive': {
const where = { id: args.id };
if (resolvedUserId) where.userId = resolvedUserId;
const note = await prisma.note.findUnique({ where });
const note = await prisma.note.findUnique({
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
});
if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
const updated = await prisma.note.update({
where,
where: { id: args.id },
data: { isArchived: !note.isArchived },
});
return textResult(parseNote(updated));
}
case 'export_notes': {
const noteWhere = {};
const nbWhere = {};
if (resolvedUserId) { noteWhere.userId = resolvedUserId; nbWhere.userId = resolvedUserId; }
const nbWhere = uid ? { userId: uid } : {};
// Optimized: Parallel queries
const [notes, notebooks, labels] = await Promise.all([
prisma.note.findMany({
where: noteWhere,
prisma.note.findMany({
where: noteWhere(uid),
orderBy: { updatedAt: 'desc' },
select: {
id: true,
title: true,
content: true,
color: true,
type: true,
isPinned: true,
isArchived: true,
isMarkdown: true,
size: true,
labels: true,
notebookId: true,
createdAt: true,
updatedAt: true,
id: true, title: true, content: true, color: true, type: true,
isPinned: true, isArchived: true, isMarkdown: true, size: true,
labels: true, notebookId: true, createdAt: true, updatedAt: true,
},
}),
prisma.notebook.findMany({
@@ -626,9 +593,8 @@ export function registerTools(server, prisma) {
}),
]);
// Filter labels by userId in memory (faster than multiple queries)
const filteredLabels = resolvedUserId
? labels.filter(l => l.notebook?.userId === resolvedUserId)
const filteredLabels = uid
? labels.filter(l => l.notebook?.userId === uid)
: labels;
return textResult({
@@ -636,32 +602,16 @@ export function registerTools(server, prisma) {
exportDate: new Date().toISOString(),
data: {
notes: notes.map(n => ({
id: n.id,
title: n.title,
content: n.content,
color: n.color,
type: n.type,
isPinned: n.isPinned,
isArchived: n.isArchived,
isMarkdown: n.isMarkdown,
size: n.size,
labels: Array.isArray(n.labels) ? n.labels : [],
notebookId: n.notebookId,
createdAt: n.createdAt,
updatedAt: n.updatedAt,
id: n.id, title: n.title, content: n.content, color: n.color, type: n.type,
isPinned: n.isPinned, isArchived: n.isArchived, isMarkdown: n.isMarkdown,
size: n.size, labels: Array.isArray(n.labels) ? n.labels : [],
notebookId: n.notebookId, createdAt: n.createdAt, updatedAt: n.updatedAt,
})),
notebooks: notebooks.map(nb => ({
id: nb.id,
name: nb.name,
icon: nb.icon,
color: nb.color,
noteCount: nb._count.notes,
id: nb.id, name: nb.name, icon: nb.icon, color: nb.color, noteCount: nb._count.notes,
})),
labels: filteredLabels.map(l => ({
id: l.id,
name: l.name,
color: l.color,
notebookId: l.notebookId,
id: l.id, name: l.name, color: l.color, notebookId: l.notebookId,
})),
},
});
@@ -671,60 +621,51 @@ export function registerTools(server, prisma) {
const importData = args.data;
let importedNotes = 0, importedLabels = 0, importedNotebooks = 0;
// OPTIMIZED: Batch operations with Promise.all for notebooks
if (importData.data?.notebooks?.length > 0) {
const existingNotebooks = await prisma.notebook.findMany({
where: resolvedUserId ? { userId: resolvedUserId } : {},
const existing = await prisma.notebook.findMany({
where: uid ? { userId: uid } : {},
select: { name: true },
});
const existingNames = new Set(existingNotebooks.map(nb => nb.name));
const existingNames = new Set(existing.map(nb => nb.name));
const notebooksToCreate = importData.data.notebooks
const toCreate = importData.data.notebooks
.filter(nb => !existingNames.has(nb.name))
.map(nb => prisma.notebook.create({
data: {
name: nb.name,
icon: nb.icon || '📁',
color: nb.color || '#3B82F6',
...(resolvedUserId ? { userId: resolvedUserId } : {}),
},
.map(nb => ({
name: nb.name,
icon: nb.icon || '📁',
color: nb.color || '#3B82F6',
...(uid ? { userId: uid } : {}),
}));
await Promise.all(notebooksToCreate);
importedNotebooks = notebooksToCreate.length;
if (toCreate.length > 0) {
await prisma.notebook.createMany({ data: toCreate, skipDuplicates: true });
importedNotebooks = toCreate.length;
}
}
// OPTIMIZED: Batch labels
if (importData.data?.labels?.length > 0) {
const notebooks = await prisma.notebook.findMany({
where: resolvedUserId ? { userId: resolvedUserId } : {},
where: uid ? { userId: uid } : {},
select: { id: true },
});
const notebookIds = new Set(notebooks.map(nb => nb.id));
const nbIds = new Set(notebooks.map(nb => nb.id));
const existingLabels = await prisma.label.findMany({
where: { notebookId: { in: Array.from(notebookIds) } },
const existing = await prisma.label.findMany({
where: { notebookId: { in: Array.from(nbIds) } },
select: { name: true, notebookId: true },
});
const existingLabelKeys = new Set(existingLabels.map(l => `${l.notebookId}:${l.name}`));
const existingKeys = new Set(existing.map(l => `${l.notebookId}:${l.name}`));
const labelsToCreate = [];
for (const label of importData.data.labels) {
if (label.notebookId && notebookIds.has(label.notebookId)) {
const key = `${label.notebookId}:${label.name}`;
if (!existingLabelKeys.has(key)) {
labelsToCreate.push(prisma.label.create({
data: { name: label.name, color: label.color, notebookId: label.notebookId },
}));
}
}
const toCreate = importData.data.labels
.filter(l => l.notebookId && nbIds.has(l.notebookId) && !existingKeys.has(`${l.notebookId}:${l.name}`))
.map(l => ({ name: l.name, color: l.color, notebookId: l.notebookId }));
if (toCreate.length > 0) {
await prisma.label.createMany({ data: toCreate, skipDuplicates: true });
importedLabels = toCreate.length;
}
await Promise.all(labelsToCreate);
importedLabels = labelsToCreate.length;
}
// OPTIMIZED: Batch notes with createMany if available, else Promise.all
if (importData.data?.notes?.length > 0) {
const notesData = importData.data.notes.map(note => ({
title: note.title,
@@ -737,22 +678,14 @@ export function registerTools(server, prisma) {
size: note.size || 'small',
labels: note.labels ?? null,
notebookId: note.notebookId || null,
...(resolvedUserId ? { userId: resolvedUserId } : {}),
...(uid ? { userId: uid } : {}),
}));
// Try createMany first (faster), fall back to Promise.all
try {
const result = await prisma.note.createMany({
data: notesData,
skipDuplicates: true,
});
const result = await prisma.note.createMany({ data: notesData, skipDuplicates: true });
importedNotes = result.count;
} catch {
// Fallback to individual creates
const creates = notesData.map(data =>
prisma.note.create({ data }).catch(() => null)
);
const results = await Promise.all(creates);
const results = await Promise.all(notesData.map(d => prisma.note.create({ data: d }).catch(() => null)));
importedNotes = results.filter(r => r !== null).length;
}
}
@@ -763,27 +696,23 @@ export function registerTools(server, prisma) {
});
}
// ═══════════════════════════════════════════════════════
// NOTEBOOKS
// ═══════════════════════════════════════════════════════
// ═══ NOTEBOOKS ═══
case 'create_notebook': {
const highestOrder = await prisma.notebook.findFirst({
where: resolvedUserId ? { userId: resolvedUserId } : {},
const highest = await prisma.notebook.findFirst({
where: uid ? { userId: uid } : {},
orderBy: { order: 'desc' },
select: { order: true },
});
const nextOrder = args.order !== undefined ? args.order : (highestOrder?.order ?? -1) + 1;
const data = {
name: args.name.trim(),
icon: args.icon || '📁',
color: args.color || '#3B82F6',
order: nextOrder,
};
data.userId = resolvedUserId || await ensureUserId();
const nextOrder = args.order !== undefined ? args.order : (highest?.order ?? -1) + 1;
const notebook = await prisma.notebook.create({
data,
data: {
name: args.name.trim(),
icon: args.icon || '📁',
color: args.color || '#3B82F6',
order: nextOrder,
userId: uid || await ensureUserId(),
},
include: { labels: true, _count: { select: { notes: true } } },
});
@@ -791,9 +720,7 @@ export function registerTools(server, prisma) {
}
case 'get_notebooks': {
const where = {};
if (resolvedUserId) where.userId = resolvedUserId;
const where = uid ? { userId: uid } : {};
const notebooks = await prisma.notebook.findMany({
where,
include: {
@@ -807,12 +734,10 @@ export function registerTools(server, prisma) {
}
case 'get_notebook': {
const where = { id: args.id };
if (resolvedUserId) where.userId = resolvedUserId;
const where = { id: args.id, ...(uid ? { userId: uid } : {}) };
const notebook = await prisma.notebook.findUnique({
where,
include: { labels: true, notes: true, _count: { select: { notes: true } } },
include: { labels: true, notes: { where: { trashedAt: null } }, _count: { select: { notes: true } } },
});
if (!notebook) throw new McpError(ErrorCode.InvalidRequest, 'Notebook not found');
@@ -824,18 +749,16 @@ export function registerTools(server, prisma) {
}
case 'update_notebook': {
const updateData = {};
if ('name' in args) updateData.name = args.name.trim();
if ('icon' in args) updateData.icon = args.icon;
if ('color' in args) updateData.color = args.color;
if ('order' in args) updateData.order = args.order;
const where = { id: args.id };
if (resolvedUserId) where.userId = resolvedUserId;
const d = {};
if ('name' in args) d.name = args.name.trim();
if ('icon' in args) d.icon = args.icon;
if ('color' in args) d.color = args.color;
if ('order' in args) d.order = args.order;
const where = { id: args.id, ...(uid ? { userId: uid } : {}) };
const notebook = await prisma.notebook.update({
where,
data: updateData,
data: d,
include: { labels: true, _count: { select: { notes: true } } },
});
@@ -843,38 +766,29 @@ export function registerTools(server, prisma) {
}
case 'delete_notebook': {
const where = { id: args.id };
if (resolvedUserId) where.userId = resolvedUserId;
// Move notes to inbox before deleting
await prisma.$transaction([
prisma.note.updateMany({
where: { notebookId: args.id, ...(resolvedUserId ? { userId: resolvedUserId } : {}) },
where: { notebookId: args.id, ...(uid ? { userId: uid } : {}) },
data: { notebookId: null },
}),
prisma.notebook.delete({ where }),
prisma.notebook.delete({
where: { id: args.id, ...(uid ? { userId: uid } : {}) },
}),
]);
return textResult({ success: true, message: 'Notebook deleted, notes moved to Inbox' });
}
case 'reorder_notebooks': {
const ids = args.notebookIds;
// Optimized: Verify ownership in one query
const where = { id: { in: ids } };
if (resolvedUserId) where.userId = resolvedUserId;
const existingNotebooks = await prisma.notebook.findMany({
where,
select: { id: true },
});
const existingIds = new Set(existingNotebooks.map(nb => nb.id));
const missingIds = ids.filter(id => !existingIds.has(id));
if (missingIds.length > 0) {
throw new McpError(ErrorCode.InvalidRequest, `Notebooks not found: ${missingIds.join(', ')}`);
const where = { id: { in: ids }, ...(uid ? { userId: uid } : {}) };
const existing = await prisma.notebook.findMany({ where, select: { id: true } });
const existingIds = new Set(existing.map(nb => nb.id));
const missing = ids.filter(id => !existingIds.has(id));
if (missing.length > 0) {
throw new McpError(ErrorCode.InvalidRequest, `Notebooks not found: ${missing.join(', ')}`);
}
await prisma.$transaction(
@@ -882,12 +796,10 @@ export function registerTools(server, prisma) {
prisma.notebook.update({ where: { id }, data: { order: index } })
)
);
return textResult({ success: true, message: 'Notebooks reordered' });
return textResult({ success: true });
}
// ═══════════════════════════════════════════════════════
// LABELS - OPTIMIZED to fix N+1 query
// ═══════════════════════════════════════════════════════
// ═══ LABELS ═══
case 'create_label': {
const existing = await prisma.label.findFirst({
where: { name: args.name.trim(), notebookId: args.notebookId },
@@ -908,49 +820,37 @@ export function registerTools(server, prisma) {
const where = {};
if (args?.notebookId) where.notebookId = args.notebookId;
// OPTIMIZED: Single query with include, then filter in memory
const labels = await prisma.label.findMany({
where,
include: { notebook: { select: { id: true, name: true, userId: true } } },
orderBy: { name: 'asc' },
});
// Filter by userId in memory (much faster than N+1 queries)
let filteredLabels = labels;
if (resolvedUserId) {
filteredLabels = labels.filter(l => l.notebook?.userId === resolvedUserId);
}
return textResult(filteredLabels);
const filtered = uid ? labels.filter(l => l.notebook?.userId === uid) : labels;
return textResult(filtered);
}
case 'update_label': {
const updateData = {};
if ('name' in args) updateData.name = args.name.trim();
if ('color' in args) updateData.color = args.color;
const d = {};
if ('name' in args) d.name = args.name.trim();
if ('color' in args) d.color = args.color;
const label = await prisma.label.update({
where: { id: args.id },
data: updateData,
});
const label = await prisma.label.update({ where: { id: args.id }, data: d });
return textResult(label);
}
case 'delete_label': {
await prisma.label.delete({ where: { id: args.id } });
return textResult({ success: true, message: 'Label deleted' });
return textResult({ success: true });
}
// ═══════════════════════════════════════════════════════
// REMINDERS
// ═══════════════════════════════════════════════════════
// ═══ REMINDERS ═══
case 'get_due_reminders': {
const where = {
const where = noteWhere(uid, {
reminder: { not: null, lte: new Date() },
isReminderDone: false,
isArchived: false,
};
if (resolvedUserId) where.userId = resolvedUserId;
});
const reminders = await prisma.note.findMany({
where,
@@ -958,7 +858,6 @@ export function registerTools(server, prisma) {
orderBy: { reminder: 'asc' },
});
// Mark them as done
if (reminders.length > 0) {
await prisma.note.updateMany({
where: { id: { in: reminders.map(r => r.id) } },
@@ -966,7 +865,7 @@ export function registerTools(server, prisma) {
});
}
return textResult({ success: true, count: reminders.length, reminders });
return textResult({ count: reminders.length, reminders });
}
default:
@@ -974,7 +873,7 @@ export function registerTools(server, prisma) {
}
} catch (error) {
if (error instanceof McpError) throw error;
throw new McpError(ErrorCode.InternalError, `Tool execution failed: ${error.message}`);
throw new McpError(ErrorCode.InternalError, `Tool error: ${error.message}`);
}
});
}
}

View File

@@ -1,85 +0,0 @@
'use client'
import { useState } from 'react'
import { Note } from '@/lib/types'
import { NoteCard } from './note-card'
import { ChevronDown, ChevronUp, Pin } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
interface FavoritesSectionProps {
pinnedNotes: Note[]
onEdit?: (note: Note, readOnly?: boolean) => void
onSizeChange?: (noteId: string, size: 'small' | 'medium' | 'large') => void
isLoading?: boolean
}
export function FavoritesSection({ pinnedNotes, onEdit, onSizeChange, isLoading }: FavoritesSectionProps) {
const [isCollapsed, setIsCollapsed] = useState(false)
const { t } = useLanguage()
if (isLoading) {
return (
<section data-testid="favorites-section" className="mb-8">
<div className="flex items-center gap-2 mb-4 px-2 py-2">
<Pin className="w-5 h-5 text-muted-foreground animate-pulse" />
<div className="h-6 w-32 bg-muted rounded animate-pulse" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3].map((i) => (
<div key={i} className="h-40 bg-muted rounded-2xl animate-pulse" />
))}
</div>
</section>
)
}
if (pinnedNotes.length === 0) {
return null
}
return (
<section data-testid="favorites-section" className="mb-8">
<button
onClick={() => setIsCollapsed(!isCollapsed)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setIsCollapsed(!isCollapsed)
}
}}
className="w-full flex items-center justify-between gap-2 mb-4 px-2 py-2 hover:bg-accent rounded-lg transition-colors min-h-[44px]"
aria-expanded={!isCollapsed}
aria-label={t('favorites.toggleSection') || 'Toggle pinned notes section'}
>
<div className="flex items-center gap-2">
<span className="text-2xl">📌</span>
<h2 className="text-lg font-semibold text-foreground">
{t('notes.pinnedNotes')}
<span className="text-sm font-medium text-muted-foreground ml-2">
({pinnedNotes.length})
</span>
</h2>
</div>
{isCollapsed ? (
<ChevronDown className="w-5 h-5 text-muted-foreground" />
) : (
<ChevronUp className="w-5 h-5 text-muted-foreground" />
)}
</button>
{/* Collapsible Content */}
{!isCollapsed && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{pinnedNotes.map((note) => (
<NoteCard
key={note.id}
note={note}
onEdit={onEdit}
onSizeChange={(size) => onSizeChange?.(note.id, size)}
/>
))}
</div>
)}
</section>
)
}

View File

@@ -1,454 +0,0 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import dynamic from 'next/dynamic'
import { Note } from '@/lib/types'
import { getAISettings } from '@/app/actions/ai-settings'
import { getAllNotes, searchNotes } from '@/app/actions/notes'
import { NoteInput } from '@/components/note-input'
import { NotesMainSection, type NotesViewMode } from '@/components/notes-main-section'
import { NotesViewToggle } from '@/components/notes-view-toggle'
import { MemoryEchoNotification } from '@/components/memory-echo-notification'
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
import { FavoritesSection } from '@/components/favorites-section'
import { Button } from '@/components/ui/button'
import { Wand2 } from 'lucide-react'
import { useLabels } from '@/context/LabelContext'
import { useNoteRefresh } from '@/context/NoteRefreshContext'
import { useReminderCheck } from '@/hooks/use-reminder-check'
import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion'
import { useNotebooks } from '@/context/notebooks-context'
import { Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2, Plane, ChevronRight, Plus } from 'lucide-react'
import { cn } from '@/lib/utils'
import { LabelFilter } from '@/components/label-filter'
import { useLanguage } from '@/lib/i18n'
import { useHomeView } from '@/context/home-view-context'
// Lazy-load heavy dialogs — uniquement chargés à la demande
const NoteEditor = dynamic(
() => import('@/components/note-editor').then(m => ({ default: m.NoteEditor })),
{ ssr: false }
)
const BatchOrganizationDialog = dynamic(
() => import('@/components/batch-organization-dialog').then(m => ({ default: m.BatchOrganizationDialog })),
{ ssr: false }
)
const AutoLabelSuggestionDialog = dynamic(
() => import('@/components/auto-label-suggestion-dialog').then(m => ({ default: m.AutoLabelSuggestionDialog })),
{ ssr: false }
)
type InitialSettings = {
showRecentNotes: boolean
notesViewMode: 'masonry' | 'tabs'
}
interface HomeClientProps {
/** Notes pré-chargées côté serveur — hydratées immédiatement sans loading spinner */
initialNotes: Note[]
initialSettings: InitialSettings
}
export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
const searchParams = useSearchParams()
const router = useRouter()
const { t } = useLanguage()
const [notes, setNotes] = useState<Note[]>(initialNotes)
const [pinnedNotes, setPinnedNotes] = useState<Note[]>(
initialNotes.filter(n => n.isPinned)
)
const [notesViewMode, setNotesViewMode] = useState<NotesViewMode>(initialSettings.notesViewMode)
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null)
const [isLoading, setIsLoading] = useState(false) // false by default — data is pre-loaded
const [notebookSuggestion, setNotebookSuggestion] = useState<{ noteId: string; content: string } | null>(null)
const [batchOrganizationOpen, setBatchOrganizationOpen] = useState(false)
const { refreshKey } = useNoteRefresh()
const { labels } = useLabels()
const { setControls } = useHomeView()
const { shouldSuggest: shouldSuggestLabels, notebookId: suggestNotebookId, dismiss: dismissLabelSuggestion } = useAutoLabelSuggestion()
const [autoLabelOpen, setAutoLabelOpen] = useState(false)
useEffect(() => {
if (shouldSuggestLabels && suggestNotebookId) {
setAutoLabelOpen(true)
}
}, [shouldSuggestLabels, suggestNotebookId])
const notebookFilter = searchParams.get('notebook')
const isInbox = !notebookFilter
const handleNoteCreated = useCallback((note: Note) => {
setNotes((prevNotes) => {
const notebookFilter = searchParams.get('notebook')
const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || []
const colorFilter = searchParams.get('color')
const search = searchParams.get('search')?.trim() || null
if (notebookFilter && note.notebookId !== notebookFilter) return prevNotes
if (!notebookFilter && note.notebookId) return prevNotes
if (labelFilter.length > 0) {
const noteLabels = note.labels || []
if (!noteLabels.some((label: string) => labelFilter.includes(label))) return prevNotes
}
if (colorFilter) {
const labelNamesWithColor = labels
.filter((label: any) => label.color === colorFilter)
.map((label: any) => label.name)
const noteLabels = note.labels || []
if (!noteLabels.some((label: string) => labelNamesWithColor.includes(label))) return prevNotes
}
if (search) {
router.refresh()
return prevNotes
}
const isPinned = note.isPinned || false
const pinnedNotes = prevNotes.filter(n => n.isPinned)
const unpinnedNotes = prevNotes.filter(n => !n.isPinned)
if (isPinned) {
return [note, ...pinnedNotes, ...unpinnedNotes]
} else {
return [...pinnedNotes, note, ...unpinnedNotes]
}
})
if (!note.notebookId) {
const wordCount = (note.content || '').trim().split(/\s+/).filter(w => w.length > 0).length
if (wordCount >= 20) {
setNotebookSuggestion({ noteId: note.id, content: note.content || '' })
}
}
}, [searchParams, labels, router])
const handleOpenNote = (noteId: string) => {
const note = notes.find(n => n.id === noteId)
if (note) setEditingNote({ note, readOnly: false })
}
const handleSizeChange = useCallback((noteId: string, size: 'small' | 'medium' | 'large') => {
setNotes(prev => prev.map(n => n.id === noteId ? { ...n, size } : n))
setPinnedNotes(prev => prev.map(n => n.id === noteId ? { ...n, size } : n))
}, [])
useReminderCheck(notes)
// Rechargement uniquement pour les filtres actifs (search, labels, notebook)
// Les notes initiales suffisent sans filtre
useEffect(() => {
const search = searchParams.get('search')?.trim() || null
const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || []
const colorFilter = searchParams.get('color')
const notebook = searchParams.get('notebook')
const semanticMode = searchParams.get('semantic') === 'true'
// Pour le refreshKey (mutations), toujours recharger
// Pour les filtres, charger depuis le serveur
const hasActiveFilter = search || labelFilter.length > 0 || colorFilter
const load = async () => {
setIsLoading(true)
let allNotes = search
? await searchNotes(search, semanticMode, notebook || undefined)
: await getAllNotes()
// Filtre notebook côté client
if (notebook) {
allNotes = allNotes.filter((note: any) => note.notebookId === notebook)
} else {
allNotes = allNotes.filter((note: any) => !note.notebookId)
}
// Filtre labels
if (labelFilter.length > 0) {
allNotes = allNotes.filter((note: any) =>
note.labels?.some((label: string) => labelFilter.includes(label))
)
}
// Filtre couleur
if (colorFilter) {
const labelNamesWithColor = labels
.filter((label: any) => label.color === colorFilter)
.map((label: any) => label.name)
allNotes = allNotes.filter((note: any) =>
note.labels?.some((label: string) => labelNamesWithColor.includes(label))
)
}
// Merger avec les tailles locales pour ne pas écraser les modifications
setNotes(prev => {
const localSizeMap = new Map(prev.map(n => [n.id, n.size]))
return allNotes.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }))
})
setPinnedNotes(allNotes.filter((n: any) => n.isPinned))
setIsLoading(false)
}
// Éviter le rechargement initial si les notes sont déjà chargées sans filtres
if (refreshKey > 0 || hasActiveFilter) {
const cancelled = { value: false }
load().then(() => { if (cancelled.value) return })
return () => { cancelled.value = true }
} else {
// Données initiales : filtrage inbox/notebook côté client seulement
let filtered = initialNotes
if (notebook) {
filtered = initialNotes.filter(n => n.notebookId === notebook)
} else {
filtered = initialNotes.filter(n => !n.notebookId)
}
// Merger avec les tailles déjà modifiées localement
setNotes(prev => {
const localSizeMap = new Map(prev.map(n => [n.id, n.size]))
return filtered.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }))
})
setPinnedNotes(filtered.filter(n => n.isPinned))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams, refreshKey])
const { notebooks } = useNotebooks()
const currentNotebook = notebooks.find((n: any) => n.id === searchParams.get('notebook'))
const [showNoteInput, setShowNoteInput] = useState(false)
useEffect(() => {
setControls({
isTabsMode: notesViewMode === 'tabs',
openNoteComposer: () => setShowNoteInput(true),
})
return () => setControls(null)
}, [notesViewMode, setControls])
const getNotebookIcon = (iconName: string) => {
const ICON_MAP: Record<string, any> = {
'folder': Folder,
'briefcase': Briefcase,
'document': FileText,
'lightning': Zap,
'chart': BarChart3,
'globe': Globe,
'sparkle': Sparkles,
'book': Book,
'heart': Heart,
'crown': Crown,
'music': Music,
'building': Building2,
'flight_takeoff': Plane,
}
return ICON_MAP[iconName] || Folder
}
const handleNoteCreatedWrapper = (note: any) => {
handleNoteCreated(note)
setShowNoteInput(false)
}
const Breadcrumbs = ({ notebookName }: { notebookName: string }) => (
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
<span>{t('nav.notebooks')}</span>
<ChevronRight className="w-4 h-4" />
<span className="font-medium text-primary">{notebookName}</span>
</div>
)
const isTabs = notesViewMode === 'tabs'
return (
<div
className={cn(
'flex w-full min-h-0 flex-1 flex-col',
isTabs ? 'gap-3 py-1' : 'h-full px-2 py-6 sm:px-4 md:px-8'
)}
>
{/* Notebook Specific Header */}
{currentNotebook ? (
<div
className={cn(
'flex flex-col animate-in fade-in slide-in-from-top-2 duration-300',
isTabs ? 'mb-3 gap-3' : 'mb-8 gap-6'
)}
>
<Breadcrumbs notebookName={currentNotebook.name} />
<div className="flex items-start justify-between">
<div className="flex items-center gap-5">
<div className="p-3 bg-primary/10 dark:bg-primary/20 rounded-xl">
{(() => {
const Icon = getNotebookIcon(currentNotebook.icon || 'folder')
return (
<Icon
className={cn("w-8 h-8", !currentNotebook.color && "text-primary dark:text-primary-foreground")}
style={currentNotebook.color ? { color: currentNotebook.color } : undefined}
/>
)
})()}
</div>
<h1 className="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">{currentNotebook.name}</h1>
</div>
<div className="flex flex-wrap items-center gap-3">
<NotesViewToggle mode={notesViewMode} onModeChange={setNotesViewMode} />
<LabelFilter
selectedLabels={searchParams.get('labels')?.split(',').filter(Boolean) || []}
onFilterChange={(newLabels) => {
const params = new URLSearchParams(searchParams.toString())
if (newLabels.length > 0) params.set('labels', newLabels.join(','))
else params.delete('labels')
router.push(`/?${params.toString()}`)
}}
className="border-gray-200"
/>
{!isTabs && (
<Button
onClick={() => setShowNoteInput(!showNoteInput)}
className="h-10 px-6 rounded-full bg-primary hover:bg-primary/90 text-primary-foreground font-medium shadow-sm gap-2 transition-all"
>
<Plus className="w-5 h-5" />
{t('notes.addNote') || 'Add Note'}
</Button>
)}
</div>
</div>
</div>
) : (
<div
className={cn(
'flex flex-col animate-in fade-in slide-in-from-top-2 duration-300',
isTabs ? 'mb-3 gap-3' : 'mb-8 gap-6'
)}
>
{!isTabs && <div className="mb-1 h-5" />}
<div className="flex items-start justify-between">
<div className="flex items-center gap-5">
<div className="p-3 bg-white border border-gray-100 dark:bg-gray-800 dark:border-gray-700 rounded-xl shadow-sm">
<FileText className="w-8 h-8 text-primary" />
</div>
<h1 className="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">{t('notes.title')}</h1>
</div>
<div className="flex flex-wrap items-center gap-3">
<NotesViewToggle mode={notesViewMode} onModeChange={setNotesViewMode} />
<LabelFilter
selectedLabels={searchParams.get('labels')?.split(',').filter(Boolean) || []}
onFilterChange={(newLabels) => {
const params = new URLSearchParams(searchParams.toString())
if (newLabels.length > 0) params.set('labels', newLabels.join(','))
else params.delete('labels')
router.push(`/?${params.toString()}`)
}}
className="border-gray-200"
/>
{isInbox && !isLoading && notes.length >= 2 && (
<Button
onClick={() => setBatchOrganizationOpen(true)}
variant="outline"
className="h-10 px-4 rounded-full border-gray-200 text-gray-700 hover:bg-gray-50 gap-2 shadow-sm"
title={t('batch.organizeWithAI')}
>
<Wand2 className="h-4 w-4 text-purple-600" />
<span className="hidden sm:inline">{t('batch.organize')}</span>
</Button>
)}
{!isTabs && (
<Button
onClick={() => setShowNoteInput(!showNoteInput)}
className="h-10 px-6 rounded-full bg-primary hover:bg-primary/90 text-primary-foreground font-medium shadow-sm gap-2 transition-all"
>
<Plus className="w-5 h-5" />
{t('notes.newNote')}
</Button>
)}
</div>
</div>
</div>
)}
{showNoteInput && (
<div
className={cn(
'animate-in fade-in slide-in-from-top-4 duration-300',
isTabs ? 'mb-3 w-full shrink-0' : 'mb-8'
)}
>
<NoteInput
onNoteCreated={handleNoteCreatedWrapper}
forceExpanded={true}
fullWidth={isTabs}
/>
</div>
)}
{isLoading ? (
<div className="text-center py-8 text-gray-500">{t('general.loading')}</div>
) : (
<>
<FavoritesSection
pinnedNotes={pinnedNotes}
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
onSizeChange={handleSizeChange}
/>
{notes.filter((note) => !note.isPinned).length > 0 && (
<div className={cn(isTabs && 'flex min-h-0 flex-1 flex-col')}>
<NotesMainSection
viewMode={notesViewMode}
notes={notes.filter((note) => !note.isPinned)}
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
onSizeChange={handleSizeChange}
currentNotebookId={searchParams.get('notebook')}
/>
</div>
)}
{notes.filter(note => !note.isPinned).length === 0 && pinnedNotes.length === 0 && (
<div className="text-center py-8 text-gray-500">
{t('notes.emptyState')}
</div>
)}
</>
)}
<MemoryEchoNotification onOpenNote={handleOpenNote} />
{notebookSuggestion && (
<NotebookSuggestionToast
noteId={notebookSuggestion.noteId}
noteContent={notebookSuggestion.content}
onDismiss={() => setNotebookSuggestion(null)}
/>
)}
{batchOrganizationOpen && (
<BatchOrganizationDialog
open={batchOrganizationOpen}
onOpenChange={setBatchOrganizationOpen}
onNotesMoved={() => router.refresh()}
/>
)}
{autoLabelOpen && (
<AutoLabelSuggestionDialog
open={autoLabelOpen}
onOpenChange={(open) => {
setAutoLabelOpen(open)
if (!open) dismissLabelSuggestion()
}}
notebookId={suggestNotebookId}
onLabelsCreated={() => router.refresh()}
/>
)}
{editingNote && (
<NoteEditor
note={editingNote.note}
readOnly={editingNote.readOnly}
onClose={() => setEditingNote(null)}
/>
)}
</div>
)
}

View File

@@ -1,132 +0,0 @@
/**
* Masonry Grid — CSS Grid avec tailles visibles
* Layout avec espaces minimisés via dense, drag-and-drop via @dnd-kit
*/
/* ─── Container ──────────────────────────────────── */
.masonry-container {
width: 100%;
padding: 0 8px 40px 8px;
}
/* ─── CSS Grid avec dense pour minimiser les trous ─ */
.masonry-css-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
grid-auto-rows: auto;
gap: 12px;
align-items: start;
grid-auto-flow: dense;
}
/* ─── Sortable items ─────────────────────────────── */
.masonry-sortable-item {
break-inside: avoid;
box-sizing: border-box;
will-change: transform;
transition: opacity 0.15s ease-out;
}
/* Taille des notes : small=1 colonne, medium=2 colonnes, large=3 colonnes */
.masonry-sortable-item[data-size="medium"] {
grid-column: span 2;
}
.masonry-sortable-item[data-size="large"] {
grid-column: span 3;
}
/* ─── Note card base ─────────────────────────────── */
.note-card {
width: 100% !important;
min-width: 0;
box-sizing: border-box;
}
/* ─── Drag overlay ───────────────────────────────── */
.masonry-drag-overlay {
cursor: grabbing;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.25), 0 8px 16px rgba(0, 0, 0, 0.15);
border-radius: 12px;
opacity: 0.95;
pointer-events: none;
}
/* ─── Mobile (< 480px) : 1 colonne ──────────────── */
@media (max-width: 479px) {
.masonry-css-grid {
grid-template-columns: 1fr;
gap: 10px;
}
/* Sur mobile tout est 1 colonne */
.masonry-sortable-item[data-size="medium"],
.masonry-sortable-item[data-size="large"] {
grid-column: span 1;
}
.masonry-container {
padding: 0 4px 16px 4px;
}
}
/* ─── Small tablet (480767px) : 2 colonnes ─────── */
@media (min-width: 480px) and (max-width: 767px) {
.masonry-css-grid {
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
/* Sur 2 colonnes, large prend tout */
.masonry-sortable-item[data-size="large"] {
grid-column: span 2;
}
.masonry-container {
padding: 0 8px 20px 8px;
}
}
/* ─── Tablet (7681023px) ────────────────────────── */
@media (min-width: 768px) and (max-width: 1023px) {
.masonry-css-grid {
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 12px;
}
}
/* ─── Desktop (10241279px) ─────────────────────── */
@media (min-width: 1024px) and (max-width: 1279px) {
.masonry-css-grid {
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
gap: 12px;
}
}
/* ─── Large Desktop (1280px+) ───────────────────── */
@media (min-width: 1280px) {
.masonry-css-grid {
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 14px;
}
.masonry-container {
max-width: 1600px;
margin: 0 auto;
padding: 0 12px 32px 12px;
}
}
/* ─── Print ──────────────────────────────────────── */
@media print {
.masonry-sortable-item {
break-inside: avoid;
page-break-inside: avoid;
}
}
/* ─── Reduced motion ─────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
.masonry-sortable-item {
transition: none;
}
}

View File

@@ -1,292 +0,0 @@
'use client'
import { useState, useEffect, useCallback, memo, useMemo, useRef } from 'react';
import {
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
PointerSensor,
TouchSensor,
closestCenter,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
SortableContext,
arrayMove,
rectSortingStrategy,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Note } from '@/lib/types';
import { NoteCard } from './note-card';
import { updateFullOrderWithoutRevalidation } from '@/app/actions/notes';
import { useNotebookDrag } from '@/context/notebook-drag-context';
import { useLanguage } from '@/lib/i18n';
import dynamic from 'next/dynamic';
import './masonry-grid.css';
// Lazy-load NoteEditor — uniquement chargé au clic
const NoteEditor = dynamic(
() => import('./note-editor').then(m => ({ default: m.NoteEditor })),
{ ssr: false }
);
interface MasonryGridProps {
notes: Note[];
onEdit?: (note: Note, readOnly?: boolean) => void;
onSizeChange?: (noteId: string, size: 'small' | 'medium' | 'large') => void;
}
// ─────────────────────────────────────────────
// Sortable Note Item
// ─────────────────────────────────────────────
interface SortableNoteProps {
note: Note;
onEdit: (note: Note, readOnly?: boolean) => void;
onSizeChange: (noteId: string, newSize: 'small' | 'medium' | 'large') => void;
onDragStartNote?: (noteId: string) => void;
onDragEndNote?: () => void;
isDragging?: boolean;
isOverlay?: boolean;
}
const SortableNoteItem = memo(function SortableNoteItem({
note,
onEdit,
onSizeChange,
onDragStartNote,
onDragEndNote,
isDragging,
isOverlay,
}: SortableNoteProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging: isSortableDragging,
} = useSortable({ id: note.id });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isSortableDragging && !isOverlay ? 0.3 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className="masonry-sortable-item"
data-id={note.id}
data-size={note.size}
>
<NoteCard
note={note}
onEdit={onEdit}
onDragStart={onDragStartNote}
onDragEnd={onDragEndNote}
isDragging={isDragging}
onSizeChange={(newSize) => onSizeChange(note.id, newSize)}
/>
</div>
);
})
// ─────────────────────────────────────────────
// Sortable Grid Section (pinned or others)
// ─────────────────────────────────────────────
interface SortableGridSectionProps {
notes: Note[];
onEdit: (note: Note, readOnly?: boolean) => void;
onSizeChange: (noteId: string, newSize: 'small' | 'medium' | 'large') => void;
draggedNoteId: string | null;
onDragStartNote: (noteId: string) => void;
onDragEndNote: () => void;
}
const SortableGridSection = memo(function SortableGridSection({
notes,
onEdit,
onSizeChange,
draggedNoteId,
onDragStartNote,
onDragEndNote,
}: SortableGridSectionProps) {
const ids = useMemo(() => notes.map(n => n.id), [notes]);
return (
<SortableContext items={ids} strategy={rectSortingStrategy}>
<div className="masonry-css-grid">
{notes.map(note => (
<SortableNoteItem
key={note.id}
note={note}
onEdit={onEdit}
onSizeChange={onSizeChange}
onDragStartNote={onDragStartNote}
onDragEndNote={onDragEndNote}
isDragging={draggedNoteId === note.id}
/>
))}
</div>
</SortableContext>
);
});
// ─────────────────────────────────────────────
// Main MasonryGrid component
// ─────────────────────────────────────────────
export function MasonryGrid({ notes, onEdit, onSizeChange }: MasonryGridProps) {
const { t } = useLanguage();
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null);
const { startDrag, endDrag, draggedNoteId } = useNotebookDrag();
// Local notes state for optimistic size/order updates
const [localNotes, setLocalNotes] = useState<Note[]>(notes);
useEffect(() => {
setLocalNotes(prev => {
const localSizeMap = new Map(prev.map(n => [n.id, n.size]))
return notes.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }))
})
}, [notes]);
const pinnedNotes = useMemo(() => localNotes.filter(n => n.isPinned), [localNotes]);
const othersNotes = useMemo(() => localNotes.filter(n => !n.isPinned), [localNotes]);
const [activeId, setActiveId] = useState<string | null>(null);
const activeNote = useMemo(
() => localNotes.find(n => n.id === activeId) ?? null,
[localNotes, activeId]
);
const handleEdit = useCallback((note: Note, readOnly?: boolean) => {
if (onEdit) {
onEdit(note, readOnly);
} else {
setEditingNote({ note, readOnly });
}
}, [onEdit]);
const handleSizeChange = useCallback((noteId: string, newSize: 'small' | 'medium' | 'large') => {
setLocalNotes(prev => prev.map(n => n.id === noteId ? { ...n, size: newSize } : n));
onSizeChange?.(noteId, newSize);
}, [onSizeChange]);
// @dnd-kit sensors — pointer (desktop) + touch (mobile)
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 }, // Évite les activations accidentelles
}),
useSensor(TouchSensor, {
activationConstraint: { delay: 200, tolerance: 8 }, // Long-press sur mobile
})
);
const localNotesRef = useRef<Note[]>(localNotes)
useEffect(() => {
localNotesRef.current = localNotes
}, [localNotes])
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveId(event.active.id as string);
startDrag(event.active.id as string);
}, [startDrag]);
const handleDragEnd = useCallback(async (event: DragEndEvent) => {
const { active, over } = event;
setActiveId(null);
endDrag();
if (!over || active.id === over.id) return;
const reordered = arrayMove(
localNotesRef.current,
localNotesRef.current.findIndex(n => n.id === active.id),
localNotesRef.current.findIndex(n => n.id === over.id),
);
if (reordered.length === 0) return;
setLocalNotes(reordered);
// Persist order outside of setState to avoid "setState in render" warning
const ids = reordered.map(n => n.id);
updateFullOrderWithoutRevalidation(ids).catch(err => {
console.error('Failed to persist order:', err);
});
}, [endDrag]);
return (
<DndContext
id="masonry-dnd"
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="masonry-container">
{pinnedNotes.length > 0 && (
<div className="mb-8">
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3 px-2">
{t('notes.pinned')}
</h2>
<SortableGridSection
notes={pinnedNotes}
onEdit={handleEdit}
onSizeChange={handleSizeChange}
draggedNoteId={draggedNoteId}
onDragStartNote={startDrag}
onDragEndNote={endDrag}
/>
</div>
)}
{othersNotes.length > 0 && (
<div>
{pinnedNotes.length > 0 && (
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3 px-2">
{t('notes.others')}
</h2>
)}
<SortableGridSection
notes={othersNotes}
onEdit={handleEdit}
onSizeChange={handleSizeChange}
draggedNoteId={draggedNoteId}
onDragStartNote={startDrag}
onDragEndNote={endDrag}
/>
</div>
)}
</div>
{/* DragOverlay — montre une copie flottante pendant le drag */}
<DragOverlay>
{activeNote ? (
<div className="masonry-sortable-item masonry-drag-overlay" data-size={activeNote.size}>
<NoteCard
note={activeNote}
onEdit={handleEdit}
isDragging={true}
onSizeChange={(newSize) => handleSizeChange(activeNote.id, newSize)}
/>
</div>
) : null}
</DragOverlay>
{editingNote && (
<NoteEditor
note={editingNote.note}
readOnly={editingNote.readOnly}
onClose={() => setEditingNote(null)}
/>
)}
</DndContext>
);
}

View File

@@ -1,677 +0,0 @@
'use client'
import { Note, NOTE_COLORS, NoteColor } from '@/lib/types'
import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Pin, Bell, GripVertical, X, Link2, FolderOpen, StickyNote, LucideIcon, Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2, LogOut, Trash2 } from 'lucide-react'
import { useState, useEffect, useTransition, useOptimistic, memo } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter, useSearchParams } from 'next/navigation'
import { deleteNote, toggleArchive, togglePin, updateColor, updateNote, updateSize, getNoteAllUsers, leaveSharedNote, removeFusedBadge } from '@/app/actions/notes'
import { cn } from '@/lib/utils'
import { formatDistanceToNow, Locale } from 'date-fns'
import { enUS } from 'date-fns/locale/en-US'
import { fr } from 'date-fns/locale/fr'
import { es } from 'date-fns/locale/es'
import { de } from 'date-fns/locale/de'
import { faIR } from 'date-fns/locale/fa-IR'
import { it } from 'date-fns/locale/it'
import { pt } from 'date-fns/locale/pt'
import { ru } from 'date-fns/locale/ru'
import { zhCN } from 'date-fns/locale/zh-CN'
import { ja } from 'date-fns/locale/ja'
import { ko } from 'date-fns/locale/ko'
import { ar } from 'date-fns/locale/ar'
import { hi } from 'date-fns/locale/hi'
import { nl } from 'date-fns/locale/nl'
import { pl } from 'date-fns/locale/pl'
import { MarkdownContent } from './markdown-content'
import { LabelBadge } from './label-badge'
import { NoteImages } from './note-images'
import { NoteChecklist } from './note-checklist'
import { NoteActions } from './note-actions'
import { CollaboratorDialog } from './collaborator-dialog'
import { CollaboratorAvatars } from './collaborator-avatars'
import { ConnectionsBadge } from './connections-badge'
import { ConnectionsOverlay } from './connections-overlay'
import { ComparisonModal } from './comparison-modal'
import { useConnectionsCompare } from '@/hooks/use-connections-compare'
import { useLabels } from '@/context/LabelContext'
import { useLanguage } from '@/lib/i18n'
import { useNotebooks } from '@/context/notebooks-context'
import { toast } from 'sonner'
// Mapping of supported languages to date-fns locales
const localeMap: Record<string, Locale> = {
en: enUS,
fr: fr,
es: es,
de: de,
fa: faIR,
it: it,
pt: pt,
ru: ru,
zh: zhCN,
ja: ja,
ko: ko,
ar: ar,
hi: hi,
nl: nl,
pl: pl,
}
function getDateLocale(language: string): Locale {
return localeMap[language] || enUS
}
// Map icon names to lucide-react components
const ICON_MAP: Record<string, LucideIcon> = {
'folder': Folder,
'briefcase': Briefcase,
'document': FileText,
'lightning': Zap,
'chart': BarChart3,
'globe': Globe,
'sparkle': Sparkles,
'book': Book,
'heart': Heart,
'crown': Crown,
'music': Music,
'building': Building2,
}
// Function to get icon component by name
function getNotebookIcon(iconName: string): LucideIcon {
const IconComponent = ICON_MAP[iconName] || Folder
return IconComponent
}
interface NoteCardProps {
note: Note
onEdit?: (note: Note, readOnly?: boolean) => void
isDragging?: boolean
isDragOver?: boolean
onDragStart?: (noteId: string) => void
onDragEnd?: () => void
onResize?: () => void
onSizeChange?: (newSize: 'small' | 'medium' | 'large') => void
}
// Helper function to get initials from name
function getInitials(name: string): string {
if (!name) return '??'
const trimmedName = name.trim()
const parts = trimmedName.split(' ')
if (parts.length === 1) {
return trimmedName.substring(0, 2).toUpperCase()
}
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
}
// Helper function to get avatar color based on name hash
function getAvatarColor(name: string): string {
const colors = [
'bg-primary',
'bg-purple-500',
'bg-green-500',
'bg-orange-500',
'bg-pink-500',
'bg-teal-500',
'bg-red-500',
'bg-indigo-500',
]
const hash = name.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
return colors[hash % colors.length]
}
export const NoteCard = memo(function NoteCard({
note,
onEdit,
onDragStart,
onDragEnd,
isDragging,
onResize,
onSizeChange
}: NoteCardProps) {
const router = useRouter()
const searchParams = useSearchParams()
const { refreshLabels } = useLabels()
const { data: session } = useSession()
const { t, language } = useLanguage()
const { notebooks, moveNoteToNotebookOptimistic } = useNotebooks()
const [, startTransition] = useTransition()
const [isDeleting, setIsDeleting] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [showCollaboratorDialog, setShowCollaboratorDialog] = useState(false)
const [collaborators, setCollaborators] = useState<any[]>([])
const [owner, setOwner] = useState<any>(null)
const [showConnectionsOverlay, setShowConnectionsOverlay] = useState(false)
const [comparisonNotes, setComparisonNotes] = useState<string[] | null>(null)
const [showNotebookMenu, setShowNotebookMenu] = useState(false)
// Move note to a notebook
const handleMoveToNotebook = async (notebookId: string | null) => {
await moveNoteToNotebookOptimistic(note.id, notebookId)
setShowNotebookMenu(false)
// No need for router.refresh() - triggerRefresh() is already called in moveNoteToNotebookOptimistic
}
// Optimistic UI state for instant feedback
const [optimisticNote, addOptimisticNote] = useOptimistic(
note,
(state, newProps: Partial<Note>) => ({ ...state, ...newProps })
)
// Local color state so color persists after transition ends
const [localColor, setLocalColor] = useState(note.color)
const colorClasses = NOTE_COLORS[(localColor || optimisticNote.color) as NoteColor] || NOTE_COLORS.default
// Check if this note is currently open in the editor
const isNoteOpenInEditor = searchParams.get('note') === note.id
// Only fetch comparison notes when we have IDs to compare
const { notes: comparisonNotesData, isLoading: isLoadingComparison } = useConnectionsCompare(
comparisonNotes && comparisonNotes.length > 0 ? comparisonNotes : null
)
const currentUserId = session?.user?.id
const canManageCollaborators = currentUserId && note.userId && currentUserId === note.userId
const isSharedNote = currentUserId && note.userId && currentUserId !== note.userId
const isOwner = currentUserId && note.userId && currentUserId === note.userId
// Load collaborators only for shared notes (not owned by current user)
useEffect(() => {
// Skip API call for notes owned by current user — no need to fetch collaborators
if (!isSharedNote) {
// For own notes, set owner to current user
if (currentUserId && session?.user) {
setOwner({
id: currentUserId,
name: session.user.name,
email: session.user.email,
image: session.user.image,
})
}
return
}
let isMounted = true
const loadCollaborators = async () => {
if (note.userId && isMounted) {
try {
const users = await getNoteAllUsers(note.id)
if (isMounted) {
setCollaborators(users)
if (users.length > 0) {
setOwner(users[0])
}
}
} catch (error) {
console.error('Failed to load collaborators:', error)
if (isMounted) {
setCollaborators([])
}
}
}
}
loadCollaborators()
return () => {
isMounted = false
}
}, [note.id, note.userId, isSharedNote, currentUserId, session?.user])
const handleDelete = async () => {
setIsDeleting(true)
try {
await deleteNote(note.id)
await refreshLabels()
} catch (error) {
console.error('Failed to delete note:', error)
setIsDeleting(false)
}
}
const handleTogglePin = async () => {
startTransition(async () => {
addOptimisticNote({ isPinned: !note.isPinned })
await togglePin(note.id, !note.isPinned)
if (!note.isPinned) {
toast.success(t('notes.pinned') || 'Note pinned')
} else {
toast.info(t('notes.unpinned') || 'Note unpinned')
}
})
}
const handleToggleArchive = async () => {
startTransition(async () => {
addOptimisticNote({ isArchived: !note.isArchived })
await toggleArchive(note.id, !note.isArchived)
})
}
const handleColorChange = async (color: string) => {
setLocalColor(color) // instant visual update, survives transition
startTransition(async () => {
addOptimisticNote({ color })
await updateNote(note.id, { color }, { skipRevalidation: false })
})
}
const handleSizeChange = (size: 'small' | 'medium' | 'large') => {
// Notifier le parent immédiatement (hors transition) — c'est lui
// qui détient la source de vérité via localNotes
onSizeChange?.(size)
onResize?.()
// Persister en arrière-plan
updateSize(note.id, size).catch(err =>
console.error('Failed to update note size:', err)
)
}
const handleCheckItem = async (checkItemId: string) => {
if (note.type === 'checklist' && Array.isArray(note.checkItems)) {
const updatedItems = note.checkItems.map(item =>
item.id === checkItemId ? { ...item, checked: !item.checked } : item
)
startTransition(async () => {
addOptimisticNote({ checkItems: updatedItems })
await updateNote(note.id, { checkItems: updatedItems })
// No router.refresh() — optimistic update is sufficient and avoids grid rebuild
})
}
}
const handleLeaveShare = async () => {
if (confirm(t('notes.confirmLeaveShare'))) {
try {
await leaveSharedNote(note.id)
setIsDeleting(true) // Hide the note from view
} catch (error) {
console.error('Failed to leave share:', error)
}
}
}
const handleRemoveFusedBadge = async (e: React.MouseEvent) => {
e.stopPropagation() // Prevent opening the note editor
startTransition(async () => {
addOptimisticNote({ autoGenerated: null })
await removeFusedBadge(note.id)
// No router.refresh() — optimistic update is sufficient and avoids grid rebuild
})
}
if (isDeleting) return null
const getMinHeight = (size?: string) => {
switch (size) {
case 'medium': return '350px'
case 'large': return '500px'
default: return '150px' // small
}
}
return (
<Card
data-testid="note-card"
data-draggable="true"
data-note-id={note.id}
data-size={optimisticNote.size}
style={{ minHeight: getMinHeight(optimisticNote.size) }}
draggable={true}
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', note.id)
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/html', '') // Prevent ghost image in some browsers
onDragStart?.(note.id)
}}
onDragEnd={() => onDragEnd?.()}
className={cn(
'note-card group relative rounded-2xl overflow-hidden p-5 border shadow-sm',
'transition-all duration-200 ease-out',
'hover:shadow-xl hover:-translate-y-1',
colorClasses.bg,
colorClasses.card,
colorClasses.hover,
colorClasses.hover,
isDragging && 'shadow-2xl' // Removed opacity, scale, and rotation for clean drag
)}
onClick={(e) => {
// Only trigger edit if not clicking on buttons
const target = e.target as HTMLElement
if (!target.closest('button') && !target.closest('[role="checkbox"]') && !target.closest('.muuri-drag-handle') && !target.closest('.drag-handle')) {
// For shared notes, pass readOnly flag
onEdit?.(note, !!isSharedNote) // Pass second parameter as readOnly flag (convert to boolean)
}
}}
>
{/* Drag Handle - Only visible on mobile/touch devices */}
<div
className="muuri-drag-handle absolute top-2 left-2 z-20 cursor-grab active:cursor-grabbing p-2 md:hidden"
aria-label={t('notes.dragToReorder') || 'Drag to reorder'}
title={t('notes.dragToReorder') || 'Drag to reorder'}
>
<GripVertical className="h-5 w-5 text-muted-foreground" />
</div>
{/* Move to Notebook Dropdown Menu */}
<div onClick={(e) => e.stopPropagation()} className="absolute top-2 right-2 z-20">
<DropdownMenu open={showNotebookMenu} onOpenChange={setShowNotebookMenu}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 bg-primary/10 dark:bg-primary/20 hover:bg-primary/20 dark:hover:bg-primary/30 text-primary dark:text-primary-foreground"
title={t('notebookSuggestion.moveToNotebook')}
>
<FolderOpen className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
{t('notebookSuggestion.moveToNotebook')}
</div>
<DropdownMenuItem onClick={() => handleMoveToNotebook(null)}>
<StickyNote className="h-4 w-4 mr-2" />
{t('notebookSuggestion.generalNotes')}
</DropdownMenuItem>
{notebooks.map((notebook: any) => {
const NotebookIcon = getNotebookIcon(notebook.icon || 'folder')
return (
<DropdownMenuItem
key={notebook.id}
onClick={() => handleMoveToNotebook(notebook.id)}
>
<NotebookIcon className="h-4 w-4 mr-2" />
{notebook.name}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Pin Button - Visible on hover or if pinned */}
<Button
variant="ghost"
size="sm"
data-testid="pin-button"
className={cn(
"absolute top-2 right-12 z-20 min-h-[44px] min-w-[44px] h-8 w-8 p-0 rounded-md transition-opacity",
optimisticNote.isPinned ? "opacity-100" : "opacity-0 group-hover:opacity-100"
)}
onClick={(e) => {
e.stopPropagation()
handleTogglePin()
}}
>
<Pin
className={cn("h-4 w-4", optimisticNote.isPinned ? "fill-current text-primary" : "text-muted-foreground")}
/>
</Button>
{/* Reminder Icon - Move slightly if pin button is there */}
{note.reminder && new Date(note.reminder) > new Date() && (
<Bell
className="absolute top-3 right-10 h-4 w-4 text-primary"
/>
)}
{/* Memory Echo Badges - Fusion + Connections (BEFORE Title) */}
<div className="flex flex-wrap gap-1 mb-2">
{/* Fusion Badge with remove button */}
{note.autoGenerated && (
<div className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 border border-purple-200 dark:border-purple-800 flex items-center gap-1 group/badge relative">
<Link2 className="h-2.5 w-2.5" />
{t('memoryEcho.fused')}
<button
onClick={handleRemoveFusedBadge}
className="ml-1 opacity-0 group-hover/badge:opacity-100 hover:opacity-100 transition-opacity"
title={t('notes.remove') || 'Remove'}
>
<Trash2 className="h-2.5 w-2.5" />
</button>
</div>
)}
{/* Connections Badge */}
<ConnectionsBadge
noteId={note.id}
onClick={() => {
// Only open overlay if note is NOT open in editor
// (to avoid having 2 Dialogs with 2 close buttons)
if (!isNoteOpenInEditor) {
setShowConnectionsOverlay(true)
}
}}
/>
</div>
{/* Title */}
{optimisticNote.title && (
<h3 className="text-base font-medium mb-2 pr-10 text-foreground">
{optimisticNote.title}
</h3>
)}
{/* Search Match Type Badge */}
{optimisticNote.matchType && (
<Badge
variant={optimisticNote.matchType === 'exact' ? 'default' : 'secondary'}
className={cn(
'mb-2 text-xs',
optimisticNote.matchType === 'exact'
? 'bg-green-100 text-green-800 border-green-200 dark:bg-green-900/30 dark:text-green-300 dark:border-green-800'
: 'bg-primary/10 text-primary border-primary/20 dark:bg-primary/20 dark:text-primary-foreground'
)}
>
{t(`semanticSearch.${optimisticNote.matchType === 'exact' ? 'exactMatch' : 'related'}`)}
</Badge>
)}
{/* Shared badge */}
{isSharedNote && owner && (
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-primary dark:text-primary-foreground font-medium">
{t('notes.sharedBy')} {owner.name || owner.email}
</span>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-gray-500 hover:text-red-600 dark:hover:text-red-400"
onClick={(e) => {
e.stopPropagation()
handleLeaveShare()
}}
>
<LogOut className="h-3 w-3 mr-1" />
{t('notes.leaveShare')}
</Button>
</div>
)}
{/* Images Component */}
<NoteImages images={optimisticNote.images || []} title={optimisticNote.title} />
{/* Link Previews */}
{Array.isArray(optimisticNote.links) && optimisticNote.links.length > 0 && (
<div className="flex flex-col gap-2 mb-2">
{optimisticNote.links.map((link, idx) => (
<a
key={idx}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="block border rounded-md overflow-hidden bg-white/50 dark:bg-black/20 hover:bg-white/80 dark:hover:bg-black/40 transition-colors"
onClick={(e) => e.stopPropagation()}
>
{link.imageUrl && (
<div className="h-24 bg-cover bg-center" style={{ backgroundImage: `url(${link.imageUrl})` }} />
)}
<div className="p-2">
<h4 className="font-medium text-xs truncate text-gray-900 dark:text-gray-100">{link.title || link.url}</h4>
{link.description && <p className="text-xs text-gray-500 dark:text-gray-400 line-clamp-2 mt-0.5">{link.description}</p>}
<span className="text-[10px] text-primary mt-1 block">
{new URL(link.url).hostname}
</span>
</div>
</a>
))}
</div>
)}
{/* Content */}
{optimisticNote.type === 'text' ? (
<div className="text-sm text-foreground line-clamp-10">
<MarkdownContent content={optimisticNote.content} />
</div>
) : (
<NoteChecklist
items={optimisticNote.checkItems || []}
onToggleItem={handleCheckItem}
/>
)}
{/* Labels - using shared LabelBadge component */}
{optimisticNote.notebookId && Array.isArray(optimisticNote.labels) && optimisticNote.labels.length > 0 && (
<div className="flex flex-wrap gap-1 mt-3">
{optimisticNote.labels.map((label) => (
<LabelBadge key={label} label={label} />
))}
</div>
)}
{/* Footer with Date only */}
<div className="mt-3 flex items-center justify-end">
{/* Creation Date */}
<div className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: getDateLocale(language) })}
</div>
</div>
{/* Owner Avatar - Aligned with action buttons at bottom */}
{owner && (
<div
className={cn(
"absolute bottom-2 left-2 z-20",
"w-6 h-6 rounded-full text-white text-[10px] font-semibold flex items-center justify-center",
getAvatarColor(owner.name || owner.email || 'Unknown')
)}
title={owner.name || owner.email || 'Unknown'}
>
{getInitials(owner.name || owner.email || '??')}
</div>
)}
{/* Action Bar Component - Always show for now to fix regression */}
{true && (
<NoteActions
isPinned={optimisticNote.isPinned}
isArchived={optimisticNote.isArchived}
currentColor={optimisticNote.color}
currentSize={optimisticNote.size as 'small' | 'medium' | 'large'}
onTogglePin={handleTogglePin}
onToggleArchive={handleToggleArchive}
onColorChange={handleColorChange}
onSizeChange={handleSizeChange}
onDelete={() => setShowDeleteDialog(true)}
onShareCollaborators={() => setShowCollaboratorDialog(true)}
className="absolute bottom-0 left-0 right-0 p-2 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity"
/>
)}
{/* Collaborator Dialog */}
{currentUserId && note.userId && (
<div onClick={(e) => e.stopPropagation()}>
<CollaboratorDialog
open={showCollaboratorDialog}
onOpenChange={setShowCollaboratorDialog}
noteId={note.id}
noteOwnerId={note.userId}
currentUserId={currentUserId}
/>
</div>
)}
{/* Connections Overlay */}
<div onClick={(e) => e.stopPropagation()}>
<ConnectionsOverlay
isOpen={showConnectionsOverlay}
onClose={() => setShowConnectionsOverlay(false)}
noteId={note.id}
onOpenNote={(noteId) => {
// Find the note and open it
onEdit?.(note, false)
}}
onCompareNotes={(noteIds) => {
setComparisonNotes(noteIds)
}}
/>
</div>
{/* Comparison Modal */}
{comparisonNotes && comparisonNotesData.length > 0 && (
<div onClick={(e) => e.stopPropagation()}>
<ComparisonModal
isOpen={!!comparisonNotes}
onClose={() => setComparisonNotes(null)}
notes={comparisonNotesData}
onOpenNote={(noteId) => {
const foundNote = comparisonNotesData.find(n => n.id === noteId)
if (foundNote) {
onEdit?.(foundNote, false)
}
}}
/>
</div>
)}
{/* Delete Confirmation Dialog */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('notes.confirmDeleteTitle') || t('notes.delete')}</AlertDialogTitle>
<AlertDialogDescription>
{t('notes.confirmDelete') || 'Are you sure you want to delete this note?'}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel') || 'Cancel'}</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={handleDelete}>
{t('notes.delete') || 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Card>
)
})

View File

@@ -1,44 +0,0 @@
'use client'
import dynamic from 'next/dynamic'
import { Note } from '@/lib/types'
import { NotesTabsView } from '@/components/notes-tabs-view'
const MasonryGridLazy = dynamic(
() => import('@/components/masonry-grid').then((m) => m.MasonryGrid),
{
ssr: false,
loading: () => (
<div
className="min-h-[200px] rounded-xl border border-dashed border-muted-foreground/20 bg-muted/30 animate-pulse"
aria-hidden
/>
),
}
)
export type NotesViewMode = 'masonry' | 'tabs'
interface NotesMainSectionProps {
notes: Note[]
viewMode: NotesViewMode
onEdit?: (note: Note, readOnly?: boolean) => void
onSizeChange?: (noteId: string, size: 'small' | 'medium' | 'large') => void
currentNotebookId?: string | null
}
export function NotesMainSection({ notes, viewMode, onEdit, onSizeChange, currentNotebookId }: NotesMainSectionProps) {
if (viewMode === 'tabs') {
return (
<div className="flex min-h-0 flex-1 flex-col" data-testid="notes-grid-tabs-wrap">
<NotesTabsView notes={notes} onEdit={onEdit} currentNotebookId={currentNotebookId} />
</div>
)
}
return (
<div data-testid="notes-grid">
<MasonryGridLazy notes={notes} onEdit={onEdit} onSizeChange={onSizeChange} />
</div>
)
}

View File

@@ -20,6 +20,10 @@ RUN npx prisma generate
FROM node:22-bookworm-slim AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
openssl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=deps /app/node_modules ./node_modules
COPY . .

View File

@@ -1,11 +1,10 @@
'use client'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { useState, useEffect } from 'react'
import { toast } from 'sonner'
import { Loader2, CheckCircle2, XCircle, Clock, Zap, Info } from 'lucide-react'
import { Loader2, CheckCircle2, XCircle, Clock, Zap, Info, Shield, Brain, MessageSquare, Search } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
interface TestResult {
@@ -65,14 +64,14 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' | 'chat' }) {
if (data.success) {
toast.success(
`${t('admin.aiTest.testSuccessToast', { type: type === 'tags' ? 'Tags' : 'Embeddings' })}`,
`${t('admin.aiTest.testSuccessToast', { type: type === 'tags' ? 'Tags' : type === 'chat' ? 'Chat' : 'Embeddings' })}`,
{
description: `Provider: ${data.provider} | Time: ${endTime - startTime}ms`
}
)
} else {
toast.error(
`${t('admin.aiTest.testFailedToast', { type: type === 'tags' ? 'Tags' : 'Embeddings' })}`,
`${t('admin.aiTest.testFailedToast', { type: type === 'tags' ? 'Tags' : type === 'chat' ? 'Chat' : 'Embeddings' })}`,
{
description: data.error || 'Unknown error'
}
@@ -93,7 +92,7 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' | 'chat' }) {
}
const getProviderInfo = () => {
if (!config) return { provider: t('admin.aiTest.testing'), model: t('admin.aiTest.testing') }
if (!config) return { provider: '...', model: '...' }
if (type === 'tags') {
return {
@@ -116,127 +115,164 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' | 'chat' }) {
const providerInfo = getProviderInfo()
return (
<div className="space-y-4">
{/* Provider Info */}
<div className="space-y-3 p-4 bg-muted/50 rounded-lg">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{t('admin.aiTest.provider')}</span>
<Badge variant="outline" className="text-xs">
{providerInfo.provider.toUpperCase()}
</Badge>
<div className="space-y-8">
{/* Current Config Summary */}
<div className="rounded-2xl border border-border bg-muted/20 overflow-hidden shadow-inner">
<div className="px-4 py-2.5 border-b border-border/50 bg-muted/40 flex items-center gap-2">
{type === 'tags' ? <Brain className="h-4 w-4 text-primary" /> :
type === 'embeddings' ? <Search className="h-4 w-4 text-green-600" /> :
<MessageSquare className="h-4 w-4 text-violet-600" />}
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{t('admin.aiTest.provider')}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{t('admin.aiTest.model')}</span>
<span className="text-sm text-muted-foreground font-mono">
{providerInfo.model}
</span>
<div className="p-6 space-y-5">
<div className="flex items-center justify-between gap-6">
<span className="text-sm font-bold text-muted-foreground truncate">{t('admin.aiTest.provider')}</span>
<Badge variant="secondary" className="font-mono text-xs py-1 h-7 px-4 bg-background border-border rounded-xl shadow-sm">
{providerInfo.provider}
</Badge>
</div>
<div className="flex items-center justify-between gap-6">
<span className="text-sm font-bold text-muted-foreground truncate">{t('admin.aiTest.model')}</span>
<span className="text-xs font-mono font-bold text-foreground truncate bg-background/80 px-3 py-1 rounded-lg border border-border/50" title={providerInfo.model}>
{providerInfo.model}
</span>
</div>
</div>
</div>
{/* Test Button */}
{/* Run Test Action */}
<Button
onClick={runTest}
disabled={isLoading}
className="w-full"
variant={result?.success ? 'default' : result?.success === false ? 'destructive' : 'default'}
className={`w-full shadow-lg py-7 h-auto rounded-2xl transition-all duration-300 relative overflow-hidden group/btn ${
isLoading ? 'opacity-90' : 'hover:scale-[1.02] active:scale-[0.98] hover:shadow-xl'
} ${
result?.success ? 'bg-primary' : result?.success === false ? 'bg-destructive hover:bg-destructive/90' : 'bg-primary hover:bg-primary/90'
}`}
>
<div className="absolute inset-0 bg-gradient-to-r from-white/0 via-white/10 to-white/0 -translate-x-full group-hover/btn:animate-[shimmer_2s_infinite] pointer-events-none" />
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('admin.aiTest.testing')}
</>
<div className="flex flex-col items-center gap-2">
<Loader2 className="h-6 w-6 animate-spin" />
<span className="text-xs font-bold tracking-widest uppercase">{t('admin.aiTest.testing')}...</span>
</div>
) : (
<>
<Zap className="mr-2 h-4 w-4" />
{t('admin.aiTest.runTest')}
</>
<div className="flex items-center justify-center gap-4">
<div className={`w-10 h-10 rounded-xl flex items-center justify-center transition-all ${
result?.success ? 'bg-white/20' : 'bg-white/20 group-hover/btn:scale-110'
}`}>
<Zap className="h-5 w-5" />
</div>
<span className="font-black tracking-tight text-lg py-1">
{t('admin.aiTest.runTest')}
</span>
</div>
)}
</Button>
{/* Results */}
{/* Result Display */}
{result && (
<Card className={result.success ? 'border-green-200 dark:border-green-900' : 'border-red-200 dark:border-red-900'}>
<CardContent className="pt-6">
{/* Status */}
<div className="flex items-center gap-2 mb-4">
{result.success ? (
<>
<CheckCircle2 className="h-5 w-5 text-green-600" />
<span className="font-semibold text-green-600 dark:text-green-400">{t('admin.aiTest.testPassed')}</span>
</>
) : (
<>
<XCircle className="h-5 w-5 text-red-600" />
<span className="font-semibold text-red-600 dark:text-red-400">{t('admin.aiTest.testFailed')}</span>
</>
<div className={`rounded-xl border transition-all duration-300 animate-in fade-in slide-in-from-top-2 ${
result.success
? 'bg-green-500/[0.03] border-green-500/20 shadow-green-500/[0.02] shadow-lg'
: 'bg-destructive/[0.03] border-destructive/20 shadow-destructive/[0.02] shadow-lg'
}`}>
<div className="p-5 space-y-5">
{/* Status Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{result.success ? (
<div className="w-8 h-8 rounded-full bg-green-500/20 flex items-center justify-center text-green-600">
<CheckCircle2 className="h-5 w-5" />
</div>
) : (
<div className="w-8 h-8 rounded-full bg-destructive/20 flex items-center justify-center text-destructive">
<XCircle className="h-5 w-5" />
</div>
)}
<span className={`font-bold tracking-tight ${result.success ? 'text-green-700 dark:text-green-400' : 'text-destructive'}`}>
{result.success ? t('admin.aiTest.testPassed') : t('admin.aiTest.testFailed')}
</span>
</div>
{result.responseTime && (
<div className="flex items-center gap-1.5 px-2 py-1 bg-background/50 rounded-md border border-border/50 text-[10px] font-bold text-muted-foreground">
<Clock className="h-3 w-3" />
{result.responseTime}ms
</div>
)}
</div>
{/* Response Time */}
{result.responseTime && (
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-4">
<Clock className="h-4 w-4" />
<span>{t('admin.aiTest.responseTime', { time: result.responseTime })}</span>
</div>
)}
{/* Tags Results */}
{/* Response Content - Tags */}
{type === 'tags' && result.success && result.tags && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Info className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">{t('admin.aiTest.generatedTags')}</span>
<div className="space-y-3 pt-3 border-t border-border/10">
<div className="flex items-center gap-2 text-xs font-semibold text-muted-foreground uppercase tracking-widest">
<Info className="h-3.5 w-3.5" />
{t('admin.aiTest.generatedTags')}
</div>
<div className="flex flex-wrap gap-2">
{result.tags.map((tag, idx) => (
<Badge
<div
key={idx}
variant="secondary"
className="text-sm"
className="group relative flex items-center gap-2 px-3 py-1.5 bg-background border border-border rounded-lg text-sm font-medium hover:border-primary/30 hover:bg-primary/[0.02] transition-colors"
>
{tag.tag}
<span className="ml-1 text-xs opacity-70">
({Math.round(tag.confidence * 100)}%)
<span className="text-foreground">{tag.tag}</span>
<span className="text-[10px] text-muted-foreground bg-muted px-1 rounded font-mono">
{Math.round(tag.confidence * 100)}%
</span>
</Badge>
</div>
))}
</div>
</div>
)}
{/* Chat Response */}
{/* Response Content - Chat */}
{type === 'chat' && result.success && result.chatResponse && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Info className="h-4 w-4 text-violet-600" />
<span className="text-sm font-medium">Réponse du modèle</span>
<div className="space-y-3 pt-3 border-t border-border/10">
<div className="flex items-center gap-2 text-xs font-semibold text-muted-foreground uppercase tracking-widest">
<MessageSquare className="h-3.5 w-3.5" />
Modèle Réponse
</div>
<div className="p-3 bg-muted rounded-lg">
<p className="text-sm italic">&quot;{result.chatResponse}&quot;</p>
<div className="p-4 bg-background/50 border border-border/50 rounded-xl relative">
<div className="absolute -left-1.5 top-4 w-3 h-3 bg-background border-l border-t border-border/50 rotate-[-45deg] rounded-sm" />
<p className="text-sm leading-relaxed text-foreground italic">
&quot;{result.chatResponse}&quot;
</p>
</div>
</div>
)}
{/* Embeddings Results */}
{/* Response Content - Embeddings */}
{type === 'embeddings' && result.success && result.embeddingLength && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Info className="h-4 w-4 text-green-600" />
<span className="text-sm font-medium">{t('admin.aiTest.embeddingDimensions')}</span>
</div>
<div className="p-3 bg-muted rounded-lg">
<div className="text-2xl font-bold text-center">
{result.embeddingLength}
<div className="space-y-4 pt-3 border-t border-border/10">
<div className="grid grid-cols-2 gap-4">
<div className="bg-background/50 border border-border/50 rounded-xl p-4 text-center">
<div className="text-2xl font-black tracking-tighter text-foreground">
{result.embeddingLength}
</div>
<div className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mt-1">
{t('admin.aiTest.vectorDimensions')}
</div>
</div>
<div className="text-xs text-center text-muted-foreground mt-1">
{t('admin.aiTest.vectorDimensions')}
<div className="bg-background/50 border border-border/50 rounded-xl p-4 text-center flex flex-col justify-center">
<div className="flex items-center justify-center gap-1">
<div className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
<span className="text-xs font-bold text-foreground">Active Vector</span>
</div>
</div>
</div>
{result.firstValues && result.firstValues.length > 0 && (
<div className="space-y-1">
<span className="text-xs font-medium">{t('admin.aiTest.first5Values')}</span>
<div className="p-2 bg-muted rounded font-mono text-xs">
[{result.firstValues.slice(0, 5).map((v, i) => v.toFixed(4)).join(', ')}]
<div className="space-y-2">
<div className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t('admin.aiTest.first5Values')}
</div>
<div className="p-3 bg-muted/50 rounded-lg font-mono text-[11px] leading-none flex justify-between overflow-x-auto whitespace-nowrap gap-2 scrollbar-hide border border-border/50">
{result.firstValues.slice(0, 5).map((v, i) => (
<span key={i} className="text-foreground tabular-nums">
{v.toFixed(4)}
</span>
))}
</div>
</div>
)}
@@ -245,32 +281,30 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' | 'chat' }) {
{/* Error Details */}
{!result.success && result.error && (
<div className="mt-4 p-3 bg-red-50 dark:bg-red-950/20 rounded-lg border border-red-200 dark:border-red-900">
<p className="text-sm font-medium text-red-900 dark:text-red-100">{t('admin.aiTest.error')}</p>
<p className="text-sm text-red-700 dark:text-red-300 mt-1">{result.error}</p>
{result.details && (
<details className="mt-2">
<summary className="text-xs cursor-pointer text-red-600 dark:text-red-400">
{t('admin.aiTest.technicalDetails')}
</summary>
<pre className="mt-2 text-xs overflow-auto p-2 bg-red-100 dark:bg-red-900/30 rounded">
{JSON.stringify(result.details, null, 2)}
</pre>
</details>
)}
<div className="space-y-3 pt-3 border-t border-border/10">
<div className="p-4 bg-destructive/[0.05] border border-destructive/20 rounded-xl">
<div className="flex items-center gap-2 mb-2">
<Info className="h-4 w-4 text-destructive" />
<span className="text-xs font-bold text-destructive uppercase tracking-widest">Détails de l&apos;erreur</span>
</div>
<p className="text-sm text-destructive font-medium leading-relaxed">{result.error}</p>
{result.details && (
<details className="mt-4 group">
<summary className="text-[10px] font-bold uppercase tracking-widest cursor-pointer text-muted-foreground hover:text-destructive transition-colors">
{t('admin.aiTest.technicalDetails')}
</summary>
<div className="mt-2 p-3 bg-black/5 dark:bg-black/20 rounded-lg overflow-x-auto border border-border/50">
<pre className="text-[10px] font-mono text-muted-foreground leading-tight">
{JSON.stringify(result.details, null, 2)}
</pre>
</div>
</details>
)}
</div>
</div>
)}
</CardContent>
</Card>
)}
{/* Loading State */}
{isLoading && (
<div className="text-center py-4">
<Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
<p className="text-sm text-muted-foreground mt-2">
{t('admin.aiTest.testingType', { type: type === 'tags' ? 'tags generation' : 'embeddings' })}
</p>
</div>
</div>
)}
</div>

View File

@@ -1,8 +1,7 @@
'use client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { ArrowLeft, TestTube } from 'lucide-react'
import { ArrowLeft, TestTube, Info, Sparkles, Zap, Brain, MessageSquare, Search } from 'lucide-react'
import { AI_TESTER } from './ai-tester'
import { useLanguage } from '@/lib/i18n'
@@ -10,109 +9,140 @@ export default function AITestPage() {
const { t } = useLanguage()
return (
<div className="container mx-auto py-10 px-4 max-w-6xl">
<div className="flex justify-between items-center mb-8">
<div className="flex items-center gap-3">
<a href="/admin/settings">
<Button variant="outline" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</a>
<div>
<h1 className="text-3xl font-bold flex items-center gap-2">
<TestTube className="h-8 w-8" />
{t('admin.aiTest.title')}
</h1>
<p className="text-muted-foreground mt-1">
{t('admin.aiTest.description')}
</p>
<div className="bg-background flex flex-col min-h-screen">
<div className="flex-1 overflow-y-auto">
<div className="max-w-[1800px] mx-auto px-6 md:px-16 py-12 space-y-16">
{/* Header - Even larger and more spaced */}
<div className="flex flex-col md:flex-row md:items-end justify-between gap-10 border-b border-border/40 pb-12">
<div className="flex items-start gap-8">
<a href="/admin/settings" className="mt-2">
<Button variant="outline" size="icon" className="rounded-3xl h-16 w-16 border-border/60 bg-card hover:bg-muted transition-all shadow-md group">
<ArrowLeft className="h-7 w-7 group-hover:-translate-x-1 transition-transform" />
</Button>
</a>
<div>
<div className="flex items-center gap-4 mb-3">
<div className="w-12 h-12 rounded-2xl bg-primary/10 flex items-center justify-center text-primary shadow-inner">
<TestTube className="h-7 w-7" />
</div>
<span className="text-sm font-black uppercase tracking-[0.3em] text-primary/70">Diagnostics Haute Précision</span>
</div>
<h1 className="text-5xl md:text-7xl font-black tracking-tight text-foreground leading-[1.1]">
{t('admin.aiTest.title')}
</h1>
<p className="text-muted-foreground mt-4 text-xl font-medium max-w-3xl leading-relaxed">
{t('admin.aiTest.description')}
</p>
</div>
</div>
<div className="flex flex-col items-end gap-3 px-8 py-5 bg-card border border-border/50 rounded-[2.5rem] shadow-sm">
<div className="flex items-center gap-4">
<div className="w-3 h-3 rounded-full bg-green-500 animate-pulse" />
<span className="text-base font-black uppercase tracking-widest text-foreground">Système Actif</span>
</div>
<p className="text-xs text-muted-foreground font-bold uppercase tracking-widest">Latence: Optimisée</p>
</div>
</div>
{/* New Horizontal Section for each Test - Maximum Width Utilization */}
<div className="space-y-12">
{/* 1. Tags Test - Horizontal Layout */}
<div className="bg-card rounded-[4rem] border border-border/60 shadow-xl overflow-hidden hover:shadow-2xl transition-all duration-700 group flex flex-col xl:flex-row">
<div className="xl:w-1/3 p-12 md:p-16 border-b xl:border-b-0 xl:border-r border-border/40 bg-gradient-to-br from-primary/[0.05] to-transparent relative overflow-hidden">
<div className="absolute -right-10 -bottom-10 opacity-[0.03] group-hover:opacity-[0.08] transition-all duration-700 group-hover:scale-125 group-hover:-rotate-12">
<Brain className="h-80 w-80 text-primary" />
</div>
<div className="relative space-y-8">
<div className="w-20 h-20 rounded-[1.5rem] bg-background flex items-center justify-center text-4xl shadow-2xl border border-border/50 group-hover:scale-110 transition-transform duration-500">
🏷
</div>
<div>
<h3 className="text-3xl md:text-4xl font-black text-foreground tracking-tight mb-4">{t('admin.aiTest.tagsTestTitle')}</h3>
<p className="text-lg text-muted-foreground font-bold opacity-80 leading-relaxed">{t('admin.aiTest.tagsTestDescription')}</p>
</div>
<div className="flex flex-wrap gap-3">
<span className="px-4 py-2 bg-primary/10 rounded-xl text-primary text-[10px] font-black uppercase tracking-widest">Auto-Labeling</span>
<span className="px-4 py-2 bg-primary/10 rounded-xl text-primary text-[10px] font-black uppercase tracking-widest">Suggestions</span>
</div>
</div>
</div>
<div className="xl:w-2/3 p-12 md:p-16 bg-gradient-to-l from-transparent to-primary/[0.01]">
<div className="max-w-4xl">
<AI_TESTER type="tags" />
</div>
</div>
</div>
{/* 2. Embeddings Test - Horizontal Layout */}
<div className="bg-card rounded-[4rem] border border-border/60 shadow-xl overflow-hidden hover:shadow-2xl transition-all duration-700 group flex flex-col xl:flex-row">
<div className="xl:w-1/3 p-12 md:p-16 border-b xl:border-b-0 xl:border-r border-border/40 bg-gradient-to-br from-green-500/[0.05] to-transparent relative overflow-hidden">
<div className="absolute -right-10 -bottom-10 opacity-[0.03] group-hover:opacity-[0.08] transition-all duration-700 group-hover:scale-125 group-hover:rotate-12">
<Search className="h-80 w-80 text-green-500" />
</div>
<div className="relative space-y-8">
<div className="w-20 h-20 rounded-[1.5rem] bg-background flex items-center justify-center text-4xl shadow-2xl border border-border/50 group-hover:scale-110 transition-transform duration-500">
🔍
</div>
<div>
<h3 className="text-3xl md:text-4xl font-black text-foreground tracking-tight mb-4">{t('admin.aiTest.embeddingsTestTitle')}</h3>
<p className="text-lg text-muted-foreground font-bold opacity-80 leading-relaxed">{t('admin.aiTest.embeddingsTestDescription')}</p>
</div>
<div className="flex flex-wrap gap-3">
<span className="px-4 py-2 bg-green-500/10 rounded-xl text-green-600 text-[10px] font-black uppercase tracking-widest">Vector Store</span>
<span className="px-4 py-2 bg-green-500/10 rounded-xl text-green-600 text-[10px] font-black uppercase tracking-widest">Similarity</span>
</div>
</div>
</div>
<div className="xl:w-2/3 p-12 md:p-16 bg-gradient-to-l from-transparent to-green-500/[0.01]">
<div className="max-w-4xl">
<AI_TESTER type="embeddings" />
</div>
</div>
</div>
{/* 3. Chat Test - Horizontal Layout */}
<div className="bg-card rounded-[4rem] border border-border/60 shadow-xl overflow-hidden hover:shadow-2xl transition-all duration-700 group flex flex-col xl:flex-row">
<div className="xl:w-1/3 p-12 md:p-16 border-b xl:border-b-0 xl:border-r border-border/40 bg-gradient-to-br from-violet-500/[0.05] to-transparent relative overflow-hidden">
<div className="absolute -right-10 -bottom-10 opacity-[0.03] group-hover:opacity-[0.08] transition-all duration-700 group-hover:scale-125 group-hover:-rotate-6">
<MessageSquare className="h-80 w-80 text-violet-500" />
</div>
<div className="relative space-y-8">
<div className="w-20 h-20 rounded-[1.5rem] bg-background flex items-center justify-center text-4xl shadow-2xl border border-border/50 group-hover:scale-110 transition-transform duration-500">
💬
</div>
<div>
<h3 className="text-3xl md:text-4xl font-black text-foreground tracking-tight mb-4">{t('admin.aiTest.chatTestTitle')}</h3>
<p className="text-lg text-muted-foreground font-bold opacity-80 leading-relaxed">{t('admin.aiTest.chatTestDescription')}</p>
</div>
<div className="flex flex-wrap gap-3">
<span className="px-4 py-2 bg-violet-500/10 rounded-xl text-violet-600 text-[10px] font-black uppercase tracking-widest">Conversational</span>
<span className="px-4 py-2 bg-violet-500/10 rounded-xl text-violet-600 text-[10px] font-black uppercase tracking-widest">Streaming</span>
</div>
</div>
</div>
<div className="xl:w-2/3 p-12 md:p-16 bg-gradient-to-l from-transparent to-violet-500/[0.01]">
<div className="max-w-4xl">
<AI_TESTER type="chat" />
</div>
</div>
</div>
</div>
{/* Tips Section - Broad and visible */}
<div className="bg-amber-500/5 border border-amber-500/20 rounded-[3rem] p-12 flex flex-col md:flex-row items-center gap-10">
<div className="w-24 h-24 rounded-3xl bg-amber-500/10 flex items-center justify-center text-5xl shrink-0 shadow-inner">
💡
</div>
<div className="space-y-4">
<h4 className="text-2xl font-black text-amber-700 dark:text-amber-400 uppercase tracking-tight">{t('admin.aiTest.tipTitle')}</h4>
<p className="text-xl text-amber-600/90 dark:text-amber-300/80 leading-relaxed font-bold">
{t('admin.aiTest.tipContent')}
</p>
</div>
</div>
</div>
</div>
<div className="grid gap-6 md:grid-cols-3">
{/* Tags Provider Test */}
<Card className="border-primary/20 dark:border-primary/30">
<CardHeader className="bg-primary/5 dark:bg-primary/10">
<CardTitle className="flex items-center gap-2">
<span className="text-2xl">🏷</span>
{t('admin.aiTest.tagsTestTitle')}
</CardTitle>
<CardDescription>
{t('admin.aiTest.tagsTestDescription')}
</CardDescription>
</CardHeader>
<CardContent className="pt-6">
<AI_TESTER type="tags" />
</CardContent>
</Card>
{/* Embeddings Provider Test */}
<Card className="border-green-200 dark:border-green-900">
<CardHeader className="bg-green-50/50 dark:bg-green-950/20">
<CardTitle className="flex items-center gap-2">
<span className="text-2xl">🔍</span>
{t('admin.aiTest.embeddingsTestTitle')}
</CardTitle>
<CardDescription>
{t('admin.aiTest.embeddingsTestDescription')}
</CardDescription>
</CardHeader>
<CardContent className="pt-6">
<AI_TESTER type="embeddings" />
</CardContent>
</Card>
{/* Chat Provider Test */}
<Card className="border-violet-200 dark:border-violet-900">
<CardHeader className="bg-violet-50/50 dark:bg-violet-950/20">
<CardTitle className="flex items-center gap-2">
<span className="text-2xl">💬</span>
Fournisseur de chat
</CardTitle>
<CardDescription>
Testez le fournisseur IA responsable de l&apos;assistant conversationnel
</CardDescription>
</CardHeader>
<CardContent className="pt-6">
<AI_TESTER type="chat" />
</CardContent>
</Card>
</div>
{/* Info Section */}
<Card className="mt-6">
<CardHeader>
<CardTitle> {t('admin.aiTest.howItWorksTitle')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<div>
<h4 className="font-semibold mb-2">{t('admin.aiTest.tagsGenerationTest')}</h4>
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
<li>{t('admin.aiTest.tagsStep1')}</li>
<li>{t('admin.aiTest.tagsStep2')}</li>
<li>{t('admin.aiTest.tagsStep3')}</li>
<li>{t('admin.aiTest.tagsStep4')}</li>
</ul>
</div>
<div>
<h4 className="font-semibold mb-2">{t('admin.aiTest.embeddingsTestLabel')}</h4>
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
<li>{t('admin.aiTest.embeddingsStep1')}</li>
<li>{t('admin.aiTest.embeddingsStep2')}</li>
<li>{t('admin.aiTest.embeddingsStep3')}</li>
<li>{t('admin.aiTest.embeddingsStep4')}</li>
</ul>
</div>
<div className="bg-amber-50 dark:bg-amber-950/20 p-4 rounded-lg border border-amber-200 dark:border-amber-900">
<p className="font-semibold text-amber-900 dark:text-amber-100">💡 {t('admin.aiTest.tipTitle')}</p>
<p className="text-amber-800 dark:text-amber-200 mt-1">
{t('admin.aiTest.tipContent')}
</p>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -56,33 +56,33 @@ export function AdminAIPageClient({
key: 'titleSuggestions' as const,
label: t('admin.ai.titleSuggestions'),
description: t('admin.ai.titleSuggestionsDesc'),
icon: <Sparkles className="h-4 w-4 text-yellow-500" />,
icon: <Sparkles className="h-4 w-4" />,
},
{
key: 'paragraphRefactor' as const,
label: t('admin.ai.aiAssistant'),
description: t('admin.ai.aiAssistantDesc'),
icon: <Brain className="h-4 w-4 text-purple-500" />,
icon: <Brain className="h-4 w-4" />,
},
{
key: 'memoryEcho' as const,
label: t('admin.ai.memoryEchoFeature'),
description: t('admin.ai.memoryEchoFeatureDesc'),
icon: <Zap className="h-4 w-4 text-amber-500" />,
icon: <Zap className="h-4 w-4" />,
},
{
key: 'languageDetection' as const,
label: t('admin.ai.languageDetection'),
description: t('admin.ai.languageDetectionDesc'),
icon: <Globe className="h-4 w-4 text-green-500" />,
icon: <Globe className="h-4 w-4" />,
},
{
key: 'autoLabeling' as const,
label: t('admin.ai.autoLabeling'),
description: t('admin.ai.autoLabelingDesc'),
icon: <Tag className="h-4 w-4 text-rose-500" />,
icon: <Tag className="h-4 w-4" />,
},
]
@@ -91,40 +91,40 @@ export function AdminAIPageClient({
title: t('admin.ai.activeFeatures'),
value: String(Object.values(features).filter(Boolean).length) + ' / ' + featureList.length,
trend: { value: 0, isPositive: true },
icon: <Zap className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />,
icon: <Zap className="h-5 w-5 text-primary" />,
},
{
title: t('admin.ai.successRate'),
value: '100%',
trend: { value: 0, isPositive: true },
icon: <TrendingUp className="h-5 w-5 text-green-600 dark:text-green-400" />,
icon: <TrendingUp className="h-5 w-5 text-green-600" />,
},
{
title: t('admin.ai.avgResponseTime'),
value: '—',
trend: { value: 0, isPositive: true },
icon: <Activity className="h-5 w-5 text-primary dark:text-primary-foreground" />,
icon: <Activity className="h-5 w-5 text-primary" />,
},
{
title: t('admin.ai.configuredProviders'),
value: String(providers.filter(p => p.status !== 'Not Configured').length),
icon: <Settings className="h-5 w-5 text-purple-600 dark:text-purple-400" />,
icon: <Settings className="h-5 w-5 text-primary" />,
},
]
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div className="space-y-8">
<div className="flex justify-between items-start">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
<h1 className="text-2xl font-bold tracking-tight text-foreground">
{t('admin.ai.pageTitle')}
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
<p className="text-muted-foreground mt-1">
{t('admin.ai.pageDescription')}
</p>
</div>
<Link href="/admin/settings">
<Button variant="outline">
<Button variant="outline" className="border-border">
<Settings className="mr-2 h-4 w-4" />
{t('admin.ai.configure')}
</Button>
@@ -133,25 +133,31 @@ export function AdminAIPageClient({
<AdminMetrics metrics={aiMetrics} />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Feature Toggles */}
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow overflow-hidden border border-gray-200 dark:border-gray-800 p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
{t('admin.ai.features')}
</h2>
<div className="space-y-4">
<div className="bg-card rounded-lg border border-border p-6 shadow-sm flex flex-col gap-4">
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
<Zap className="h-5 w-5" />
</div>
<div>
<h2 className="font-semibold text-foreground">{t('admin.ai.features')}</h2>
<p className="text-sm text-muted-foreground">Activez ou désactivez les fonctionnalités IA</p>
</div>
</div>
<div className="space-y-4 pt-2 border-t border-border">
{featureList.map(({ key, label, description, icon }) => (
<div
key={key}
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-zinc-800 rounded-lg"
className="flex items-start justify-between p-3 bg-muted rounded-lg border border-border/50"
>
<div className="flex items-center gap-3 flex-1 min-w-0">
{icon}
<div className="flex items-start gap-3 flex-1 min-w-0">
<div className="mt-0.5 text-primary">{icon}</div>
<div className="min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
<p className="text-sm font-medium text-foreground truncate">
{label}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
<p className="text-xs text-muted-foreground truncate">
{description}
</p>
</div>
@@ -160,7 +166,7 @@ export function AdminAIPageClient({
checked={features[key]}
onCheckedChange={(v) => handleToggle(key, v)}
disabled={saving === key}
className="ml-3 flex-shrink-0"
className="ml-3 mt-0.5 flex-shrink-0"
/>
</div>
))}
@@ -168,42 +174,53 @@ export function AdminAIPageClient({
</div>
{/* AI Provider Status */}
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow overflow-hidden border border-gray-200 dark:border-gray-800 p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
{t('admin.ai.providerStatus')}
</h2>
<div className="space-y-3">
{providers.map((provider) => (
<div
key={provider.name}
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-zinc-800 rounded-lg"
>
<p className="text-sm font-medium text-gray-900 dark:text-white">
{provider.name}
</p>
<span
className={`px-2 py-1 text-xs font-medium rounded-full ${
provider.status === 'Connected' || provider.status === 'Available'
? 'text-green-700 dark:text-green-400 bg-green-100 dark:bg-green-900'
: 'text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-800'
}`}
>
{provider.status}
</span>
<div className="flex flex-col gap-6">
<div className="bg-card rounded-lg border border-border p-6 shadow-sm flex flex-col gap-4">
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
<Settings className="h-5 w-5" />
</div>
))}
<div>
<h2 className="font-semibold text-foreground">{t('admin.ai.providerStatus')}</h2>
<p className="text-sm text-muted-foreground">État de vos fournisseurs connectés</p>
</div>
</div>
<div className="space-y-3 pt-2 border-t border-border">
{providers.map((provider) => (
<div
key={provider.name}
className="flex items-center justify-between p-3 bg-muted rounded-lg border border-border/50"
>
<p className="text-sm font-medium text-foreground">
{provider.name}
</p>
<span
className={`px-2 py-1 text-xs font-medium rounded-full ${
provider.status === 'Connected' || provider.status === 'Available'
? 'text-green-700 bg-green-500/10 border border-green-500/20'
: 'text-muted-foreground bg-muted-foreground/10 border border-muted-foreground/20'
}`}
>
{provider.status}
</span>
</div>
))}
</div>
</div>
<div className="bg-card rounded-lg border border-border p-6 shadow-sm">
<h2 className="text-sm font-semibold text-foreground mb-2">
{t('admin.ai.recentRequests')}
</h2>
<div className="p-4 rounded-lg bg-muted border border-border/50 flex flex-col items-center justify-center text-center">
<Activity className="h-6 w-6 text-muted-foreground mb-2" />
<p className="text-sm text-muted-foreground">
{t('admin.ai.comingSoon')}
</p>
</div>
</div>
</div>
</div>
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow overflow-hidden border border-gray-200 dark:border-gray-800 p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
{t('admin.ai.recentRequests')}
</h2>
<p className="text-gray-600 dark:text-gray-400">
{t('admin.ai.comingSoon')}
</p>
</div>
</div>
)
}

View File

@@ -1,6 +1,5 @@
import { AdminHeader } from '@/components/admin-header'
import { AdminSidebar } from '@/components/admin-sidebar'
import { AdminContentArea } from '@/components/admin-content-area'
import { AdminNav } from '@/components/admin-nav'
// Auth is enforced solely by middleware (auth.config.ts → authorized callback).
// All cross-group navigation (admin ↔ main) uses <a> tags (full page reload)
@@ -11,11 +10,19 @@ export default function AdminLayout({
children: React.ReactNode
}) {
return (
<div className="bg-background-light dark:bg-background-dark font-display text-slate-900 dark:text-white flex flex-col min-h-screen">
<div className="bg-background flex flex-col min-h-screen">
<AdminHeader />
<div className="flex flex-1">
<AdminSidebar />
<AdminContentArea>{children}</AdminContentArea>
{/* Horizontal Tab Navigation */}
<div className="flex items-center gap-1 px-8 bg-background border-b border-border shrink-0">
<AdminNav />
</div>
{/* Page Content */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-5xl mx-auto px-8 py-8 space-y-8">
{children}
</div>
</div>
</div>
)

View File

@@ -46,7 +46,7 @@ export default async function AdminPage() {
<AdminMetrics metrics={metrics} />
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow overflow-hidden border border-gray-200 dark:border-gray-800 p-6">
<div className="bg-card rounded-lg shadow-sm overflow-hidden border border-border p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Recent Activity
</h2>

View File

@@ -9,7 +9,7 @@ import { Combobox } from '@/components/ui/combobox'
import { updateSystemConfig, testEmail } from '@/app/actions/admin-settings'
import { toast } from 'sonner'
import { useState, useEffect, useCallback } from 'react'
import { TestTube, ExternalLink, RefreshCw } from 'lucide-react'
import { TestTube, ExternalLink, RefreshCw, Shield, Brain, Mail, Wrench } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
type AIProvider = 'ollama' | 'openai' | 'custom' | 'deepseek' | 'openrouter' | 'mistral' | 'zai' | 'lmstudio'
@@ -80,6 +80,7 @@ type ModelPurpose = 'tags' | 'embeddings' | 'chat'
export function AdminSettingsForm({ config }: { config: Record<string, string> }) {
const { t } = useLanguage()
const [activeAiTab, setActiveAiTab] = useState<'tags' | 'embeddings' | 'chat'>('tags')
const [isSaving, setIsSaving] = useState(false)
const [isTesting, setIsTesting] = useState(false)
@@ -546,14 +547,19 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
]
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>{t('admin.security.title')}</CardTitle>
<CardDescription>{t('admin.security.description')}</CardDescription>
</CardHeader>
<div className="columns-1 lg:columns-2 gap-6">
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden break-inside-avoid mb-6">
<div className="flex items-center gap-3 p-6 border-b border-border">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
<Shield className="h-5 w-5" />
</div>
<div>
<h2 className="font-semibold text-foreground">{t('admin.security.title')}</h2>
<p className="text-sm text-muted-foreground">{t('admin.security.description')}</p>
</div>
</div>
<form onSubmit={(e) => { e.preventDefault(); handleSaveSecurity(new FormData(e.currentTarget)) }}>
<CardContent className="space-y-4">
<div className="p-6 space-y-4">
<div className="flex items-center space-x-2">
<Checkbox
id="ALLOW_REGISTRATION"
@@ -570,22 +576,34 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
<p className="text-xs text-muted-foreground">
{t('admin.security.allowPublicRegistrationDescription')}
</p>
</CardContent>
<CardFooter>
</div>
<div className="px-6 pb-6">
<Button type="submit" disabled={isSaving}>{t('admin.security.title')}</Button>
</CardFooter>
</div>
</form>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>{t('admin.ai.title')}</CardTitle>
<CardDescription>{t('admin.ai.description')}</CardDescription>
</CardHeader>
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden break-inside-avoid mb-6">
<div className="flex items-center gap-3 p-6 border-b border-border">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
<Brain className="h-5 w-5" />
</div>
<div>
<h2 className="font-semibold text-foreground">{t('admin.ai.title')}</h2>
<p className="text-sm text-muted-foreground">{t('admin.ai.description')}</p>
</div>
</div>
<form onSubmit={(e) => { e.preventDefault(); handleSaveAI(new FormData(e.currentTarget)) }}>
<CardContent className="space-y-6">
<div className="px-6 pt-6">
<div className="flex border-b border-border/50 overflow-x-auto">
<button type="button" onClick={() => setActiveAiTab('tags')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'tags' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>🏷 Tags</button>
<button type="button" onClick={() => setActiveAiTab('embeddings')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'embeddings' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>🔍 Embeddings</button>
<button type="button" onClick={() => setActiveAiTab('chat')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'chat' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>💬 Chat</button>
</div>
</div>
<div className="p-6 space-y-6">
{/* Tags Generation Provider */}
<div className="space-y-4 p-4 border rounded-lg bg-primary/5 dark:bg-primary/10">
<div className={`space-y-4 p-4 border border-border/50 rounded-lg bg-muted/50 ${activeAiTab === 'tags' ? 'block' : 'hidden'}`}>
<h3 className="text-base font-semibold flex items-center gap-2">
<span className="text-primary">🏷</span> {t('admin.ai.tagsGenerationProvider')}
</h3>
@@ -615,7 +633,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
</div>
{/* Embeddings Provider */}
<div className="space-y-4 p-4 border rounded-lg bg-green-50/50 dark:bg-green-950/20">
<div className={`space-y-4 p-4 border border-border/50 rounded-lg bg-muted/50 ${activeAiTab === 'embeddings' ? 'block' : 'hidden'}`}>
<h3 className="text-base font-semibold flex items-center gap-2">
<span className="text-green-600">🔍</span> {t('admin.ai.embeddingsProvider')}
</h3>
@@ -650,7 +668,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
</div>
{/* Chat Provider */}
<div className="space-y-4 p-4 border rounded-lg bg-blue-50/50 dark:bg-blue-950/20">
<div className={`space-y-4 p-4 border border-border/50 rounded-lg bg-muted/50 ${activeAiTab === 'chat' ? 'block' : 'hidden'}`}>
<h3 className="text-base font-semibold flex items-center gap-2">
<span className="text-blue-600">💬</span> {t('admin.ai.chatProvider')}
</h3>
@@ -678,8 +696,8 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
<input type="hidden" name="AI_MODEL_CHAT" value={selectedChatModel} />
{renderProviderConfig(chatProvider, 'chat', selectedChatModel, setSelectedChatModel)}
</div>
</CardContent>
<CardFooter className="flex justify-between pt-6">
</div>
<div className="px-6 pb-6 flex flex-col sm:flex-row gap-3 sm:justify-between pt-6">
<Button type="submit" disabled={isSaving}>{isSaving ? t('admin.ai.saving') : t('admin.ai.saveSettings')}</Button>
<a href="/admin/ai-test">
<Button type="button" variant="outline" className="gap-2">
@@ -688,17 +706,22 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
<ExternalLink className="h-3 w-3" />
</Button>
</a>
</CardFooter>
</div>
</form>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>{t('admin.email.title')}</CardTitle>
<CardDescription>{t('admin.email.description')}</CardDescription>
</CardHeader>
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden break-inside-avoid mb-6">
<div className="flex items-center gap-3 p-6 border-b border-border">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
<Mail className="h-5 w-5" />
</div>
<div>
<h2 className="font-semibold text-foreground">{t('admin.email.title')}</h2>
<p className="text-sm text-muted-foreground">{t('admin.email.description')}</p>
</div>
</div>
<form onSubmit={(e) => { e.preventDefault(); handleSaveEmail(new FormData(e.currentTarget)) }}>
<CardContent className="space-y-4">
<div className="p-6 space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">{t('admin.email.provider')}</label>
<div className="flex gap-2">
@@ -826,23 +849,28 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
</div>
</div>
)}
</CardContent>
<CardFooter className="flex justify-between pt-6">
</div>
<div className="px-6 pb-6 flex flex-col sm:flex-row gap-3 sm:justify-between pt-6">
<Button type="submit" disabled={isSaving}>{t('admin.email.saveSettings')}</Button>
<Button type="button" variant="secondary" onClick={handleTestEmail} disabled={isTesting}>
{isTesting ? t('admin.smtp.sending') : t('admin.smtp.testEmail')}
</Button>
</CardFooter>
</div>
</form>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>{t('admin.tools.title')}</CardTitle>
<CardDescription>{t('admin.tools.description')}</CardDescription>
</CardHeader>
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden break-inside-avoid mb-6">
<div className="flex items-center gap-3 p-6 border-b border-border">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
<Wrench className="h-5 w-5" />
</div>
<div>
<h2 className="font-semibold text-foreground">{t('admin.tools.title')}</h2>
<p className="text-sm text-muted-foreground">{t('admin.tools.description')}</p>
</div>
</div>
<form onSubmit={(e) => { e.preventDefault(); handleSaveTools(new FormData(e.currentTarget)) }}>
<CardContent className="space-y-4">
<div className="p-6 space-y-4">
<div className="space-y-2">
<label htmlFor="WEB_SEARCH_PROVIDER" className="text-sm font-medium">{t('admin.tools.searchProvider')}</label>
<select
@@ -880,13 +908,13 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
{/* Search test result */}
{searchTestResult && (
<div className={`rounded-lg border p-3 text-sm flex items-start gap-2 ${searchTestResult.success ? 'border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950/30 dark:text-green-300' : 'border-red-200 bg-red-50 text-red-800 dark:border-red-800 dark:bg-red-950/30 dark:text-red-300'}`}>
<div className={`rounded-lg border p-3 text-sm flex items-start gap-2 ${searchTestResult.success ? 'border-green-500/20 bg-green-500/10 text-green-600' : 'border-red-500/20 bg-red-500/10 text-red-600'}`}>
<span className={`mt-0.5 inline-block w-2 h-2 rounded-full flex-shrink-0 ${searchTestResult.success ? 'bg-green-500' : 'bg-red-500'}`} />
<span>{searchTestResult.message}</span>
</div>
)}
</CardContent>
<CardFooter className="flex justify-between">
</div>
<div className="px-6 pb-6 flex flex-col sm:flex-row gap-3 sm:justify-between">
<Button type="submit" disabled={isSaving}>{t('admin.tools.saveSettings')}</Button>
<Button
type="button"
@@ -896,9 +924,9 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
>
{isTestingSearch ? t('admin.tools.testing') : t('admin.tools.testSearch')}
</Button>
</CardFooter>
</div>
</form>
</Card>
</div>
</div>
)
}

View File

@@ -6,11 +6,9 @@ export default async function AdminSettingsPage() {
const config = await getSystemConfig()
return (
<div className="space-y-6">
<div className="space-y-8">
<SettingsHeader />
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow overflow-hidden border border-gray-200 dark:border-gray-800 p-6">
<AdminSettingsForm config={config} />
</div>
<AdminSettingsForm config={config} />
</div>
)
}

View File

@@ -6,10 +6,10 @@ export function SettingsHeader() {
const { t } = useLanguage()
return (
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
<h1 className="text-2xl font-bold tracking-tight text-foreground">
{t('admin.settings')}
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
<p className="text-muted-foreground mt-1">
{t('admin.settingsDescription')}
</p>
</div>

View File

@@ -21,7 +21,7 @@ export default async function AdminUsersPage() {
<CreateUserDialog />
</div>
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow overflow-hidden border border-gray-200 dark:border-gray-800">
<div className="bg-card rounded-lg shadow-sm overflow-hidden border border-border">
<UserList initialUsers={users} />
</div>
</div>

View File

@@ -14,18 +14,18 @@ export const dynamic = 'force-dynamic'
export const revalidate = 0
export default async function LabPage(props: {
searchParams: Promise<{ id?: string }>
searchParams: Promise<{ id?: string; canvas?: string }>
}) {
const searchParams = await props.searchParams
const id = searchParams.id
/** Canonical param is id; notifications / older UI used canvas= */
const requestedId = searchParams.id ?? searchParams.canvas
const session = await auth()
if (!session?.user?.id) redirect('/login')
const canvases = await getCanvases()
// Resolve current canvas correctly
const currentCanvasId = searchParams.id || (canvases.length > 0 ? canvases[0].id : undefined)
const currentCanvasId = requestedId || (canvases.length > 0 ? canvases[0].id : undefined)
const currentCanvas = currentCanvasId ? canvases.find(c => c.id === currentCanvasId) : undefined
// Wrapper for server action creation

View File

@@ -1,9 +1,8 @@
'use client'
import { SettingsSection } from '@/components/settings'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { useLanguage } from '@/lib/i18n'
import { FileText, Sparkles, MessageCircle } from 'lucide-react'
export default function AboutSettingsPage() {
const { t } = useLanguage()
@@ -11,126 +10,95 @@ export default function AboutSettingsPage() {
const buildDate = '2026-01-17'
return (
<div className="space-y-6">
<div className="space-y-8">
<div>
<h1 className="text-3xl font-bold mb-2">{t('about.title')}</h1>
<p className="text-gray-600 dark:text-gray-400">
{t('about.description')}
</p>
<h1 className="text-2xl font-bold tracking-tight text-foreground">{t('about.title')}</h1>
<p className="text-muted-foreground mt-1">{t('about.description')}</p>
</div>
<SettingsSection
title={t('about.appName')}
icon={<span className="text-2xl">📝</span>}
description={t('about.appDescription')}
>
<Card>
<CardContent className="pt-6 space-y-4">
<div className="flex justify-between items-center">
<span className="font-medium">{t('about.version')}</span>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* App info */}
<div className="bg-card rounded-lg border border-border p-6 shadow-sm flex flex-col gap-4">
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
<FileText className="h-5 w-5" />
</div>
<div>
<h3 className="font-semibold text-foreground">{t('about.appName')}</h3>
<p className="text-sm text-muted-foreground">{t('about.appDescription')}</p>
</div>
</div>
<div className="space-y-3 pt-2 border-t border-border">
<div className="flex justify-between items-center text-sm">
<span className="text-muted-foreground">{t('about.version')}</span>
<Badge variant="secondary">{version}</Badge>
</div>
<div className="flex justify-between items-center">
<span className="font-medium">{t('about.buildDate')}</span>
<div className="flex justify-between items-center text-sm">
<span className="text-muted-foreground">{t('about.buildDate')}</span>
<Badge variant="outline">{buildDate}</Badge>
</div>
<div className="flex justify-between items-center">
<span className="font-medium">{t('about.platform')}</span>
<div className="flex justify-between items-center text-sm">
<span className="text-muted-foreground">{t('about.platform')}</span>
<Badge variant="outline">{t('about.platformWeb')}</Badge>
</div>
</CardContent>
</Card>
</SettingsSection>
</div>
</div>
<SettingsSection
title={t('about.features.title')}
icon={<span className="text-2xl"></span>}
description={t('about.features.description')}
>
<Card>
<CardContent className="pt-6 space-y-2">
<div className="flex items-center gap-2">
<span className="text-green-500"></span>
<span>{t('about.features.titleSuggestions')}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-green-500"></span>
<span>{t('about.features.semanticSearch')}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-green-500"></span>
<span>{t('about.features.paragraphReformulation')}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-green-500"></span>
<span>{t('about.features.memoryEcho')}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-green-500"></span>
<span>{t('about.features.notebookOrganization')}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-green-500"></span>
<span>{t('about.features.dragDrop')}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-green-500"></span>
<span>{t('about.features.labelSystem')}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-green-500"></span>
<span>{t('about.features.multipleProviders')}</span>
</div>
</CardContent>
</Card>
</SettingsSection>
<SettingsSection
title={t('about.technology.title')}
icon={<span className="text-2xl"></span>}
description={t('about.technology.description')}
>
<Card>
<CardContent className="pt-6 space-y-2 text-sm">
<div><strong>{t('about.technology.frontend')}:</strong> Next.js 16, React 19, TypeScript</div>
<div><strong>{t('about.technology.backend')}:</strong> Next.js API Routes, Server Actions</div>
<div><strong>{t('about.technology.database')}:</strong> SQLite (Prisma ORM)</div>
<div><strong>{t('about.technology.authentication')}:</strong> NextAuth 5</div>
<div><strong>{t('about.technology.ai')}:</strong> Vercel AI SDK, OpenAI, Ollama</div>
<div><strong>{t('about.technology.ui')}:</strong> Radix UI, Tailwind CSS, Lucide Icons</div>
<div><strong>{t('about.technology.testing')}:</strong> Playwright (E2E)</div>
</CardContent>
</Card>
</SettingsSection>
<SettingsSection
title={t('about.support.title')}
icon={<span className="text-2xl">💬</span>}
description={t('about.support.description')}
>
<Card>
<CardContent className="pt-6 space-y-4">
<div>
<p className="font-medium mb-2">{t('about.support.documentation')}</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
Check the documentation for detailed guides and tutorials.
</p>
{/* Features */}
<div className="bg-card rounded-lg border border-border p-6 shadow-sm flex flex-col gap-4">
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
<Sparkles className="h-5 w-5" />
</div>
<div>
<p className="font-medium mb-2">{t('about.support.reportIssues')}</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
Found a bug? Report it in the issue tracker.
</p>
<h3 className="font-semibold text-foreground">{t('about.features.title')}</h3>
<p className="text-sm text-muted-foreground">{t('about.features.description')}</p>
</div>
</div>
<ul className="space-y-2 pt-2 border-t border-border">
{[
t('about.features.titleSuggestions'),
t('about.features.semanticSearch'),
t('about.features.paragraphReformulation'),
t('about.features.memoryEcho'),
t('about.features.notebookOrganization'),
t('about.features.dragDrop'),
t('about.features.labelSystem'),
t('about.features.multipleProviders'),
].map((feature) => (
<li key={feature} className="flex items-center gap-2 text-sm text-foreground">
<span className="text-primary font-bold"></span>
{feature}
</li>
))}
</ul>
</div>
{/* Support — full width */}
<div className="md:col-span-2 bg-card rounded-lg border border-border p-6 shadow-sm">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
<MessageCircle className="h-5 w-5" />
</div>
<div>
<p className="font-medium mb-2">{t('about.support.feedback')}</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
We value your feedback! Share your thoughts and suggestions.
</p>
<h3 className="font-semibold text-foreground">{t('about.support.title')}</h3>
<p className="text-sm text-muted-foreground">{t('about.support.description')}</p>
</div>
</CardContent>
</Card>
</SettingsSection>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-4 border-t border-border">
{[
{ title: t('about.support.documentation'), desc: 'Guides détaillés et tutoriels.' },
{ title: t('about.support.reportIssues'), desc: 'Signalez un bug dans le gestionnaire.' },
{ title: t('about.support.feedback'), desc: 'Vos retours nous aident à améliorer l\'app.' },
].map((item) => (
<div key={item.title} className="p-3 rounded-md bg-muted">
<p className="font-medium text-sm text-foreground">{item.title}</p>
<p className="text-xs text-muted-foreground mt-1">{item.desc}</p>
</div>
))}
</div>
</div>
</div>
</div>
)
}

View File

@@ -6,11 +6,9 @@ export function AISettingsHeader() {
const { t } = useLanguage()
return (
<div className="mb-6">
<h1 className="text-3xl font-bold">{t('aiSettings.title')}</h1>
<p className="text-gray-600 dark:text-gray-400">
{t('aiSettings.description')}
</p>
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">{t('aiSettings.title')}</h1>
<p className="text-muted-foreground mt-1">{t('aiSettings.description')}</p>
</div>
)
}

View File

@@ -1,11 +1,11 @@
'use client'
import { useState } from 'react'
import { SettingsSection, SettingSelect } from '@/components/settings'
import { updateAISettings } from '@/app/actions/ai-settings'
import { updateUserSettings } from '@/app/actions/user-settings'
import { useLanguage } from '@/lib/i18n'
import { toast } from 'sonner'
import { Palette, Type, LayoutGrid, Maximize2 } from 'lucide-react'
interface AppearanceSettingsClientProps {
initialFontSize: string
@@ -15,7 +15,13 @@ interface AppearanceSettingsClientProps {
initialFontFamily?: string
}
export function AppearanceSettingsClient({ initialFontSize, initialTheme, initialNotesViewMode, initialCardSizeMode = 'variable', initialFontFamily = 'inter' }: AppearanceSettingsClientProps) {
export function AppearanceSettingsClient({
initialFontSize,
initialTheme,
initialNotesViewMode,
initialCardSizeMode = 'variable',
initialFontFamily = 'inter',
}: AppearanceSettingsClientProps) {
const { t } = useLanguage()
const [theme, setTheme] = useState(initialTheme || 'light')
const [fontSize, setFontSize] = useState(initialFontSize || 'medium')
@@ -26,11 +32,9 @@ export function AppearanceSettingsClient({ initialFontSize, initialTheme, initia
const handleThemeChange = async (value: string) => {
setTheme(value)
localStorage.setItem('theme-preference', value)
const root = document.documentElement
root.removeAttribute('data-theme')
root.classList.remove('dark')
if (value === 'auto') {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) root.classList.add('dark')
} else if (value === 'dark') {
@@ -39,19 +43,14 @@ export function AppearanceSettingsClient({ initialFontSize, initialTheme, initia
root.setAttribute('data-theme', value)
if (['midnight'].includes(value)) root.classList.add('dark')
}
await updateUserSettings({ theme: value as 'light' | 'dark' | 'auto' })
await updateUserSettings({ theme: value as any })
toast.success(t('settings.settingsSaved') || 'Saved')
}
const handleFontSizeChange = async (value: string) => {
setFontSize(value)
const fontSizeMap: Record<string, string> = {
'small': '14px', 'medium': '16px', 'large': '18px', 'extra-large': '20px'
}
document.documentElement.style.setProperty('--user-font-size', fontSizeMap[value] || '16px')
const map: Record<string, string> = { small: '14px', medium: '16px', large: '18px', 'extra-large': '20px' }
document.documentElement.style.setProperty('--user-font-size', map[value] || '16px')
await updateAISettings({ fontSize: value as any })
toast.success(t('settings.settingsSaved') || 'Saved')
}
@@ -76,31 +75,64 @@ export function AppearanceSettingsClient({ initialFontSize, initialTheme, initia
setFontFamily(font)
localStorage.setItem('font-family', font)
const root = document.documentElement
if (font === 'system') {
root.classList.add('font-system')
} else {
root.classList.remove('font-system')
}
font === 'system' ? root.classList.add('font-system') : root.classList.remove('font-system')
await updateAISettings({ fontFamily: font })
toast.success(t('settings.settingsSaved') || 'Saved')
}
const SelectCard = ({
icon: Icon,
title,
description,
value,
options,
onChange,
}: {
icon: React.ElementType
title: string
description: string
value: string
options: { value: string; label: string }[]
onChange: (v: string) => void
}) => (
<div className="bg-card rounded-lg border border-border p-6 shadow-sm flex flex-col gap-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
<Icon className="h-5 w-5" />
</div>
<div>
<h3 className="font-semibold text-foreground">{title}</h3>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
</div>
<div className="relative mt-2">
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full h-11 px-4 bg-muted border border-border rounded-lg text-foreground text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none appearance-none cursor-pointer transition-colors"
>
{options.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<div className="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
</div>
</div>
</div>
)
return (
<div className="space-y-6">
<div className="space-y-8">
<div>
<h1 className="text-3xl font-bold mb-2">{t('appearance.title')}</h1>
<p className="text-gray-600 dark:text-gray-400">
{t('appearance.description')}
</p>
<h1 className="text-2xl font-bold tracking-tight text-foreground">{t('appearance.title')}</h1>
<p className="text-muted-foreground mt-1">{t('appearance.description')}</p>
</div>
<SettingsSection
title={t('settings.theme')}
icon={<span className="text-2xl">🎨</span>}
description={t('settings.themeLight') + ' / ' + t('settings.themeDark')}
>
<SettingSelect
label={t('settings.theme')}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<SelectCard
icon={Palette}
title={t('settings.theme')}
description={t('appearance.selectTheme')}
value={theme}
options={[
@@ -118,50 +150,36 @@ export function AppearanceSettingsClient({ initialFontSize, initialTheme, initia
]}
onChange={handleThemeChange}
/>
</SettingsSection>
<SettingsSection
title={t('profile.fontSize')}
icon={<span className="text-2xl">📝</span>}
description={t('profile.fontSizeDescription')}
>
<SettingSelect
label={t('profile.fontSize')}
description={t('profile.selectFontSize')}
<SelectCard
icon={Type}
title={t('profile.fontSize')}
description={t('profile.fontSizeDescription')}
value={fontSize}
options={[
{ value: 'small', label: t('profile.fontSizeSmall') },
{ value: 'medium', label: t('profile.fontSizeMedium') },
{ value: 'large', label: t('profile.fontSizeLarge') },
{ value: 'extra-large', label: t('profile.fontSizeExtraLarge') },
]}
onChange={handleFontSizeChange}
/>
</SettingsSection>
<SettingsSection
title={t('appearance.fontFamilyLabel') || 'Font Family'}
icon={<span className="text-2xl">🔤</span>}
description={t('appearance.fontFamilyDescription') || 'Choose the font used throughout the app'}
>
<SettingSelect
label={t('appearance.fontFamilyLabel') || 'Font Family'}
description={t('appearance.selectFontFamily') || 'Inter is optimized for readability, System uses your OS native font'}
<SelectCard
icon={Type}
title={t('appearance.fontFamilyLabel') || 'Police'}
description={t('appearance.fontFamilyDescription') || 'Choisissez la police de l\'application'}
value={fontFamily}
options={[
{ value: 'inter', label: 'Inter' },
{ value: 'system', label: t('appearance.fontSystem') || 'System Default' },
{ value: 'system', label: t('appearance.fontSystem') || 'Système' },
]}
onChange={handleFontFamilyChange}
/>
</SettingsSection>
<SettingsSection
title={t('appearance.notesViewLabel')}
icon={<span className="text-2xl">📋</span>}
description={t('appearance.notesViewDescription')}
>
<SettingSelect
label={t('appearance.notesViewLabel')}
<SelectCard
icon={LayoutGrid}
title={t('appearance.notesViewLabel')}
description={t('appearance.notesViewDescription')}
value={notesViewMode}
options={[
@@ -170,16 +188,11 @@ export function AppearanceSettingsClient({ initialFontSize, initialTheme, initia
]}
onChange={handleNotesViewChange}
/>
</SettingsSection>
<SettingsSection
title={t('settings.cardSizeMode')}
icon={<span className="text-2xl">📐</span>}
description={t('settings.cardSizeModeDescription')}
>
<SettingSelect
label={t('settings.cardSizeMode')}
description={t('settings.selectCardSizeMode')}
<SelectCard
icon={Maximize2}
title={t('settings.cardSizeMode')}
description={t('settings.cardSizeModeDescription')}
value={cardSizeMode}
options={[
{ value: 'variable', label: t('settings.cardSizeVariable') },
@@ -187,7 +200,7 @@ export function AppearanceSettingsClient({ initialFontSize, initialTheme, initia
]}
onChange={handleCardSizeModeChange}
/>
</SettingsSection>
</div>
</div>
)
}

View File

@@ -1,9 +1,8 @@
'use client'
import { useState } from 'react'
import { SettingsSection } from '@/components/settings'
import { Button } from '@/components/ui/button'
import { Download, Upload, Trash2, Loader2 } from 'lucide-react'
import { Download, Upload, Trash2, Loader2, RefreshCw, Sparkles, Database } from 'lucide-react'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
import { useRouter } from 'next/navigation'
@@ -14,6 +13,8 @@ export default function DataSettingsPage() {
const [isExporting, setIsExporting] = useState(false)
const [isImporting, setIsImporting] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [isReindexing, setIsReindexing] = useState(false)
const [isCleaningUp, setIsCleaningUp] = useState(false)
const handleExport = async () => {
setIsExporting(true)
@@ -24,15 +25,16 @@ export default function DataSettingsPage() {
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `memento-note-export-${new Date().toISOString().split('T')[0]}.json`
a.download = `memento-export-${new Date().toISOString().split('T')[0]}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
toast.success(t('dataManagement.export.success'))
} else {
throw new Error()
}
} catch (error) {
console.error('Export error:', error)
} catch {
toast.error(t('dataManagement.export.failed'))
} finally {
setIsExporting(false)
@@ -42,47 +44,73 @@ export default function DataSettingsPage() {
const handleImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
setIsImporting(true)
try {
const formData = new FormData()
formData.append('file', file)
const response = await fetch('/api/notes/import', {
method: 'POST',
body: formData
})
const response = await fetch('/api/notes/import', { method: 'POST', body: formData })
if (response.ok) {
const result = await response.json()
toast.success(t('dataManagement.import.success', { count: result.count }))
router.refresh()
} else {
throw new Error('Import failed')
const error = await response.json()
throw new Error(error.error || 'Import failed')
}
} catch (error) {
console.error('Import error:', error)
toast.error(t('dataManagement.import.failed'))
} catch (err: any) {
toast.error(err.message || t('dataManagement.import.failed'))
} finally {
setIsImporting(false)
event.target.value = ''
}
}
const handleDeleteAll = async () => {
if (!confirm(t('dataManagement.delete.confirm'))) {
return
const handleReindex = async () => {
setIsReindexing(true)
try {
const response = await fetch('/api/notes/reindex', { method: 'POST' })
if (response.ok) {
const result = await response.json()
toast.success(t('dataManagement.indexing.success', { count: result.count }))
} else {
throw new Error()
}
} catch {
toast.error(t('dataManagement.indexing.failed'))
} finally {
setIsReindexing(false)
}
}
const handleCleanup = async () => {
setIsCleaningUp(true)
try {
const response = await fetch('/api/notes/cleanup', { method: 'POST' })
if (response.ok) {
const result = await response.json()
toast.success(t('dataManagement.cleanup.success', { count: result.deletedLabels }))
} else {
throw new Error()
}
} catch {
toast.error(t('dataManagement.cleanup.failed'))
} finally {
setIsCleaningUp(false)
}
}
const handleDeleteAll = async () => {
if (!confirm(t('dataManagement.delete.confirm'))) return
setIsDeleting(true)
try {
const response = await fetch('/api/notes/delete-all', { method: 'POST' })
if (response.ok) {
toast.success(t('dataManagement.delete.success'))
router.refresh()
} else {
throw new Error()
}
} catch (error) {
console.error('Delete error:', error)
} catch {
toast.error(t('dataManagement.delete.failed'))
} finally {
setIsDeleting(false)
@@ -90,102 +118,114 @@ export default function DataSettingsPage() {
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">{t('dataManagement.title')}</h1>
<p className="text-gray-600 dark:text-gray-400">
{t('dataManagement.toolsDescription')}
</p>
<div className="max-w-4xl mx-auto space-y-8 p-6">
<div className="space-y-1">
<h1 className="text-3xl font-bold tracking-tight text-foreground">{t('dataManagement.title')}</h1>
<p className="text-muted-foreground">{t('dataManagement.toolsDescription')}</p>
</div>
<SettingsSection
title={`💾 ${t('dataManagement.export.title')}`}
icon={<span className="text-2xl">💾</span>}
description={t('dataManagement.export.description')}
>
<div className="flex items-center justify-between py-4">
<div>
<p className="font-medium">{t('dataManagement.export.title')}</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
{t('dataManagement.export.description')}
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Export card */}
<div className="bg-card rounded-xl border border-border p-6 shadow-sm flex flex-col justify-between transition-all hover:shadow-md">
<div className="space-y-4">
<div className="w-12 h-12 rounded-full bg-blue-500/10 flex items-center justify-center text-blue-600 shrink-0">
<Download className="h-6 w-6" />
</div>
<div>
<h3 className="text-lg font-semibold text-foreground">{t('dataManagement.export.title')}</h3>
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
{t('dataManagement.export.description')}
</p>
</div>
</div>
<Button
onClick={handleExport}
disabled={isExporting}
>
{isExporting ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<Download className="h-4 w-4 mr-2" />
)}
<Button onClick={handleExport} disabled={isExporting} className="mt-6 w-full">
{isExporting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Download className="h-4 w-4 mr-2" />}
{isExporting ? t('dataManagement.exporting') : t('dataManagement.export.button')}
</Button>
</div>
</SettingsSection>
<SettingsSection
title={`📥 ${t('dataManagement.import.title')}`}
icon={<span className="text-2xl">📥</span>}
description={t('dataManagement.import.description')}
>
<div className="flex items-center justify-between py-4">
<div>
<p className="font-medium">{t('dataManagement.import.title')}</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
{t('dataManagement.import.description')}
</p>
{/* Import card */}
<div className="bg-card rounded-xl border border-border p-6 shadow-sm flex flex-col justify-between transition-all hover:shadow-md">
<div className="space-y-4">
<div className="w-12 h-12 rounded-full bg-emerald-500/10 flex items-center justify-center text-emerald-600 shrink-0">
<Upload className="h-6 w-6" />
</div>
<div>
<h3 className="text-lg font-semibold text-foreground">{t('dataManagement.import.title')}</h3>
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
{t('dataManagement.import.description')}
</p>
</div>
</div>
<input type="file" accept=".json" onChange={handleImport} disabled={isImporting} className="hidden" id="import-file" />
<Button onClick={() => document.getElementById('import-file')?.click()} disabled={isImporting} variant="outline" className="mt-6 w-full">
{isImporting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Upload className="h-4 w-4 mr-2" />}
{isImporting ? t('dataManagement.importing') : t('dataManagement.import.button')}
</Button>
</div>
{/* Reindex card */}
<div className="bg-card rounded-xl border border-border p-6 shadow-sm flex flex-col justify-between transition-all hover:shadow-md">
<div className="space-y-4">
<div className="w-12 h-12 rounded-full bg-amber-500/10 flex items-center justify-center text-amber-600 shrink-0">
<Sparkles className="h-6 w-6" />
</div>
<div>
<h3 className="text-lg font-semibold text-foreground">{t('dataManagement.indexing.title')}</h3>
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
{t('dataManagement.indexing.description')}
</p>
</div>
</div>
<Button onClick={handleReindex} disabled={isReindexing} variant="secondary" className="mt-6 w-full">
{isReindexing ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <RefreshCw className="h-4 w-4 mr-2" />}
{isReindexing ? t('dataManagement.exporting') : t('dataManagement.indexing.button')}
</Button>
</div>
{/* Cleanup card */}
<div className="bg-card rounded-xl border border-border p-6 shadow-sm flex flex-col justify-between transition-all hover:shadow-md">
<div className="space-y-4">
<div className="w-12 h-12 rounded-full bg-purple-500/10 flex items-center justify-center text-purple-600 shrink-0">
<Database className="h-6 w-6" />
</div>
<div>
<h3 className="text-lg font-semibold text-foreground">{t('dataManagement.cleanup.title')}</h3>
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
{t('dataManagement.cleanup.description')}
</p>
</div>
</div>
<Button onClick={handleCleanup} disabled={isCleaningUp} variant="secondary" className="mt-6 w-full">
{isCleaningUp ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Database className="h-4 w-4 mr-2" />}
{isCleaningUp ? t('dataManagement.exporting') : t('dataManagement.cleanup.button')}
</Button>
</div>
</div>
{/* Danger zone */}
<div className="bg-destructive/5 rounded-xl border border-destructive/20 p-6 shadow-sm mt-12">
<div className="flex items-center gap-4 mb-6">
<div className="w-12 h-12 rounded-full bg-destructive/10 flex items-center justify-center text-destructive shrink-0">
<Trash2 className="h-6 w-6" />
</div>
<div>
<input
type="file"
accept=".json"
onChange={handleImport}
disabled={isImporting}
className="hidden"
id="import-file"
/>
<Button
onClick={() => document.getElementById('import-file')?.click()}
disabled={isImporting}
>
{isImporting ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<Upload className="h-4 w-4 mr-2" />
)}
{isImporting ? t('dataManagement.importing') : t('dataManagement.import.button')}
</Button>
<h3 className="text-xl font-bold text-destructive">{t('dataManagement.dangerZone')}</h3>
<p className="text-sm text-muted-foreground">{t('dataManagement.dangerZoneDescription')}</p>
</div>
</div>
</SettingsSection>
<SettingsSection
title={`⚠️ ${t('dataManagement.dangerZone')}`}
icon={<span className="text-2xl"></span>}
description={t('dataManagement.dangerZoneDescription')}
>
<div className="flex items-center justify-between py-4 border-t border-red-200 dark:border-red-900">
<div>
<p className="font-medium text-red-600 dark:text-red-400">{t('dataManagement.delete.title')}</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
{t('dataManagement.delete.description')}
</p>
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between p-4 bg-background/50 rounded-lg border border-destructive/10 gap-4">
<div className="space-y-1">
<p className="font-semibold text-destructive">{t('dataManagement.delete.title')}</p>
<p className="text-sm text-muted-foreground">{t('dataManagement.delete.description')}</p>
</div>
<Button
variant="destructive"
onClick={handleDeleteAll}
disabled={isDeleting}
>
{isDeleting ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<Trash2 className="h-4 w-4 mr-2" />
)}
<Button variant="destructive" onClick={handleDeleteAll} disabled={isDeleting} className="shrink-0 w-full sm:w-auto">
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Trash2 className="h-4 w-4 mr-2" />}
{isDeleting ? t('dataManagement.deleting') : t('dataManagement.delete.button')}
</Button>
</div>
</SettingsSection>
</div>
</div>
)
}

View File

@@ -1,11 +1,11 @@
'use client'
import { useState } from 'react'
import { SettingsSection, SettingToggle, SettingSelect } from '@/components/settings'
import { useLanguage } from '@/lib/i18n'
import { updateAISettings } from '@/app/actions/ai-settings'
import { toast } from 'sonner'
import { useRouter } from 'next/navigation'
import { Globe, Bell } from 'lucide-react'
interface GeneralSettingsClientProps {
initialSettings: {
@@ -25,7 +25,6 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
const handleLanguageChange = async (value: string) => {
setLanguage(value)
await updateAISettings({ preferredLanguage: value as any })
if (value === 'auto') {
localStorage.removeItem('user-language')
toast.success(t('settings.languageAuto') || 'Language set to Auto')
@@ -34,7 +33,6 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
setContextLanguage(value as any)
toast.success(t('profile.languageUpdateSuccess') || 'Language updated')
}
setTimeout(() => router.refresh(), 300)
}
@@ -51,63 +49,102 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
}
return (
<div className="space-y-6">
<div className="space-y-8">
{/* Page title */}
<div>
<h1 className="text-3xl font-bold mb-2">{t('generalSettings.title')}</h1>
<p className="text-gray-600 dark:text-gray-400">
{t('generalSettings.description')}
</p>
<h1 className="text-2xl font-bold tracking-tight text-foreground">{t('generalSettings.title')}</h1>
<p className="text-muted-foreground mt-1">{t('generalSettings.description')}</p>
</div>
<SettingsSection
title={t('settings.language')}
icon={<span className="text-2xl">🌍</span>}
description={t('profile.languagePreferencesDescription')}
>
<SettingSelect
label={t('settings.language')}
description={t('settings.selectLanguage')}
value={language}
options={[
{ value: 'auto', label: t('profile.autoDetect') },
{ value: 'en', label: 'English' },
{ value: 'fr', label: 'Français' },
{ value: 'es', label: 'Español' },
{ value: 'de', label: 'Deutsch' },
{ value: 'fa', label: 'فارسی' },
{ value: 'it', label: 'Italiano' },
{ value: 'pt', label: 'Português' },
{ value: 'ru', label: 'Русский' },
{ value: 'zh', label: '中文' },
{ value: 'ja', label: '日本語' },
{ value: 'ko', label: '한국어' },
{ value: 'ar', label: 'العربية' },
{ value: 'hi', label: 'हिन्दी' },
{ value: 'nl', label: 'Nederlands' },
{ value: 'pl', label: 'Polski' },
]}
onChange={handleLanguageChange}
/>
</SettingsSection>
{/* 2-column card grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Language card */}
<div className="bg-card rounded-lg border border-border p-6 shadow-sm flex flex-col gap-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
<Globe className="h-5 w-5" />
</div>
<div>
<h3 className="font-semibold text-foreground">{t('settings.language')}</h3>
<p className="text-sm text-muted-foreground">{t('settings.selectLanguage')}</p>
</div>
</div>
<div className="relative mt-2">
<select
value={language}
onChange={(e) => handleLanguageChange(e.target.value)}
className="w-full h-11 px-4 bg-muted border border-border rounded-lg text-foreground text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none appearance-none cursor-pointer transition-colors"
>
<option value="auto">{t('profile.autoDetect')}</option>
<option value="en">English</option>
<option value="fr">Français</option>
<option value="es">Español</option>
<option value="de">Deutsch</option>
<option value="fa">فارسی</option>
<option value="it">Italiano</option>
<option value="pt">Português</option>
<option value="ru">Русский</option>
<option value="zh"></option>
<option value="ja"></option>
<option value="ko"></option>
<option value="ar">العربية</option>
<option value="hi">ि</option>
<option value="nl">Nederlands</option>
<option value="pl">Polski</option>
</select>
<div className="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
</div>
</div>
</div>
<SettingsSection
title={t('settings.notifications')}
icon={<span className="text-2xl">🔔</span>}
description={t('settings.notificationsDesc')}
>
<SettingToggle
label={t('settings.emailNotifications')}
description={t('settings.emailNotificationsDesc')}
checked={emailNotifications}
onChange={handleEmailNotificationsChange}
/>
<SettingToggle
label={t('settings.desktopNotifications')}
description={t('settings.desktopNotificationsDesc')}
checked={desktopNotifications}
onChange={handleDesktopNotificationsChange}
/>
</SettingsSection>
{/* Notifications card */}
<div className="bg-card rounded-lg border border-border p-6 shadow-sm flex flex-col gap-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
<Bell className="h-5 w-5" />
</div>
<div>
<h3 className="font-semibold text-foreground">{t('settings.notifications')}</h3>
<p className="text-sm text-muted-foreground">{t('settings.notificationsDesc')}</p>
</div>
</div>
<div className="mt-2 space-y-5">
{/* Email toggle */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-foreground">{t('settings.emailNotifications')}</p>
<p className="text-xs text-muted-foreground">{t('settings.emailNotificationsDesc')}</p>
</div>
<button
type="button"
role="switch"
aria-checked={emailNotifications}
onClick={() => handleEmailNotificationsChange(!emailNotifications)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary/30 ${emailNotifications ? 'bg-primary' : 'bg-muted-foreground/30'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${emailNotifications ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Desktop toggle */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-foreground">{t('settings.desktopNotifications')}</p>
<p className="text-xs text-muted-foreground">{t('settings.desktopNotificationsDesc')}</p>
</div>
<button
type="button"
role="switch"
aria-checked={desktopNotifications}
onClick={() => handleDesktopNotificationsChange(!desktopNotifications)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary/30 ${desktopNotifications ? 'bg-primary' : 'bg-muted-foreground/30'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${desktopNotifications ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -8,17 +8,17 @@ export default function SettingsLayout({
children: React.ReactNode
}) {
return (
<div className="container mx-auto py-10 px-4 max-w-6xl">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Sidebar Navigation */}
<aside className="lg:col-span-1">
<SettingsNav />
</aside>
<div className="flex flex-col h-full">
{/* Horizontal Tab Navigation */}
<header className="flex items-center gap-1 px-8 bg-background border-b border-border shrink-0">
<SettingsNav />
</header>
{/* Main Content */}
<main className="lg:col-span-3 space-y-6">
{/* Page Content */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-5xl mx-auto px-8 py-8 space-y-8">
{children}
</main>
</div>
</div>
</div>
)

View File

@@ -5,10 +5,7 @@ import { listMcpKeys, getMcpServerStatus } from '@/app/actions/mcp-keys'
export default async function McpSettingsPage() {
const session = await auth()
if (!session?.user) {
redirect('/api/auth/signin')
}
if (!session?.user) redirect('/api/auth/signin')
const [keys, serverStatus] = await Promise.all([
listMcpKeys(),
@@ -16,7 +13,11 @@ export default async function McpSettingsPage() {
])
return (
<div className="space-y-6">
<div className="space-y-8">
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">Paramètres MCP</h1>
<p className="text-muted-foreground mt-1">Gérez vos clés API et serveurs MCP connectés.</p>
</div>
<McpSettingsPanel initialKeys={keys} serverStatus={serverStatus} />
</div>
)

View File

@@ -1,179 +1,92 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { updateProfile, changePassword, updateFontSize, updateShowRecentNotes } from '@/app/actions/profile'
import { updateProfile, changePassword } from '@/app/actions/profile'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
import { User, Lock } from 'lucide-react'
export function ProfileForm({ user, userAISettings }: { user: any; userAISettings?: any }) {
const router = useRouter()
const [fontSize, setFontSize] = useState(userAISettings?.fontSize || 'medium')
const [isUpdatingFontSize, setIsUpdatingFontSize] = useState(false)
const [showRecentNotes, setShowRecentNotes] = useState(userAISettings?.showRecentNotes ?? false)
const [isUpdatingRecentNotes, setIsUpdatingRecentNotes] = useState(false)
const { t } = useLanguage()
const FONT_SIZES = [
{ value: 'small', label: t('profile.fontSizeSmall'), size: '14px' },
{ value: 'medium', label: t('profile.fontSizeMedium'), size: '16px' },
{ value: 'large', label: t('profile.fontSizeLarge'), size: '18px' },
{ value: 'extra-large', label: t('profile.fontSizeExtraLarge'), size: '20px' },
]
const handleFontSizeChange = async (size: string) => {
setIsUpdatingFontSize(true)
try {
const result = await updateFontSize(size)
if (result?.error) {
toast.error(t('profile.fontSizeUpdateFailed'))
} else {
setFontSize(size)
// Apply font size immediately
applyFontSize(size)
toast.success(t('profile.fontSizeUpdateSuccess'))
}
} catch (error) {
toast.error(t('profile.fontSizeUpdateFailed'))
} finally {
setIsUpdatingFontSize(false)
}
}
const applyFontSize = (size: string) => {
// Base font size in pixels (16px is standard)
const fontSizeMap = {
'small': '14px', // ~87% of 16px
'medium': '16px', // 100% (standard)
'large': '18px', // ~112% of 16px
'extra-large': '20px' // 125% of 16px
}
const fontSizeFactorMap = {
'small': 0.95,
'medium': 1.0,
'large': 1.1,
'extra-large': 1.25
}
const fontSizeValue = fontSizeMap[size as keyof typeof fontSizeMap] || '16px'
const fontSizeFactor = fontSizeFactorMap[size as keyof typeof fontSizeFactorMap] || 1.0
document.documentElement.style.setProperty('--user-font-size', fontSizeValue)
document.documentElement.style.setProperty('--user-font-size-factor', fontSizeFactor.toString())
localStorage.setItem('user-font-size', size)
}
// Apply saved font size on mount
useEffect(() => {
const savedFontSize = localStorage.getItem('user-font-size') || userAISettings?.fontSize || 'medium'
applyFontSize(savedFontSize as string)
}, [])
const handleShowRecentNotesChange = async (enabled: boolean) => {
setIsUpdatingRecentNotes(true)
const previousValue = showRecentNotes
try {
const result = await updateShowRecentNotes(enabled)
if (result?.error) {
toast.error(result.error)
} else {
setShowRecentNotes(enabled)
toast.success(t('profile.recentNotesUpdateSuccess') || 'Paramètre mis à jour')
// Force full page reload to ensure settings are reloaded
window.location.href = '/settings/profile'
}
} catch (error: any) {
setShowRecentNotes(previousValue)
toast.error(error?.message || 'Erreur')
} finally {
setIsUpdatingRecentNotes(false)
}
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>{t('profile.title')}</CardTitle>
<CardDescription>{t('profile.description')}</CardDescription>
</CardHeader>
<form action={async (formData) => {
const result = await updateProfile({ name: formData.get('name') as string })
if (result?.error) {
toast.error(t('profile.updateFailed'))
} else {
toast.success(t('profile.updateSuccess'))
}
}}>
<CardContent className="space-y-4">
<div className="space-y-2">
<label htmlFor="name" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">{t('profile.displayName')}</label>
<Input id="name" name="name" defaultValue={user.name} />
</div>
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">{t('profile.email')}</label>
<Input id="email" value={user.email} disabled className="bg-muted" />
</div>
</CardContent>
<CardFooter>
<Button type="submit">{t('general.save')}</Button>
</CardFooter>
</form>
</Card>
<div className="space-y-8">
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">{t('profile.title')}</h1>
<p className="text-muted-foreground mt-1">{t('profile.description')}</p>
</div>
<Card>
<CardHeader>
<CardTitle>{t('profile.changePassword')}</CardTitle>
<CardDescription>{t('profile.changePasswordDescription')}</CardDescription>
</CardHeader>
<form action={async (formData) => {
const result = await changePassword(formData)
if (result?.error) {
const msg = '_form' in result.error
? result.error._form[0]
: result.error.currentPassword?.[0] || result.error.newPassword?.[0] || result.error.confirmPassword?.[0] || t('profile.passwordChangeFailed')
toast.error(msg)
} else {
toast.success(t('profile.passwordChangeSuccess'))
// Reset form manually or redirect
const form = document.querySelector('form#password-form') as HTMLFormElement
form?.reset()
}
}} id="password-form">
<CardContent className="space-y-4">
<div className="space-y-2">
<label htmlFor="currentPassword" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">{t('profile.currentPassword')}</label>
<Input id="currentPassword" name="currentPassword" type="password" required />
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Profile info card */}
<div className="bg-card rounded-lg border border-border p-6 shadow-sm flex flex-col gap-4">
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
<User className="h-5 w-5" />
</div>
<div className="space-y-2">
<label htmlFor="newPassword" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">{t('profile.newPassword')}</label>
<Input id="newPassword" name="newPassword" type="password" required minLength={6} />
<div>
<h3 className="font-semibold text-foreground">{t('profile.title')}</h3>
<p className="text-sm text-muted-foreground">{t('profile.description')}</p>
</div>
<div className="space-y-2">
<label htmlFor="confirmPassword" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">{t('profile.confirmPassword')}</label>
<Input id="confirmPassword" name="confirmPassword" type="password" required minLength={6} />
</div>
<form action={async (formData) => {
const result = await updateProfile({ name: formData.get('name') as string })
if (result?.error) toast.error(t('profile.updateFailed'))
else toast.success(t('profile.updateSuccess'))
}} className="space-y-4">
<div className="space-y-1.5">
<label htmlFor="name" className="text-sm font-medium text-foreground">{t('profile.displayName')}</label>
<Input id="name" name="name" defaultValue={user.name} className="bg-muted border-border focus:border-primary" />
</div>
</CardContent>
<CardFooter>
<Button type="submit">{t('profile.updatePassword')}</Button>
</CardFooter>
</form>
</Card>
<div className="space-y-1.5">
<label htmlFor="email" className="text-sm font-medium text-foreground">{t('profile.email')}</label>
<Input id="email" value={user.email} disabled className="bg-muted border-border opacity-60" />
</div>
<Button type="submit" className="w-full mt-2">{t('general.save')}</Button>
</form>
</div>
{/* Password card */}
<div className="bg-card rounded-lg border border-border p-6 shadow-sm flex flex-col gap-4">
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
<Lock className="h-5 w-5" />
</div>
<div>
<h3 className="font-semibold text-foreground">{t('profile.changePassword')}</h3>
<p className="text-sm text-muted-foreground">{t('profile.changePasswordDescription')}</p>
</div>
</div>
<form action={async (formData) => {
const result = await changePassword(formData)
if (result?.error) {
const msg = '_form' in result.error
? result.error._form[0]
: result.error.currentPassword?.[0] || result.error.newPassword?.[0] || result.error.confirmPassword?.[0] || t('profile.passwordChangeFailed')
toast.error(msg)
} else {
toast.success(t('profile.passwordChangeSuccess'))
const form = document.querySelector('form#password-form') as HTMLFormElement
form?.reset()
}
}} id="password-form" className="space-y-4">
<div className="space-y-1.5">
<label htmlFor="currentPassword" className="text-sm font-medium text-foreground">{t('profile.currentPassword')}</label>
<Input id="currentPassword" name="currentPassword" type="password" required className="bg-muted border-border focus:border-primary" />
</div>
<div className="space-y-1.5">
<label htmlFor="newPassword" className="text-sm font-medium text-foreground">{t('profile.newPassword')}</label>
<Input id="newPassword" name="newPassword" type="password" required minLength={6} className="bg-muted border-border focus:border-primary" />
</div>
<div className="space-y-1.5">
<label htmlFor="confirmPassword" className="text-sm font-medium text-foreground">{t('profile.confirmPassword')}</label>
<Input id="confirmPassword" name="confirmPassword" type="password" required minLength={6} className="bg-muted border-border focus:border-primary" />
</div>
<Button type="submit" className="w-full mt-2">{t('profile.updatePassword')}</Button>
</form>
</div>
</div>
</div>
)
}

View File

@@ -19,6 +19,7 @@ export async function createAgent(data: {
role: string
sourceUrls?: string[]
sourceNotebookId?: string
sourceNoteIds?: string[]
targetNotebookId?: string
frequency?: string
tools?: string[]
@@ -28,6 +29,8 @@ export async function createAgent(data: {
scheduledTime?: string
scheduledDay?: number
timezone?: string
slideTheme?: string
slideStyle?: string
}) {
const session = await auth()
if (!session?.user?.id) {
@@ -43,6 +46,7 @@ export async function createAgent(data: {
role: data.role,
sourceUrls: data.sourceUrls ? JSON.stringify(data.sourceUrls) : null,
sourceNotebookId: data.sourceNotebookId || null,
sourceNoteIds: data.sourceNoteIds ? JSON.stringify(data.sourceNoteIds) : null,
targetNotebookId: data.targetNotebookId || null,
frequency: data.frequency || 'manual',
tools: data.tools ? JSON.stringify(data.tools) : '[]',
@@ -52,6 +56,8 @@ export async function createAgent(data: {
scheduledTime: data.scheduledTime || '08:00',
scheduledDay: data.scheduledDay ?? null,
timezone: data.timezone || null,
slideTheme: data.slideTheme || null,
slideStyle: data.slideStyle || null,
userId: session.user.id,
}
})
@@ -88,6 +94,7 @@ export async function updateAgent(id: string, data: {
role?: string
sourceUrls?: string[]
sourceNotebookId?: string | null
sourceNoteIds?: string[] | null
targetNotebookId?: string | null
frequency?: string
isEnabled?: boolean
@@ -98,6 +105,8 @@ export async function updateAgent(id: string, data: {
scheduledTime?: string
scheduledDay?: number | null
timezone?: string
slideTheme?: string | null
slideStyle?: string | null
}) {
const session = await auth()
if (!session?.user?.id) {
@@ -117,6 +126,7 @@ export async function updateAgent(id: string, data: {
if (data.role !== undefined) updateData.role = data.role
if (data.sourceUrls !== undefined) updateData.sourceUrls = JSON.stringify(data.sourceUrls)
if (data.sourceNotebookId !== undefined) updateData.sourceNotebookId = data.sourceNotebookId
if (data.sourceNoteIds !== undefined) updateData.sourceNoteIds = data.sourceNoteIds ? JSON.stringify(data.sourceNoteIds) : null
if (data.targetNotebookId !== undefined) updateData.targetNotebookId = data.targetNotebookId
if (data.frequency !== undefined) updateData.frequency = data.frequency
if (data.isEnabled !== undefined) updateData.isEnabled = data.isEnabled
@@ -127,6 +137,8 @@ export async function updateAgent(id: string, data: {
if (data.scheduledTime !== undefined) updateData.scheduledTime = data.scheduledTime
if (data.scheduledDay !== undefined) updateData.scheduledDay = data.scheduledDay
if (data.timezone !== undefined) updateData.timezone = data.timezone
if (data.slideTheme !== undefined) updateData.slideTheme = data.slideTheme
if (data.slideStyle !== undefined) updateData.slideStyle = data.slideStyle
// Recalculate nextRun when scheduling fields change
const shouldRecalcNextRun =
@@ -192,7 +204,7 @@ export async function getAgents() {
try {
const agents = await prisma.agent.findMany({
where: { userId: session.user.id },
where: { userId: session.user.id, NOT: { frequency: 'one-shot' } },
include: {
_count: { select: { actions: true } },
actions: {
@@ -220,21 +232,21 @@ export async function runAgent(id: string) {
if (!session?.user?.id) {
throw new Error('Non autorise')
}
const userId = session.user.id
try {
const { executeAgent } = await import('@/lib/ai/services/agent-executor.service')
const result = await executeAgent(id, session.user.id)
revalidatePath('/agents')
revalidatePath('/')
return result
} catch (error) {
console.error('Error running agent:', error)
return {
success: false,
actionId: '',
error: error instanceof Error ? error.message : 'Erreur inconnue'
}
// Verify ownership
const agent = await prisma.agent.findUnique({ where: { id }, select: { id: true, userId: true } })
if (!agent || agent.userId !== userId) {
return { success: false, agentId: id, error: 'Agent introuvable' }
}
// Fire and forget — return immediately so the UI doesn't block
import('@/lib/ai/services/agent-executor.service')
.then(({ executeAgent }) => executeAgent(id, userId))
.then(() => { /* revalidation is handled client-side via polling */ })
.catch(err => console.error('[runAgent] Background error:', err))
return { success: true, agentId: id, status: 'running' }
}
// --- History ---

View File

@@ -34,7 +34,8 @@ export async function getCanvases() {
return prisma.canvas.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'asc' }
/** Most recently updated first — default canvas matches latest work (agent output, edits) */
orderBy: { updatedAt: 'desc' },
})
}

View File

@@ -1375,28 +1375,35 @@ export async function syncAllEmbeddings() {
}
// Get all notes including those shared with the user
export async function getAllNotes(includeArchived = false) {
export async function getAllNotes(includeArchived = false, notebookId?: string) {
const session = await auth();
if (!session?.user?.id) return [];
const userId = session.user.id;
try {
// Fetch own notes + shared notes in parallel — no embedding to keep transfer fast
const [ownNotes, acceptedShares] = await Promise.all([
prisma.note.findMany({
where: {
userId,
trashedAt: null,
...(includeArchived ? {} : { isArchived: false }),
},
select: NOTE_LIST_SELECT,
orderBy: [
{ isPinned: 'desc' },
{ order: 'asc' },
{ updatedAt: 'desc' }
]
}),
const whereClause: any = {
userId,
trashedAt: null,
...(includeArchived ? {} : { isArchived: false }),
...(notebookId !== undefined ? { notebookId: notebookId || null } : {}),
}
const ownNotes = await prisma.note.findMany({
where: whereClause,
select: NOTE_LIST_SELECT,
orderBy: [
{ isPinned: 'desc' },
{ order: 'asc' },
{ updatedAt: 'desc' }
]
})
if (notebookId) {
return ownNotes.map(parseNote)
}
const [acceptedShares] = await Promise.all([
prisma.noteShare.findMany({
where: { userId, status: 'accepted' },
include: { note: { select: NOTE_LIST_SELECT } }

View File

@@ -5,7 +5,7 @@ import { auth } from '@/auth';
export const dynamic = 'force-dynamic';
export async function GET() {
export async function POST() {
try {
const session = await auth()
if (!session?.user?.id || (session.user as any).role !== 'ADMIN') {

View File

@@ -0,0 +1,140 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
type GenerateType = 'slide-generator' | 'excalidraw-generator'
const TYPE_DEFAULTS: Record<GenerateType, {
role: string
tools: string[]
maxSteps: number
}> = {
'slide-generator': {
role: 'Crée une présentation PowerPoint professionnelle et visuelle à partir du contenu de la note fournie.',
tools: ['note_search', 'note_read', 'generate_pptx'],
maxSteps: 8,
},
'excalidraw-generator': {
role: 'Génère un diagramme Excalidraw clair et professionnel à partir du contenu de la note fournie.',
tools: ['note_search', 'note_read', 'generate_excalidraw'],
maxSteps: 6,
},
}
// ─── POST : kick off generation (fire-and-forget) ──────────────────────────
export async function POST(req: NextRequest) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 })
}
const userId = session.user.id
const body = await req.json()
const { noteId, type, theme, style } = body as {
noteId: string
type: GenerateType
theme?: string
style?: string
}
if (!noteId || !type || !TYPE_DEFAULTS[type]) {
return NextResponse.json({ error: 'Paramètres invalides' }, { status: 400 })
}
const note = await prisma.note.findFirst({
where: { id: noteId, userId },
select: { id: true, title: true, notebookId: true },
})
if (!note) {
return NextResponse.json({ error: 'Note introuvable' }, { status: 404 })
}
const defaults = TYPE_DEFAULTS[type]
const agentName = type === 'slide-generator'
? `Slides — ${(note.title || 'Note').substring(0, 40)}`
: `Diagramme — ${(note.title || 'Note').substring(0, 40)}`
const agent = await prisma.agent.create({
data: {
name: agentName,
type,
role: defaults.role,
tools: JSON.stringify(defaults.tools),
maxSteps: defaults.maxSteps,
frequency: 'one-shot',
isEnabled: true,
sourceNoteIds: JSON.stringify([noteId]),
targetNotebookId: note.notebookId ?? undefined,
slideTheme: theme ?? 'vibrant_tech',
slideStyle: style ?? 'soft',
userId,
},
})
// ── Fire and forget — do NOT await so the HTTP response returns immediately ──
// In Node.js / Docker self-hosted, the process keeps running after response.
import('@/lib/ai/services/agent-executor.service')
.then(({ executeAgent }) => executeAgent(agent.id, userId))
.catch(err => console.error('[run-for-note] Background agent error:', err))
return NextResponse.json({ success: true, agentId: agent.id, status: 'running' })
}
// ─── GET : poll current agent status ──────────────────────────────────────
export async function GET(req: NextRequest) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 })
}
const userId = session.user.id
const agentId = req.nextUrl.searchParams.get('agentId')
if (!agentId) {
return NextResponse.json({ error: 'agentId manquant' }, { status: 400 })
}
// Verify ownership
const agent = await prisma.agent.findFirst({
where: { id: agentId, userId },
select: { id: true },
})
if (!agent) {
return NextResponse.json({ error: 'Agent introuvable' }, { status: 404 })
}
// Return latest action for this agent
const action = await prisma.agentAction.findFirst({
where: { agentId },
orderBy: { createdAt: 'desc' },
select: { id: true, status: true, result: true, log: true },
})
if (!action) {
// Action not created yet (race condition in first poll) — still running
return NextResponse.json({ status: 'running' })
}
// If success, find canvasId from the related canvas (result stores canvas id)
let canvasId: string | null = null
let noteId: string | null = null
if (action.status === 'success' && action.result) {
// result field: the executor stores canvas.id or note.id
const canvas = await prisma.canvas.findFirst({
where: { id: action.result },
select: { id: true },
})
if (canvas) {
canvasId = canvas.id
} else {
noteId = action.result
}
}
return NextResponse.json({
status: action.status, // 'running' | 'success' | 'failure'
actionId: action.id,
canvasId,
noteId,
error: action.status === 'failure' ? (action.log || 'Erreur inconnue') : undefined,
})
}

View File

@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { auth } from '@/auth'
export async function GET(req: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const canvasId = req.nextUrl.searchParams.get('id')
if (!canvasId) return NextResponse.json({ error: 'Missing id' }, { status: 400 })
const canvas = await prisma.canvas.findUnique({
where: { id: canvasId, userId: session.user.id },
})
if (!canvas) return NextResponse.json({ error: 'Not found' }, { status: 404 })
let parsed: any
try { parsed = JSON.parse(canvas.data) } catch {
return NextResponse.json({ error: 'Invalid data' }, { status: 500 })
}
if (parsed.type !== 'pptx' || !parsed.base64) {
return NextResponse.json({ error: 'Not a PPTX canvas' }, { status: 400 })
}
const byteChars = atob(parsed.base64)
const bytes = new Uint8Array(byteChars.length)
for (let i = 0; i < byteChars.length; i++) bytes[i] = byteChars.charCodeAt(i)
const filename = parsed.filename || `${canvas.name.replace(/[^a-zA-Z0-9]/g, '_')}.pptx`
// Auto-delete after serving
await prisma.canvas.delete({ where: { id: canvasId } })
return new NextResponse(bytes, {
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Length': String(bytes.length),
},
})
} catch (error) {
console.error('[Canvas Download]', error)
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,70 @@
import { NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
export async function GET(req: NextRequest) {
try {
const canvasId = req.nextUrl.searchParams.get('id')
console.log('[Slides API] Request for id:', canvasId)
if (!canvasId) {
console.log('[Slides API] ERROR: Missing id')
return new Response(JSON.stringify({ error: 'Missing id' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
})
}
const canvas = await prisma.canvas.findUnique({
where: { id: canvasId },
})
if (!canvas) {
console.log('[Slides API] ERROR: Canvas not found for id:', canvasId)
return new Response(JSON.stringify({ error: 'Not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
})
}
console.log('[Slides API] Canvas found:', canvas.name, '| data length:', canvas.data?.length)
console.log('[Slides API] Raw data start:', canvas.data?.substring(0, 200))
let parsed: any
try {
parsed = JSON.parse(canvas.data)
} catch (parseErr) {
console.log('[Slides API] ERROR: JSON parse failed:', parseErr)
return new Response(JSON.stringify({ error: 'Invalid data' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
})
}
console.log('[Slides API] Parsed type:', parsed.type, '| has html:', !!parsed.html, '| html length:', parsed.html?.length)
console.log('[Slides API] HTML start:', parsed.html?.substring(0, 150))
if (parsed.type !== 'slides' || !parsed.html) {
console.log('[Slides API] ERROR: Not a slides canvas. type:', parsed.type, 'html exists:', !!parsed.html)
return new Response(JSON.stringify({ error: 'Not a slides canvas' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
})
}
console.log('[Slides API] SUCCESS: Returning HTML, length:', parsed.html.length)
return new Response(parsed.html, {
status: 200,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-cache',
},
})
} catch (error) {
console.error('[Slides API] FATAL:', error)
return new Response(JSON.stringify({ error: 'Internal Server Error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
})
}
}

View File

@@ -14,13 +14,14 @@ export const dynamic = 'force-dynamic'
* Authorization: Bearer <CRON_SECRET>
*/
export async function POST(request: NextRequest) {
// Optional auth
const cronSecret = process.env.CRON_SECRET
if (cronSecret) {
const authHeader = request.headers.get('authorization')
if (authHeader !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!cronSecret) {
console.error('[CronAgents] CRON_SECRET env var is required but not set')
return NextResponse.json({ error: 'Server misconfiguration' }, { status: 500 })
}
const authHeader = request.headers.get('authorization')
if (authHeader !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {

View File

@@ -4,14 +4,14 @@ import prisma from '@/lib/prisma';
export const dynamic = 'force-dynamic'; // No caching
export async function POST(request: NextRequest) {
// Optional auth: set CRON_SECRET env var, callers must pass
// Authorization: Bearer <CRON_SECRET>
const cronSecret = process.env.CRON_SECRET
if (cronSecret) {
const authHeader = request.headers.get('authorization')
if (authHeader !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!cronSecret) {
console.error('[CronReminders] CRON_SECRET env var is required but not set')
return NextResponse.json({ error: 'Server misconfiguration' }, { status: 500 })
}
const authHeader = request.headers.get('authorization')
if (authHeader !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {

View File

@@ -1,12 +1,17 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
export async function POST(request: NextRequest) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
// Visible directement dans `docker logs memento-web`
console.error('[CLIENT-ERROR-REPORT]', JSON.stringify(body, null, 2))
} catch {
// ignore
// ignore malformed payload
}
return NextResponse.json({ ok: true })
}

View File

@@ -1,41 +0,0 @@
import { NextResponse } from 'next/server';
import { getSystemConfig } from '@/lib/config';
import { auth } from '@/auth';
/**
* Debug endpoint to check AI configuration
* This helps verify that OpenAI is properly configured
*/
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if ((session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
try {
const config = await getSystemConfig();
// Return only AI-related config for debugging
const aiConfig = {
AI_PROVIDER_TAGS: config.AI_PROVIDER_TAGS || 'not set',
AI_PROVIDER_EMBEDDING: config.AI_PROVIDER_EMBEDDING || 'not set',
AI_MODEL_TAGS: config.AI_MODEL_TAGS || 'not set',
AI_MODEL_EMBEDDING: config.AI_MODEL_EMBEDDING || 'not set',
OPENAI_API_KEY: config.OPENAI_API_KEY ? 'set (hidden)' : 'not set',
OLLAMA_BASE_URL: config.OLLAMA_BASE_URL || 'not set',
OLLAMA_MODEL: config.OLLAMA_MODEL || 'not set',
CUSTOM_OPENAI_BASE_URL: config.CUSTOM_OPENAI_BASE_URL || 'not set',
CUSTOM_OPENAI_API_KEY: config.CUSTOM_OPENAI_API_KEY ? 'set (hidden)' : 'not set',
};
return NextResponse.json(aiConfig);
} catch (error) {
return NextResponse.json(
{ error: 'Failed to get config', details: error },
{ status: 500 }
);
}
}

View File

@@ -1,38 +0,0 @@
import { NextResponse } from 'next/server';
import { chatService } from '@/lib/ai/services/chat.service';
import { auth } from '@/auth';
export async function POST(req: Request) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if ((session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
try {
const body = await req.json();
console.log("TEST ROUTE INCOMING BODY:", body);
// Simulate what the server action does
const result = await chatService.chat(body.message, body.conversationId, body.notebookId);
return NextResponse.json({ success: true, result });
} catch (err: any) {
console.error("====== TEST ROUTE CHAT ERROR ======");
console.error("NAME:", err.name);
console.error("MSG:", err.message);
if (err.cause) console.error("CAUSE:", JSON.stringify(err.cause, null, 2));
if (err.data) console.error("DATA:", JSON.stringify(err.data, null, 2));
if (err.stack) console.error("STACK:", err.stack);
console.error("===================================");
return NextResponse.json({
success: false,
error: err.message,
name: err.name,
cause: err.cause,
data: err.data
}, { status: 500 });
}
}

View File

@@ -1,127 +0,0 @@
import { NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
import { auth } from '@/auth'
function getHashColor(name: string): string {
const colors = ['red', 'blue', 'green', 'yellow', 'purple', 'pink', 'orange', 'gray']
let hash = 0
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash)
}
return colors[Math.abs(hash) % colors.length]
}
export async function POST() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if ((session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
try {
const result = { created: 0, deleted: 0, missing: [] as string[] }
// Get ALL users
const users = await prisma.user.findMany({
select: { id: true, email: true }
})
for (const user of users) {
const userId = user.id
// 1. Get all labels from notes
const allNotes = await prisma.note.findMany({
where: { userId },
select: { labels: true }
})
const labelsInNotes = new Set<string>()
allNotes.forEach(note => {
if (note.labels) {
try {
const parsed: string[] = Array.isArray(note.labels) ? (note.labels as string[]) : []
if (Array.isArray(parsed)) {
parsed.forEach(l => {
if (l && l.trim()) labelsInNotes.add(l.trim())
})
}
} catch (e) {}
}
})
// 2. Get existing Label records
const existingLabels = await prisma.label.findMany({
where: { userId },
select: { id: true, name: true }
})
const existingLabelMap = new Map<string, any>()
existingLabels.forEach(label => {
existingLabelMap.set(label.name.toLowerCase(), label)
})
// 3. Create missing Label records
for (const labelName of labelsInNotes) {
if (!existingLabelMap.has(labelName.toLowerCase())) {
try {
await prisma.label.create({
data: {
userId,
name: labelName,
color: getHashColor(labelName)
}
})
result.created++
} catch (e: any) {
console.error(`[FIX] ✗ Failed to create "${labelName}":`, e.message, e.code)
result.missing.push(labelName)
}
}
}
// 4. Delete orphan Label records
const usedLabelsSet = new Set<string>()
allNotes.forEach(note => {
if (note.labels) {
try {
const parsed: string[] = Array.isArray(note.labels) ? (note.labels as string[]) : []
if (Array.isArray(parsed)) {
parsed.forEach(l => usedLabelsSet.add(l.toLowerCase()))
}
} catch (e) {}
}
})
for (const label of existingLabels) {
if (!usedLabelsSet.has(label.name.toLowerCase())) {
try {
await prisma.label.delete({
where: { id: label.id }
})
result.deleted++
} catch (e) {}
}
}
}
revalidatePath('/')
return NextResponse.json({
success: true,
...result,
message: `Created ${result.created} labels, deleted ${result.deleted} orphans`
})
} catch (error) {
console.error('[FIX] Error:', error)
return NextResponse.json(
{ success: false, error: String(error) },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
export async function POST(req: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
// 1. Find and delete labels that have no notes and belong to this user
// We only delete labels that are not part of a notebook (global labels)
const orphanedLabels = await prisma.label.findMany({
where: {
userId,
notebookId: null,
notes: { none: {} }
}
})
await prisma.label.deleteMany({
where: {
id: { in: orphanedLabels.map(l => l.id) }
}
})
// 2. Clean up NoteEmbeddings that don't have a corresponding Note (shouldn't happen with Cascade, but good for cleanup)
const orphanedEmbeddings = await prisma.noteEmbedding.findMany({
where: {
note: { userId: { not: userId } } // Or just those where note is null if not using cascade
}
})
// Actually, let's just focus on user-specific cleanup
// 3. Remove note history entries for notes that were deleted (cascade should handle this, but let's be safe)
return NextResponse.json({
success: true,
deletedLabels: orphanedLabels.length
})
} catch (error) {
console.error('Cleanup error:', error)
return NextResponse.json(
{ success: false, error: 'Failed to cleanup data' },
{ status: 500 }
)
}
}

View File

@@ -91,9 +91,15 @@ export async function GET(req: NextRequest) {
id: note.id,
title: note.title,
content: note.content,
color: note.color,
isPinned: note.isPinned,
isArchived: note.isArchived,
type: note.type,
checkItems: note.checkItems,
images: note.images,
links: note.links,
createdAt: note.createdAt,
updatedAt: note.updatedAt,
isPinned: note.isPinned,
notebookId: note.notebookId,
labelRelations: note.labelRelations.map(label => ({
id: label.id,

View File

@@ -111,11 +111,12 @@ export async function POST(req: NextRequest) {
const mappedNotebookId = notebookIdMap.get(note.notebookId) || null
// Get label IDs
const labelNames = (note.labels || note.labelRelations || []).map((l: any) => l.name).filter(Boolean)
const labels = await prisma.label.findMany({
where: {
userId: session.user.id,
name: {
in: note.labels.map((l: any) => l.name)
in: labelNames
}
}
})
@@ -125,8 +126,14 @@ export async function POST(req: NextRequest) {
data: {
userId: session.user.id,
title: note.title || 'Untitled',
content: note.content,
content: note.content || '',
color: note.color || 'default',
isPinned: note.isPinned || false,
isArchived: note.isArchived || false,
type: note.type || 'richtext',
checkItems: note.checkItems || null,
images: note.images || null,
links: note.links || null,
notebookId: mappedNotebookId,
labelRelations: {
connect: labels.map(label => ({ id: label.id }))

View File

@@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { EmbeddingService } from '@/lib/ai/services/embedding.service'
export async function POST(req: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
// Fetch all notes for the user
const notes = await prisma.note.findMany({
where: { userId, trashedAt: null },
select: { id: true, title: true, content: true }
})
const embeddingService = new EmbeddingService()
let processedCount = 0
// Process in small batches to avoid timeouts if possible
// Note: In a real production app, this should be a background job
for (const note of notes) {
try {
const textToEmbed = `${note.title || ''}\n${note.content}`
if (textToEmbed.trim()) {
const embedding = await embeddingService.generateEmbedding(textToEmbed)
await prisma.noteEmbedding.upsert({
where: { noteId: note.id },
update: { embedding: JSON.stringify(embedding) },
create: {
noteId: note.id,
embedding: JSON.stringify(embedding)
}
})
processedCount++
}
} catch (err) {
console.error(`Failed to reindex note ${note.id}:`, err)
}
}
return NextResponse.json({
success: true,
count: processedCount,
total: notes.length
})
} catch (error) {
console.error('Reindex error:', error)
return NextResponse.json(
{ success: false, error: 'Failed to reindex notes' },
{ status: 500 }
)
}
}

View File

@@ -4,6 +4,7 @@ import Credentials from 'next-auth/providers/credentials';
import { z } from 'zod';
import prisma from '@/lib/prisma';
import bcrypt from 'bcryptjs';
import { rateLimit } from '@/lib/rate-limit';
export const { auth, signIn, signOut, handlers } = NextAuth({
...authConfig,
@@ -16,17 +17,21 @@ export const { auth, signIn, signOut, handlers } = NextAuth({
.safeParse(credentials);
if (!parsedCredentials.success) {
console.error('Invalid credentials format');
return null;
}
const { email, password } = parsedCredentials.data;
const { allowed } = rateLimit(`login:${email.toLowerCase()}`)
if (!allowed) {
return null;
}
const user = await prisma.user.findUnique({
where: { email: email.toLowerCase() }
});
if (!user || !user.password) {
console.error('User not found or no password set');
return null;
}
@@ -41,10 +46,8 @@ export const { auth, signIn, signOut, handlers } = NextAuth({
};
}
console.error('Password mismatch');
return null;
} catch (error) {
console.error('CRITICAL AUTH ERROR:', error);
} catch {
return null;
}
},

View File

@@ -1,19 +0,0 @@
import { cn } from '@/lib/utils'
export interface AdminContentAreaProps {
children: React.ReactNode
className?: string
}
export function AdminContentArea({ children, className }: AdminContentAreaProps) {
return (
<main
className={cn(
'flex-1 overflow-y-auto bg-gray-50 dark:bg-zinc-950 p-6',
className
)}
>
{children}
</main>
)
}

View File

@@ -0,0 +1,71 @@
'use client'
import { usePathname } from 'next/navigation'
import { LayoutDashboard, Users, Brain, Settings } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
export interface AdminNavProps {
className?: string
}
export interface NavItem {
titleKey: string
href: string
icon: React.ReactNode
}
const navItems: NavItem[] = [
{
titleKey: 'admin.sidebar.dashboard',
href: '/admin',
icon: <LayoutDashboard className="h-4 w-4" />,
},
{
titleKey: 'admin.sidebar.users',
href: '/admin/users',
icon: <Users className="h-4 w-4" />,
},
{
titleKey: 'admin.sidebar.aiManagement',
href: '/admin/ai',
icon: <Brain className="h-4 w-4" />,
},
{
titleKey: 'admin.sidebar.settings',
href: '/admin/settings',
icon: <Settings className="h-4 w-4" />,
},
]
export function AdminNav({ className }: AdminNavProps) {
const pathname = usePathname()
const { t } = useLanguage()
return (
<nav className={cn('flex items-center gap-1', className)}>
{navItems.map((item) => {
const isActive = pathname === item.href || (item.href !== '/admin' && pathname?.startsWith(item.href + '/'))
return (
// <a> instead of <Link>: avoids Next.js RSC navigation transitions
// that trigger React Error #310 (React bug #33580) in production.
// Full-page reloads are acceptable for admin navigation.
<a
key={item.href}
href={item.href}
className={cn(
'flex items-center gap-2 px-3 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap',
isActive
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
)}
>
{item.icon}
<span>{t(item.titleKey)}</span>
</a>
)
})}
</nav>
)
}

View File

@@ -1,79 +0,0 @@
'use client'
import { usePathname } from 'next/navigation'
import { LayoutDashboard, Users, Brain, Settings } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
export interface AdminSidebarProps {
className?: string
}
export interface NavItem {
titleKey: string
href: string
icon: React.ReactNode
}
const navItems: NavItem[] = [
{
titleKey: 'admin.sidebar.dashboard',
href: '/admin',
icon: <LayoutDashboard className="h-5 w-5" />,
},
{
titleKey: 'admin.sidebar.users',
href: '/admin/users',
icon: <Users className="h-5 w-5" />,
},
{
titleKey: 'admin.sidebar.aiManagement',
href: '/admin/ai',
icon: <Brain className="h-5 w-5" />,
},
{
titleKey: 'admin.sidebar.settings',
href: '/admin/settings',
icon: <Settings className="h-5 w-5" />,
},
]
export function AdminSidebar({ className }: AdminSidebarProps) {
const pathname = usePathname()
const { t } = useLanguage()
return (
<aside
className={cn(
'w-64 bg-white dark:bg-zinc-900 border-r border-gray-200 dark:border-gray-800 p-4',
className
)}
>
<nav className="space-y-1">
{navItems.map((item) => {
const isActive = pathname === item.href || (item.href !== '/admin' && pathname?.startsWith(item.href + '/'))
return (
// <a> instead of <Link>: avoids Next.js RSC navigation transitions
// that trigger React Error #310 (React bug #33580) in production.
// Full-page reloads are acceptable for admin navigation.
<a
key={item.href}
href={item.href}
className={cn(
'flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors',
'hover:bg-gray-100 dark:hover:bg-zinc-800',
isActive
? 'bg-gray-100 dark:bg-zinc-800 text-gray-900 dark:text-white font-semibold'
: 'text-gray-600 dark:text-gray-400'
)}
>
{item.icon}
<span>{t(item.titleKey)}</span>
</a>
)
})}
</nav>
</aside>
)
}

View File

@@ -5,7 +5,7 @@
* Compact card matching the reference design — with a "Next Run / Status" footer.
*/
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
@@ -83,21 +83,51 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
const dateLocale = language === 'fr' ? fr : enUS
const isNew = Date.now() - new Date(agent.createdAt).getTime() < 5 * 60 * 1000
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
// Cleanup polling on unmount
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current) }, [])
const handleRun = async () => {
setIsRunning(true)
const toastId = toast.loading(
t('agents.toasts.running') || `Agent "${agent.name}" en cours…`,
{ description: t('agents.toasts.runningDesc') || 'La génération peut prendre quelques minutes.', duration: Infinity }
)
try {
const { runAgent } = await import('@/app/actions/agent-actions')
const result = await runAgent(agent.id)
if (result.success) {
toast.success(t('agents.toasts.runSuccess', { name: agent.name }))
} else {
toast.error(t('agents.toasts.runError', { error: result.error || t('agents.toasts.runFailed') }))
if (!result.success) {
toast.error(t('agents.toasts.runError', { error: result.error || t('agents.toasts.runFailed') }), { id: toastId })
setIsRunning(false)
return
}
// Poll status every 3 s until terminal state
if (pollRef.current) clearInterval(pollRef.current)
pollRef.current = setInterval(async () => {
try {
const res = await fetch(`/api/agents/run-for-note?agentId=${agent.id}`)
const data = await res.json()
if (data.status === 'success') {
clearInterval(pollRef.current!)
pollRef.current = null
setIsRunning(false)
toast.success(t('agents.toasts.runSuccess', { name: agent.name }), { id: toastId, duration: 6000 })
onRefresh()
} else if (data.status === 'failure') {
clearInterval(pollRef.current!)
pollRef.current = null
setIsRunning(false)
toast.error(t('agents.toasts.runError', { error: data.error || t('agents.toasts.runFailed') }), { id: toastId })
onRefresh()
}
} catch { /* network error — keep polling */ }
}, 3000)
} catch {
toast.error(t('agents.toasts.runGenericError'))
} finally {
toast.error(t('agents.toasts.runGenericError'), { id: toastId })
setIsRunning(false)
onRefresh()
}
}

View File

@@ -6,15 +6,15 @@
* Novice-friendly: hides system prompt and tools behind "Advanced mode".
*/
import { useState, useMemo, useRef } from 'react'
import { X, Plus, Trash2, Globe, FileSearch, FilePlus, FileText, ExternalLink, Brain, ChevronDown, ChevronUp, HelpCircle, Mail, ImageIcon } from 'lucide-react'
import { useState, useMemo, useRef, useCallback, useEffect } from 'react'
import { X, Plus, Trash2, Globe, FileSearch, FilePlus, FileText, ExternalLink, Brain, ChevronDown, ChevronUp, HelpCircle, Mail, ImageIcon, Presentation, Pencil, Check } from 'lucide-react'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
// --- Types ---
type AgentType = 'scraper' | 'researcher' | 'monitor' | 'custom'
type AgentType = 'scraper' | 'researcher' | 'monitor' | 'custom' | 'slide-generator' | 'excalidraw-generator'
/** Small "?" tooltip shown next to form labels */
function FieldHelp({ tooltip }: { tooltip: string }) {
@@ -41,6 +41,7 @@ interface AgentFormProps {
role: string
sourceUrls?: string | null
sourceNotebookId?: string | null
sourceNoteIds?: string | null
targetNotebookId?: string | null
frequency: string
tools?: string | null
@@ -50,6 +51,8 @@ interface AgentFormProps {
scheduledTime?: string | null
scheduledDay?: number | null
timezone?: string | null
slideTheme?: string | null
slideStyle?: string | null
} | null
notebooks: { id: string; name: string; icon?: string | null }[]
onSave: (data: FormData) => Promise<void>
@@ -62,6 +65,8 @@ const TOOL_PRESETS: Record<string, string[]> = {
researcher: ['web_search', 'web_scrape', 'note_search', 'note_create', 'memory_search'],
monitor: ['note_search', 'note_read', 'note_create', 'memory_search'],
custom: ['memory_search'],
'slide-generator': ['generate_pptx'],
'excalidraw-generator': ['generate_excalidraw'],
}
// --- Shared class strings ---
@@ -89,6 +94,13 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
return ['']
})
const [sourceNotebookId, setSourceNotebookId] = useState(agent?.sourceNotebookId || '')
const [sourceNoteIds, setSourceNoteIds] = useState<string[]>(() => {
if (agent?.sourceNoteIds) {
try { return JSON.parse(agent.sourceNoteIds) } catch { return [] }
}
return []
})
const [noteOptions, setNoteOptions] = useState<{ id: string; title: string }[]>([])
const [targetNotebookId, setTargetNotebookId] = useState(agent?.targetNotebookId || '')
const [frequency, setFrequency] = useState(agent?.frequency || 'manual')
const [scheduledTime, setScheduledTime] = useState(agent?.scheduledTime || '08:00')
@@ -110,7 +122,36 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
const [maxSteps, setMaxSteps] = useState(agent?.maxSteps || 10)
const [notifyEmail, setNotifyEmail] = useState(agent?.notifyEmail || false)
const [includeImages, setIncludeImages] = useState(agent?.includeImages || false)
const [slideTheme, setSlideTheme] = useState(agent?.slideTheme || '')
const [slideStyle, setSlideStyle] = useState<'soft' | 'sharp' | 'rounded' | 'pill'>(
(agent?.slideStyle as 'soft' | 'sharp' | 'rounded' | 'pill') || 'soft'
)
const [excalidrawStyle, setExcalidrawStyle] = useState<'default' | 'austere' | 'sketch-plus'>(() => {
if (agent?.slideStyle === 'austere') return 'austere'
if (agent?.slideStyle === 'sketch-plus') return 'sketch-plus'
return 'default'
})
const [excalidrawType, setExcalidrawType] = useState<'auto' | 'architecture-cloud' | 'flowchart' | 'mindmap' | 'org-chart' | 'timeline' | 'process-map'>(() => {
const value = (agent?.slideTheme || '').trim()
if (value === 'auto' || value === 'architecture-cloud' || value === 'mindmap' || value === 'org-chart' || value === 'timeline' || value === 'process-map') return value
return 'flowchart'
})
const [isSaving, setIsSaving] = useState(false)
useEffect(() => {
if (!sourceNotebookId || (type !== 'slide-generator' && type !== 'excalidraw-generator' && type !== 'monitor')) {
setNoteOptions([])
return
}
fetch(`/api/notes?notebookId=${sourceNotebookId}&limit=50`)
.then(r => r.json())
.then(data => {
const notes = Array.isArray(data.data) ? data.data : Array.isArray(data) ? data : []
setNoteOptions(notes.map((n: any) => ({ id: n.id, title: n.title || 'Sans titre' })))
})
.catch(() => setNoteOptions([]))
}, [sourceNotebookId, type])
const [showAdvanced, setShowAdvanced] = useState(() => {
// Auto-open advanced if editing an agent with custom tools or custom prompt
if (agent?.tools) {
@@ -133,6 +174,9 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
{ id: 'note_create', icon: FilePlus, labelKey: 'agents.tools.noteCreate', external: false },
{ id: 'url_fetch', icon: ExternalLink, labelKey: 'agents.tools.urlFetch', external: false },
{ id: 'memory_search', icon: Brain, labelKey: 'agents.tools.memorySearch', external: false },
{ id: 'generate_pptx', icon: Presentation, labelKey: 'agents.tools.generatePptx', external: false },
{ id: 'generate_slides', icon: Presentation, labelKey: 'agents.tools.generateSlides', external: false },
{ id: 'generate_excalidraw', icon: Pencil, labelKey: 'agents.tools.generateExcalidraw', external: false },
], [])
// Track previous type to detect user-initiated type changes
@@ -172,10 +216,14 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
formData.set('frequency', frequency)
formData.set('targetNotebookId', targetNotebookId)
if (type === 'monitor') {
if (type === 'monitor' || type === 'slide-generator' || type === 'excalidraw-generator') {
formData.set('sourceNotebookId', sourceNotebookId)
}
if (sourceNoteIds.length > 0) {
formData.set('sourceNoteIds', JSON.stringify(sourceNoteIds))
}
const validUrls = urls.filter(u => u.trim())
if (validUrls.length > 0) {
formData.set('sourceUrls', JSON.stringify(validUrls))
@@ -189,6 +237,15 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
formData.set('scheduledDay', String(scheduledDay))
formData.set('timezone', timezone)
if (type === 'slide-generator') {
if (slideTheme) formData.set('slideTheme', slideTheme)
formData.set('slideStyle', slideStyle)
}
if (type === 'excalidraw-generator') {
formData.set('slideTheme', excalidrawType)
formData.set('slideStyle', excalidrawStyle)
}
await onSave(formData)
} catch {
toast.error(t('agents.toasts.saveError'))
@@ -197,12 +254,14 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
}
}
const showSourceNotebook = type === 'monitor'
const showSourceNotebook = type === 'monitor' || type === 'slide-generator' || type === 'excalidraw-generator'
const agentTypes: { value: AgentType; labelKey: string; descKey: string }[] = [
{ value: 'researcher', labelKey: 'agents.types.researcher', descKey: 'agents.typeDescriptions.researcher' },
{ value: 'scraper', labelKey: 'agents.types.scraper', descKey: 'agents.typeDescriptions.scraper' },
{ value: 'monitor', labelKey: 'agents.types.monitor', descKey: 'agents.typeDescriptions.monitor' },
{ value: 'slide-generator', labelKey: 'agents.types.slideGenerator', descKey: 'agents.typeDescriptions.slideGenerator' },
{ value: 'excalidraw-generator', labelKey: 'agents.types.excalidrawGenerator', descKey: 'agents.typeDescriptions.excalidrawGenerator' },
{ value: 'custom', labelKey: 'agents.types.custom', descKey: 'agents.typeDescriptions.custom' },
]
@@ -318,13 +377,13 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
</div>
)}
{/* Source Notebook (monitor only) */}
{/* Source Notebook (monitor, slide, excalidraw) */}
{showSourceNotebook && (
<div>
<label className={labelCls}>{t('agents.form.sourceNotebook')}<FieldHelp tooltip={t('agents.help.tooltips.sourceNotebook')} /></label>
<select
value={sourceNotebookId}
onChange={e => setSourceNotebookId(e.target.value)}
onChange={e => { setSourceNotebookId(e.target.value); setSourceNoteIds([]) }}
className={selectCls}
>
<option value="">{t('agents.form.selectNotebook')}</option>
@@ -337,7 +396,149 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
</div>
)}
{/* Target Notebook */}
{/* Note multi-select (slide-generator, excalidraw-generator only) */}
{(type === 'slide-generator' || type === 'excalidraw-generator') && sourceNotebookId && noteOptions.length > 0 && (
<div>
<label className={labelCls}>{t('agents.form.selectNotes')}<FieldHelp tooltip={t('agents.help.tooltips.selectNotes')} /></label>
<div className="border border-input rounded-lg max-h-48 overflow-y-auto bg-card">
{noteOptions.map(note => {
const isSelected = sourceNoteIds.includes(note.id)
return (
<button
key={note.id}
type="button"
onClick={() => {
setSourceNoteIds(prev =>
isSelected ? prev.filter(id => id !== note.id) : [...prev, note.id]
)
}}
className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left hover:bg-accent/50 transition-colors ${isSelected ? 'bg-primary/5' : ''}`}
>
<div className={`w-4 h-4 rounded border-2 flex items-center justify-center flex-shrink-0 ${isSelected ? 'border-primary bg-primary' : 'border-input'}`}>
{isSelected && <Check className="w-3 h-3 text-primary-foreground" />}
</div>
<span className={isSelected ? 'text-primary font-medium' : 'text-foreground'}>{note.title}</span>
</button>
)
})}
</div>
{sourceNoteIds.length > 0 && (
<p className="text-xs text-muted-foreground mt-1">{t('agents.form.notesSelected', { count: sourceNoteIds.length })}</p>
)}
</div>
)}
{/* Theme selector — slide-generator only */}
{type === 'slide-generator' && (
<>
<div>
<label className={labelCls}>{t('agents.form.slideTheme')}<FieldHelp tooltip={t('agents.help.tooltips.slideTheme')} /></label>
<select
value={slideTheme}
onChange={e => setSlideTheme(e.target.value)}
className={selectCls}
>
<option value="">{t('agents.form.slideThemeDefault')}</option>
<option value="modern_wellness">Modern & Wellness</option>
<option value="business_authority">Business & Authority</option>
<option value="nature_outdoors">Nature & Outdoors</option>
<option value="vintage_academic">Vintage & Academic</option>
<option value="soft_creative">Soft & Creative</option>
<option value="bohemian">Bohemian</option>
<option value="vibrant_tech">Vibrant & Tech</option>
<option value="craft_artisan">Craft & Artisan</option>
<option value="tech_night">Tech & Night (dark)</option>
<option value="education_charts">Education & Charts</option>
<option value="forest_eco">Forest & Eco</option>
<option value="elegant_fashion">Elegant & Fashion</option>
<option value="art_food">Art & Food</option>
<option value="luxury_mystery">Luxury & Mystery</option>
<option value="pure_tech_blue">Pure Tech Blue</option>
<option value="coastal_coral">Coastal Coral</option>
<option value="vibrant_orange_mint">Vibrant Orange Mint</option>
<option value="platinum_white_gold">Platinum White Gold</option>
</select>
</div>
<div>
<label className={labelCls}>{t('agents.form.slideStyle')}<FieldHelp tooltip={t('agents.help.tooltips.slideStyle')} /></label>
<div className="grid grid-cols-2 gap-2">
{(['soft', 'sharp', 'rounded', 'pill'] as const).map(s => (
<button
key={s}
type="button"
onClick={() => setSlideStyle(s)}
className={`px-3 py-2 rounded-lg border-2 text-sm transition-all text-left ${
slideStyle === s
? 'border-primary bg-primary/5 text-primary font-medium'
: `${toggleOffBorder} text-muted-foreground`
}`}
>
{t(`agents.form.slideStyle${s.charAt(0).toUpperCase() + s.slice(1)}` as any)}
</button>
))}
</div>
</div>
</>
)}
{/* Visual style selector — excalidraw-generator only */}
{type === 'excalidraw-generator' && (
<>
<div>
<label className={labelCls}>{t('agents.form.excalidrawDiagramType')}</label>
<div className="grid grid-cols-1 gap-2">
{([
{ id: 'auto', labelKey: 'agents.form.excalidrawDiagramTypeAuto' },
{ id: 'flowchart', labelKey: 'agents.form.excalidrawDiagramTypeFlowchart' },
{ id: 'mindmap', labelKey: 'agents.form.excalidrawDiagramTypeMindmap' },
{ id: 'org-chart', labelKey: 'agents.form.excalidrawDiagramTypeOrgChart' },
{ id: 'timeline', labelKey: 'agents.form.excalidrawDiagramTypeTimeline' },
{ id: 'process-map', labelKey: 'agents.form.excalidrawDiagramTypeProcessMap' },
{ id: 'architecture-cloud', labelKey: 'agents.form.excalidrawDiagramTypeArchitectureCloud' },
] as const).map((opt) => (
<button
key={opt.id}
type="button"
onClick={() => setExcalidrawType(opt.id)}
className={`px-3 py-2 rounded-lg border-2 text-sm transition-all text-left ${
excalidrawType === opt.id
? 'border-primary bg-primary/5 text-primary font-medium'
: `${toggleOffBorder} text-muted-foreground`
}`}
>
{t(opt.labelKey)}
</button>
))}
</div>
</div>
<div>
<label className={labelCls}>{t('agents.form.excalidrawDiagramStyle')}</label>
<div className="grid grid-cols-2 gap-2">
{([
{ id: 'default', labelKey: 'agents.form.excalidrawDiagramStyleDefault' },
{ id: 'sketch-plus', labelKey: 'agents.form.excalidrawDiagramStyleSketchPlus' },
{ id: 'austere', labelKey: 'agents.form.excalidrawDiagramStyleAustere' },
] as const).map((opt) => (
<button
key={opt.id}
type="button"
onClick={() => setExcalidrawStyle(opt.id)}
className={`px-3 py-2 rounded-lg border-2 text-sm transition-all text-left ${
excalidrawStyle === opt.id
? 'border-primary bg-primary/5 text-primary font-medium'
: `${toggleOffBorder} text-muted-foreground`
}`}
>
{t(opt.labelKey)}
</button>
))}
</div>
</div>
</>
)}
{/* Target Notebook — hidden for file generators (they never create notes) */}
{type !== 'slide-generator' && type !== 'excalidraw-generator' && (
<div>
<label className={labelCls}>{t('agents.form.targetNotebook')}<FieldHelp tooltip={t('agents.help.tooltips.targetNotebook')} /></label>
<select
@@ -353,6 +554,7 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
))}
</select>
</div>
)}
{/* Frequency */}
<div>
@@ -423,7 +625,8 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
</div>
)}
{/* Email Notification */}
{/* Email Notification — hidden for file generators */}
{type !== 'slide-generator' && type !== 'excalidraw-generator' && (
<div
onClick={() => setNotifyEmail(!notifyEmail)}
className={`flex items-center gap-3 p-3 rounded-lg border-2 cursor-pointer transition-all ${
@@ -441,8 +644,10 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
<div className={`w-4 h-4 bg-card rounded-full shadow-sm transition-transform mt-0.5 ${notifyEmail ? 'translate-x-4.5 ml-0.5' : 'ml-0.5'}`} />
</div>
</div>
)}
{/* Include Images */}
{/* Include Images — hidden for file generators */}
{type !== 'slide-generator' && type !== 'excalidraw-generator' && (
<div
onClick={() => setIncludeImages(!includeImages)}
className={`flex items-center gap-3 p-3 rounded-lg border-2 cursor-pointer transition-all ${
@@ -460,6 +665,7 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
<div className={`w-4 h-4 bg-card rounded-full shadow-sm transition-transform mt-0.5 ${includeImages ? 'translate-x-4.5 ml-0.5' : 'ml-0.5'}`} />
</div>
</div>
)}
{/* Advanced mode toggle */}
<button

View File

@@ -1,15 +1,13 @@
'use client'
import { useState } from 'react'
import { Card } from '@/components/ui/card'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Label } from '@/components/ui/label'
import { updateAISettings } from '@/app/actions/ai-settings'
import { DemoModeToggle } from '@/components/demo-mode-toggle'
import { toast } from 'sonner'
import { Loader2 } from 'lucide-react'
import { Loader2, Sparkles, Brain, Languages, Tag, History, Wand2 } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
interface AISettingsPanelProps {
@@ -35,17 +33,13 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
const { t } = useLanguage()
const handleToggle = async (feature: string, value: boolean) => {
// Optimistic update
setSettings(prev => ({ ...prev, [feature]: value }))
try {
setIsPending(true)
await updateAISettings({ [feature]: value })
toast.success(t('aiSettings.saved'))
} catch (error) {
console.error('Error updating setting:', error)
} catch {
toast.error(t('aiSettings.error'))
// Revert on error
setSettings(initialSettings)
} finally {
setIsPending(false)
@@ -54,31 +48,11 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
const handleFrequencyChange = async (value: 'daily' | 'weekly' | 'custom') => {
setSettings(prev => ({ ...prev, memoryEchoFrequency: value }))
try {
setIsPending(true)
await updateAISettings({ memoryEchoFrequency: value })
toast.success(t('aiSettings.saved'))
} catch (error) {
console.error('Error updating frequency:', error)
toast.error(t('aiSettings.error'))
setSettings(initialSettings)
} finally {
setIsPending(false)
}
}
const handleLanguageChange = async (value: 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl') => {
setSettings(prev => ({ ...prev, preferredLanguage: value }))
try {
setIsPending(true)
await updateAISettings({ preferredLanguage: value })
toast.success(t('aiSettings.saved'))
} catch (error) {
console.error('Error updating language:', error)
} catch {
toast.error(t('aiSettings.error'))
setSettings(initialSettings)
} finally {
@@ -88,179 +62,154 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
const handleDemoModeToggle = async (enabled: boolean) => {
setSettings(prev => ({ ...prev, demoMode: enabled }))
try {
setIsPending(true)
await updateAISettings({ demoMode: enabled })
} catch (error) {
console.error('Error toggling demo mode:', error)
} catch {
toast.error(t('aiSettings.error'))
setSettings(initialSettings)
throw error
throw new Error()
} finally {
setIsPending(false)
}
}
const features = [
{
key: 'titleSuggestions',
icon: Wand2,
name: t('titleSuggestions.available').replace('💡 ', ''),
description: t('aiSettings.titleSuggestionsDesc'),
value: settings.titleSuggestions,
},
{
key: 'paragraphRefactor',
icon: Sparkles,
name: t('aiSettings.aiNote'),
description: t('aiSettings.aiNoteDesc'),
value: settings.paragraphRefactor,
},
{
key: 'memoryEcho',
icon: Brain,
name: t('memoryEcho.title'),
description: t('memoryEcho.dailyInsight'),
value: settings.memoryEcho,
},
{
key: 'languageDetection',
icon: Languages,
name: t('aiSettings.languageDetection'),
description: t('aiSettings.languageDetectionDesc'),
value: settings.languageDetection ?? true,
},
{
key: 'autoLabeling',
icon: Tag,
name: t('aiSettings.autoLabeling'),
description: t('aiSettings.autoLabelingDesc'),
value: settings.autoLabeling ?? true,
},
{
key: 'noteHistory',
icon: History,
name: t('aiSettings.noteHistory'),
description: t('aiSettings.noteHistoryDesc'),
value: settings.noteHistory ?? false,
},
]
return (
<div className="space-y-6">
<div className="space-y-8">
{isPending && (
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t('aiSettings.saving')}
</div>
)}
{/* Feature Toggles */}
<div className="space-y-4">
<h2 className="text-xl font-semibold">{t('aiSettings.features')}</h2>
<FeatureToggle
name={t('titleSuggestions.available').replace('💡 ', '')}
description={t('aiSettings.titleSuggestionsDesc')}
checked={settings.titleSuggestions}
onChange={(checked) => handleToggle('titleSuggestions', checked)}
/>
<FeatureToggle
name={t('aiSettings.aiNote')}
description={t('aiSettings.aiNoteDesc')}
checked={settings.paragraphRefactor}
onChange={(checked) => handleToggle('paragraphRefactor', checked)}
/>
<FeatureToggle
name={t('memoryEcho.title')}
description={t('memoryEcho.dailyInsight')}
checked={settings.memoryEcho}
onChange={(checked) => handleToggle('memoryEcho', checked)}
/>
{settings.memoryEcho && (
<Card className="p-4 ml-6">
<Label htmlFor="frequency" className="text-sm font-medium">
{t('aiSettings.frequency')}
</Label>
<p className="text-xs text-gray-500 mb-3">
{t('aiSettings.frequencyDesc')}
</p>
<RadioGroup
value={settings.memoryEchoFrequency}
onValueChange={handleFrequencyChange}
{/* Feature toggles as cards */}
<div>
<h2 className="text-base font-semibold text-foreground mb-4">{t('aiSettings.features')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{features.map(({ key, icon: Icon, name, description, value }) => (
<div
key={key}
className="bg-card rounded-lg border border-border p-5 shadow-sm flex items-start justify-between gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="daily" id="daily" />
<Label htmlFor="daily" className="font-normal">
{t('aiSettings.frequencyDaily')}
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="weekly" id="weekly" />
<Label htmlFor="weekly" className="font-normal">
{t('aiSettings.frequencyWeekly')}
</Label>
</div>
</RadioGroup>
</Card>
)}
{/* Language Detection Toggle */}
<FeatureToggle
name={t('aiSettings.languageDetection')}
description={t('aiSettings.languageDetectionDesc')}
checked={settings.languageDetection ?? true}
onChange={(checked) => handleToggle('languageDetection', checked)}
/>
{/* Auto Labeling Toggle */}
<FeatureToggle
name={t('aiSettings.autoLabeling')}
description={t('aiSettings.autoLabelingDesc')}
checked={settings.autoLabeling ?? true}
onChange={(checked) => handleToggle('autoLabeling', checked)}
/>
<FeatureToggle
name={t('aiSettings.noteHistory')}
description={t('aiSettings.noteHistoryDesc')}
checked={settings.noteHistory ?? false}
onChange={(checked) => handleToggle('noteHistory', checked)}
/>
{settings.noteHistory && (
<div className="space-y-2 rounded-lg border border-border/50 bg-muted/30 p-3">
<p className="text-sm font-medium">{t('notes.historyMode')}</p>
<RadioGroup
value={settings.noteHistoryMode ?? 'manual'}
onValueChange={(value) => {
const mode = value as 'manual' | 'auto'
setSettings((s) => ({ ...s, noteHistoryMode: mode }))
updateAISettings({ noteHistoryMode: mode }).then(() => {
toast.success(t('settings.settingsSaved'))
})
}}
className="space-y-2"
>
<div className="flex items-start gap-2">
<RadioGroupItem value="manual" id="history-manual" />
<div className="grid gap-0.5 leading-none">
<Label htmlFor="history-manual" className="text-sm font-medium">
{t('notes.historyModeManual')}
</Label>
<p className="text-xs text-muted-foreground">
{t('notes.historyModeManualDesc')}
</p>
<div className="flex items-start gap-3">
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0 mt-0.5">
<Icon className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-medium text-foreground">{name}</p>
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
</div>
</div>
<div className="flex items-start gap-2">
<RadioGroupItem value="auto" id="history-auto" />
<div className="grid gap-0.5 leading-none">
<Label htmlFor="history-auto" className="text-sm font-medium">
{t('notes.historyModeAuto')}
</Label>
<p className="text-xs text-muted-foreground">
{t('notes.historyModeAutoDesc')}
</p>
</div>
</div>
</RadioGroup>
</div>
)}
{/* Demo Mode Toggle */}
<DemoModeToggle
demoMode={settings.demoMode}
onToggle={handleDemoModeToggle}
/>
<button
type="button"
role="switch"
aria-checked={value}
onClick={() => handleToggle(key, !value)}
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary/30 mt-0.5 ${value ? 'bg-primary' : 'bg-muted-foreground/30'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${value ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
))}
</div>
</div>
{/* Memory Echo frequency — shown when enabled */}
{settings.memoryEcho && (
<div className="bg-card rounded-lg border border-border p-5 shadow-sm">
<h3 className="text-sm font-semibold text-foreground mb-1">{t('aiSettings.frequency')}</h3>
<p className="text-xs text-muted-foreground mb-4">{t('aiSettings.frequencyDesc')}</p>
<RadioGroup value={settings.memoryEchoFrequency} onValueChange={handleFrequencyChange} className="space-y-2">
{[
{ value: 'daily', label: t('aiSettings.frequencyDaily') },
{ value: 'weekly', label: t('aiSettings.frequencyWeekly') },
].map((opt) => (
<div key={opt.value} className="flex items-center space-x-2">
<RadioGroupItem value={opt.value} id={`freq-${opt.value}`} />
<Label htmlFor={`freq-${opt.value}`} className="font-normal text-sm cursor-pointer">{opt.label}</Label>
</div>
))}
</RadioGroup>
</div>
)}
{/* Note History mode — shown when enabled */}
{settings.noteHistory && (
<div className="bg-card rounded-lg border border-border p-5 shadow-sm">
<h3 className="text-sm font-semibold text-foreground mb-1">{t('notes.historyMode')}</h3>
<RadioGroup
value={settings.noteHistoryMode ?? 'manual'}
onValueChange={(value) => {
const mode = value as 'manual' | 'auto'
setSettings(s => ({ ...s, noteHistoryMode: mode }))
updateAISettings({ noteHistoryMode: mode }).then(() => toast.success(t('settings.settingsSaved')))
}}
className="space-y-3 mt-3"
>
{[
{ value: 'manual', label: t('notes.historyModeManual'), desc: t('notes.historyModeManualDesc') },
{ value: 'auto', label: t('notes.historyModeAuto'), desc: t('notes.historyModeAutoDesc') },
].map((opt) => (
<div key={opt.value} className="flex items-start gap-2">
<RadioGroupItem value={opt.value} id={`history-${opt.value}`} className="mt-0.5" />
<div>
<Label htmlFor={`history-${opt.value}`} className="text-sm font-medium cursor-pointer">{opt.label}</Label>
<p className="text-xs text-muted-foreground">{opt.desc}</p>
</div>
</div>
))}
</RadioGroup>
</div>
)}
{/* Demo Mode */}
<DemoModeToggle demoMode={settings.demoMode} onToggle={handleDemoModeToggle} />
</div>
)
}
interface FeatureToggleProps {
name: string
description: string
checked: boolean
onChange: (checked: boolean) => void
}
function FeatureToggle({ name, description, checked, onChange }: FeatureToggleProps) {
return (
<Card className="p-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label className="text-base font-medium">{name}</Label>
<p className="text-sm text-gray-500">{description}</p>
</div>
<Switch
checked={checked}
onCheckedChange={onChange}
disabled={false}
/>
</div>
</Card>
)
}

View File

@@ -13,6 +13,7 @@ import {
Globe, BookOpen, FileText, RotateCcw, Check,
Maximize2, ImageIcon, Link2, Download, ArrowDownToLine,
GitMerge, PlusCircle, Eye, Code, Languages,
Presentation, PenTool, ExternalLink,
} from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { MarkdownContent } from '@/components/markdown-content'
@@ -71,11 +72,18 @@ const ACTION_IDS = [
// ── Types ─────────────────────────────────────────────────────────────────────
interface GenerateResult {
type: 'slides' | 'diagram'
canvasId?: string
noteId?: string
}
interface ContextualAIChatProps {
onClose: () => void
noteTitle?: string
noteContent?: string
noteImages?: string[]
noteId?: string
/** Called when an action result should be injected into the note */
onApplyToNote?: (newContent: string) => void
/** Called when the user wants to undo the last injected action */
@@ -95,6 +103,7 @@ export function ContextualAIChat({
noteTitle,
noteContent,
noteImages,
noteId,
onApplyToNote,
onUndoLastAction,
lastActionApplied = false,
@@ -116,7 +125,16 @@ export function ContextualAIChat({
const [actionPreview, setActionPreview] = useState<{ label: string; text: string } | null>(null)
const [showLangPicker, setShowLangPicker] = useState(false)
const [translateTarget, setTranslateTarget] = useState('')
// Generate slides / diagram state
const [generateLoading, setGenerateLoading] = useState<'slides' | 'diagram' | null>(null)
const [generateResult, setGenerateResult] = useState<GenerateResult | null>(null)
const [customLangInput, setCustomLangInput] = useState('')
// Generation options
const [slideTheme, setSlideTheme] = useState('vibrant_tech')
const [slideStyle, setSlideStyle] = useState('soft')
const [diagramType, setDiagramType] = useState('auto')
const [diagramStyle, setDiagramStyle] = useState('default')
// Resource tab state
const [resourceUrl, setResourceUrl] = useState('')
@@ -249,6 +267,104 @@ export function ContextualAIChat({
const handleDiscardPreview = () => setActionPreview(null)
// ── Generate slides / diagram ────────────────────────────────────────────────
const generatePollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const handleGenerate = async (type: 'slides' | 'diagram') => {
if (!noteId) {
toast.error(t('ai.generate.noNoteId') || 'Note non sauvegardée')
return
}
setGenerateLoading(type)
setGenerateResult(null)
// Persistent loading toast — layout-level (Sonner), survives navigation
const toastId = toast.loading(
type === 'slides'
? (t('ai.generate.toastLoading.slides') || '⏳ Génération de la présentation en cours…')
: (t('ai.generate.toastLoading.diagram') || '⏳ Génération du diagramme en cours…'),
{ duration: Infinity, description: t('ai.generate.toastLoadingDesc') || 'Vous pouvez naviguer librement, une notification apparaîtra.' }
)
try {
// POST starts the agent immediately and returns agentId (fire-and-forget on server)
const res = await fetch('/api/agents/run-for-note', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
noteId,
type: type === 'slides' ? 'slide-generator' : 'excalidraw-generator',
theme: type === 'slides' ? slideTheme : diagramType,
style: type === 'slides' ? slideStyle : diagramStyle,
}),
})
const data = await res.json()
if (!res.ok || !data.success) {
toast.error(data.error || t('ai.generate.error') || 'Erreur lors de la génération', { id: toastId })
setGenerateLoading(null)
return
}
const { agentId } = data as { agentId: string }
// Poll status every 3 s until terminal state
if (generatePollRef.current) clearInterval(generatePollRef.current)
generatePollRef.current = setInterval(async () => {
try {
const pollRes = await fetch(`/api/agents/run-for-note?agentId=${agentId}`)
const poll = await pollRes.json()
if (poll.status === 'success') {
clearInterval(generatePollRef.current!)
generatePollRef.current = null
setGenerateLoading(null)
setGenerateResult({ type, canvasId: poll.canvasId, noteId: poll.noteId })
if (type === 'slides' && poll.canvasId) {
toast.success(t('ai.generate.slidesReady') || 'Présentation générée !', {
id: toastId, duration: 10000,
description: t('ai.generate.toastSuccessSlides') || 'Cliquez sur Télécharger dans le panneau IA.',
action: { label: t('ai.generate.downloadPptx') || 'Télécharger', onClick: () => window.open(`/api/canvas/download?id=${poll.canvasId}`, '_blank') },
})
} else if (type === 'diagram' && poll.canvasId) {
toast.success(t('ai.generate.diagramReady') || 'Diagramme généré !', {
id: toastId, duration: 10000,
description: t('ai.generate.toastSuccessDiagram') || 'Votre diagramme est disponible dans le Lab.',
action: { label: t('ai.generate.openDiagram') || 'Ouvrir', onClick: () => { window.location.href = `/lab?id=${poll.canvasId}` } },
})
} else {
toast.success(type === 'slides'
? (t('ai.generate.slidesReady') || 'Présentation générée !')
: (t('ai.generate.diagramReady') || 'Diagramme généré !'),
{ id: toastId }
)
}
} else if (poll.status === 'failure') {
clearInterval(generatePollRef.current!)
generatePollRef.current = null
setGenerateLoading(null)
toast.error(poll.error || t('ai.generate.error') || 'Erreur lors de la génération', { id: toastId })
}
// If still 'running', do nothing — next poll will check again
} catch {
// Network error during poll — keep polling
}
}, 3000)
} catch {
toast.error(t('ai.generate.error') || 'Erreur lors de la génération', { id: toastId })
setGenerateLoading(null)
}
}
// Cleanup polling on unmount
useEffect(() => {
return () => {
if (generatePollRef.current) clearInterval(generatePollRef.current)
}
}, [])
// ── Resource tab handlers ────────────────────────────────────────────────────
const handleScrapeUrl = async () => {
@@ -583,7 +699,7 @@ export function ContextualAIChat({
<X className="h-3 w-3" />
</button>
</div>
<div className="px-3 py-2 max-h-36 overflow-y-auto text-sm">
<div className="px-3 py-2 max-h-64 overflow-y-auto text-sm">
<MarkdownContent content={resourcePreview.text} />
</div>
<div className="flex gap-2 px-3 py-2 border-t border-primary/20">
@@ -841,7 +957,7 @@ export function ContextualAIChat({
<button
key={l}
className={`text-xs px-2.5 py-1 rounded-lg border transition-colors ${translateTarget === l ? 'bg-primary text-primary-foreground border-primary' : 'bg-card border-border hover:bg-accent'}`}
onClick={() => setTranslateTarget(l)}
onClick={() => { setTranslateTarget(l); setCustomLangInput(l); }}
>{l}</button>
))}
</div>
@@ -849,8 +965,8 @@ export function ContextualAIChat({
className="text-xs px-3 py-1.5 rounded-lg border border-border bg-card outline-none focus:border-primary w-full"
placeholder={t('ai.action.customLang') || 'Autre langue...'}
value={customLangInput}
onChange={e => setCustomLangInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && customLangInput.trim()) { setTranslateTarget(customLangInput.trim()); setCustomLangInput('') } }}
onChange={e => { const val = e.target.value; setCustomLangInput(val); setTranslateTarget(val); }}
onKeyDown={e => { if (e.key === 'Enter' && translateTarget.trim()) { handleAction(action, translateTarget.trim()); } }}
/>
<button
disabled={!!actionLoading || !translateTarget}
@@ -883,6 +999,105 @@ export function ContextualAIChat({
})
)}
{/* ── Generate slides / diagram ─────────────────────── */}
{noteId && (
<div className="mt-1">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-2.5 mt-4">
{t('ai.generate.sectionLabel') || 'Générer depuis cette note'}
</p>
{/* ── Slides ── */}
<div className="mb-3 rounded-xl border-2 border-purple-200 dark:border-purple-800 bg-gradient-to-r from-purple-50 to-pink-50 dark:from-purple-950/40 dark:to-pink-950/40 overflow-hidden">
{/* Options */}
<div className="grid grid-cols-2 gap-1.5 px-3 pt-2.5 pb-1">
<div>
<label className="text-[9px] font-semibold uppercase tracking-wider text-purple-500 dark:text-purple-400 block mb-0.5">{t('ai.generate.theme') || 'Thème'}</label>
<select value={slideTheme} onChange={e => setSlideTheme(e.target.value)} disabled={generateLoading === 'slides'}
className="w-full text-[11px] rounded-md border border-purple-200 dark:border-purple-700 bg-white/70 dark:bg-purple-950/60 text-purple-800 dark:text-purple-200 px-1.5 py-1 focus:outline-none">
{[['vibrant_tech','Vibrant Tech'],['business_authority','Business'],['soft_creative','Créatif'],['modern_wellness','Wellness'],['tech_night','Dark Tech'],['education_charts','Éducation'],['elegant_fashion','Élégant'],['art_food','Art & Food'],['nature_outdoors','Nature'],['vintage_academic','Académique'],['platinum_white_gold','Premium']].map(([v,l]) =>
<option key={v} value={v}>{l}</option>
)}
</select>
</div>
<div>
<label className="text-[9px] font-semibold uppercase tracking-wider text-purple-500 dark:text-purple-400 block mb-0.5">{t('ai.generate.style') || 'Style'}</label>
<select value={slideStyle} onChange={e => setSlideStyle(e.target.value)} disabled={generateLoading === 'slides'}
className="w-full text-[11px] rounded-md border border-purple-200 dark:border-purple-700 bg-white/70 dark:bg-purple-950/60 text-purple-800 dark:text-purple-200 px-1.5 py-1 focus:outline-none">
<option value="soft">{t('ai.generate.styleSoft') || 'Soft'}</option>
<option value="sharp">{t('ai.generate.styleSharp') || 'Sharp'}</option>
<option value="rounded">{t('ai.generate.styleRounded') || 'Arrondi'}</option>
<option value="pill">{t('ai.generate.stylePill') || 'Pill'}</option>
</select>
</div>
</div>
{/* Button */}
<button onClick={() => handleGenerate('slides')} disabled={!!generateLoading || !!actionLoading}
className="w-full flex items-center gap-3 px-3 py-2.5 text-sm font-medium text-purple-700 dark:text-purple-300 hover:bg-purple-100/60 dark:hover:bg-purple-900/30 transition-all text-left disabled:opacity-60">
{generateLoading === 'slides' ? <Loader2 className="h-4 w-4 shrink-0 animate-spin" /> : <Presentation className="h-4 w-4 shrink-0" />}
<div className="flex flex-col flex-1 min-w-0">
<span>{t('ai.generate.slides') || 'Générer une présentation'}</span>
{generateLoading === 'slides' && <span className="text-[10px] text-purple-500">{t('ai.generate.loading') || 'Génération en cours…'}</span>}
</div>
</button>
</div>
{/* Slides result */}
{generateResult?.type === 'slides' && generateResult.canvasId && (
<a href={`/api/canvas/download?id=${generateResult.canvasId}`} target="_blank" rel="noopener noreferrer"
className="w-full flex items-center gap-2 rounded-xl border border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/30 px-4 py-2.5 text-sm font-medium text-green-700 dark:text-green-300 hover:bg-green-100 dark:hover:bg-green-900/40 transition-all mb-2">
<Download className="h-4 w-4 shrink-0" />
{t('ai.generate.downloadPptx') || 'Télécharger le .pptx'}
<ExternalLink className="h-3 w-3 ml-auto opacity-60" />
</a>
)}
{/* ── Diagram ── */}
<div className="rounded-xl border-2 border-cyan-200 dark:border-cyan-800 bg-gradient-to-r from-cyan-50 to-blue-50 dark:from-cyan-950/40 dark:to-blue-950/40 overflow-hidden">
<div className="grid grid-cols-2 gap-1.5 px-3 pt-2.5 pb-1">
<div>
<label className="text-[9px] font-semibold uppercase tracking-wider text-cyan-500 dark:text-cyan-400 block mb-0.5">{t('ai.generate.diagramType') || 'Type'}</label>
<select value={diagramType} onChange={e => setDiagramType(e.target.value)} disabled={generateLoading === 'diagram'}
className="w-full text-[11px] rounded-md border border-cyan-200 dark:border-cyan-700 bg-white/70 dark:bg-cyan-950/60 text-cyan-800 dark:text-cyan-200 px-1.5 py-1 focus:outline-none">
<option value="auto">{t('ai.generate.typeAuto') || 'Auto'}</option>
<option value="flowchart">Flowchart</option>
<option value="mindmap">Mind map</option>
<option value="timeline">Timeline</option>
<option value="org-chart">Org chart</option>
<option value="architecture-cloud">Architecture</option>
<option value="process-map">Process map</option>
</select>
</div>
<div>
<label className="text-[9px] font-semibold uppercase tracking-wider text-cyan-500 dark:text-cyan-400 block mb-0.5">{t('ai.generate.style') || 'Style'}</label>
<select value={diagramStyle} onChange={e => setDiagramStyle(e.target.value)} disabled={generateLoading === 'diagram'}
className="w-full text-[11px] rounded-md border border-cyan-200 dark:border-cyan-700 bg-white/70 dark:bg-cyan-950/60 text-cyan-800 dark:text-cyan-200 px-1.5 py-1 focus:outline-none">
<option value="default">{t('ai.generate.styleSketchy') || 'Sketchy'}</option>
<option value="austere">{t('ai.generate.styleAustere') || 'Austère'}</option>
<option value="sketch-plus">Sketch+</option>
</select>
</div>
</div>
<button onClick={() => handleGenerate('diagram')} disabled={!!generateLoading || !!actionLoading}
className="w-full flex items-center gap-3 px-3 py-2.5 text-sm font-medium text-cyan-700 dark:text-cyan-300 hover:bg-cyan-100/60 dark:hover:bg-cyan-900/30 transition-all text-left disabled:opacity-60">
{generateLoading === 'diagram' ? <Loader2 className="h-4 w-4 shrink-0 animate-spin" /> : <PenTool className="h-4 w-4 shrink-0" />}
<div className="flex flex-col flex-1 min-w-0">
<span>{t('ai.generate.diagram') || 'Générer un diagramme'}</span>
{generateLoading === 'diagram' && <span className="text-[10px] text-cyan-500">{t('ai.generate.loading') || 'Génération en cours…'}</span>}
</div>
</button>
</div>
{/* Diagram result */}
{generateResult?.type === 'diagram' && generateResult.canvasId && (
<a href={`/lab?id=${generateResult.canvasId}`}
className="mt-2 w-full flex items-center gap-2 rounded-xl border border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/30 px-4 py-2.5 text-sm font-medium text-green-700 dark:text-green-300 hover:bg-green-100 dark:hover:bg-green-900/40 transition-all">
<ExternalLink className="h-4 w-4 shrink-0" />
{t('ai.generate.openDiagram') || 'Ouvrir dans le Lab'}
</a>
)}
</div>
)}
{/* Undo last action shortcut */}
{lastActionApplied && onUndoLastAction && (
<button

View File

@@ -1,9 +0,0 @@
'use client'
export function DebugTheme({ theme }: { theme: string }) {
return (
<div className="fixed bottom-4 left-4 z-50 bg-black text-white p-2 rounded text-xs opacity-80 pointer-events-none">
Debug Theme: {theme}
</div>
)
}

View File

@@ -14,7 +14,9 @@ export function ErrorReporter() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
keepalive: true,
}).catch(() => {})
}).catch(() => {
console.error('[ErrorReporter] Failed to report client error (endpoint removed)')
})
}
function onError(event: ErrorEvent) {

View File

@@ -237,13 +237,9 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
}
let allNotes = search
? await searchNotes(search, semanticMode, notebook || undefined)
: await getAllNotes()
: await getAllNotes(false, notebook || undefined)
// Filtre notebook côté client
// Shared notes appear ONLY in inbox (general notes), not in notebooks
if (notebook) {
allNotes = allNotes.filter((note: any) => note.notebookId === notebook && !note._isShared)
} else {
if (!notebook && !search) {
allNotes = allNotes.filter((note: any) => !note.notebookId || note._isShared)
}

View File

@@ -1,13 +1,14 @@
'use client'
import dynamic from 'next/dynamic'
import { useState, useEffect, useRef } from 'react'
import { useState, useEffect, useRef, useMemo } from 'react'
import { toast } from 'sonner'
import { Download, Presentation } from 'lucide-react'
import type { ExcalidrawElement } from '@excalidraw/excalidraw/element/types'
import type { AppState, BinaryFiles } from '@excalidraw/excalidraw/types'
import type { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types'
import '@excalidraw/excalidraw/index.css'
// Dynamic import with SSR disabled is REQUIRED for Excalidraw due to window dependencies
const Excalidraw = dynamic(
async () => (await import('@excalidraw/excalidraw')).Excalidraw,
{ ssr: false }
@@ -19,34 +20,106 @@ interface CanvasBoardProps {
name: string
}
type PptxPayload = { type: string; filename: string; base64: string; slideCount?: number; theme?: string }
function parseCanvasScene(initialData?: string): {
pptx: PptxPayload | null
elements: readonly ExcalidrawElement[]
files: BinaryFiles
} {
if (!initialData) {
return { pptx: null, elements: [], files: {} }
}
try {
const parsed = JSON.parse(initialData)
if (parsed && parsed.type === 'pptx' && parsed.base64) {
return { pptx: parsed as PptxPayload, elements: [], files: {} }
}
if (parsed && Array.isArray(parsed)) {
return { pptx: null, elements: parsed as ExcalidrawElement[], files: {} }
}
if (parsed && parsed.elements) {
const files: BinaryFiles =
parsed.files && typeof parsed.files === 'object' ? parsed.files : {}
return { pptx: null, elements: parsed.elements as readonly ExcalidrawElement[], files }
}
} catch (e) {
console.error('[CanvasBoard] Failed to parse canvas data:', e)
}
return { pptx: null, elements: [], files: {} }
}
function PptxViewer({ data, name }: { data: PptxPayload; name: string }) {
const handleDownload = () => {
try {
const byteCharacters = atob(data.base64)
const byteNumbers = new Array(byteCharacters.length)
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
const blob = new Blob([byteArray], {
type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = data.filename || `${name}.pptx`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch (e) {
console.error('[PptxViewer] Download failed:', e)
toast.error('Failed to download presentation')
}
}
return (
<div className="absolute inset-0 h-full w-full flex flex-col items-center justify-center bg-white dark:bg-zinc-900">
<div className="flex flex-col items-center gap-6 p-8">
<div className="p-4 rounded-2xl bg-primary/10">
<Presentation className="w-16 h-16 text-primary" />
</div>
<div className="text-center">
<h2 className="text-xl font-semibold text-foreground mb-1">{name}</h2>
<p className="text-sm text-muted-foreground">
PowerPoint Presentation
{data.slideCount ? `${data.slideCount} slides` : ''}
{data.theme ? `${data.theme} theme` : ''}
</p>
</div>
<button
onClick={handleDownload}
className="flex items-center gap-2 px-6 py-3 bg-primary text-primary-foreground rounded-xl hover:bg-primary/90 transition-colors font-medium shadow-sm"
>
<Download className="w-5 h-5" />
Download .pptx
</button>
<p className="text-xs text-muted-foreground max-w-sm text-center">
This file can be opened in Microsoft PowerPoint, Google Slides, or Keynote.
</p>
</div>
</div>
)
}
export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
const [isDarkMode, setIsDarkMode] = useState(false)
const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'error'>('saved')
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const excalidrawAPIRef = useRef<ExcalidrawImperativeAPI | null>(null)
const filesRef = useRef<BinaryFiles>({})
// Parse initial state safely (ONLY ON MOUNT to prevent Next.js revalidation infinite loops)
const [elements] = useState<readonly ExcalidrawElement[]>(() => {
if (initialData) {
try {
const parsed = JSON.parse(initialData)
if (parsed && Array.isArray(parsed)) {
return parsed
} else if (parsed && parsed.elements) {
// Restore binary files if present
if (parsed.files && typeof parsed.files === 'object') {
filesRef.current = parsed.files
}
return parsed.elements
}
} catch (e) {
console.error("[CanvasBoard] Failed to parse initial Excalidraw data:", e)
}
}
return []
})
const scene = useMemo(
() => parseCanvasScene(initialData),
[canvasId, initialData]
)
useEffect(() => {
filesRef.current = scene.files
}, [scene])
// Detect dark mode from html class
useEffect(() => {
const checkDarkMode = () => {
const isDark = document.documentElement.classList.contains('dark')
@@ -55,7 +128,6 @@ export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
checkDarkMode()
// Observer for theme changes
const observer = new MutationObserver(checkDarkMode)
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
@@ -64,10 +136,9 @@ export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
const handleChange = (
excalidrawElements: readonly ExcalidrawElement[],
appState: AppState,
_appState: AppState,
files: BinaryFiles
) => {
// Keep files ref up to date so we can include them in the save payload
if (files) filesRef.current = files
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
@@ -75,7 +146,6 @@ export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
setSaveStatus('saving')
saveTimeoutRef.current = setTimeout(async () => {
try {
// Save both elements and binary files so images persist across page changes
const snapshot = JSON.stringify({ elements: excalidrawElements, files: filesRef.current })
await fetch('/api/canvas', {
method: 'POST',
@@ -84,26 +154,35 @@ export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
})
setSaveStatus('saved')
} catch (e) {
console.error("[CanvasBoard] Save failure:", e)
console.error('[CanvasBoard] Save failure:', e)
setSaveStatus('error')
}
}, 2000)
}
if (scene.pptx) {
return <PptxViewer data={scene.pptx} name={name} />
}
const excalKey = canvasId ? `excal-${canvasId}` : 'excal-new'
return (
<div className="absolute inset-0 h-full w-full bg-slate-50 dark:bg-[#121212]" dir="ltr">
<Excalidraw
initialData={{ elements, files: filesRef.current }}
theme={isDarkMode ? "dark" : "light"}
key={excalKey}
excalidrawAPI={(api) => { excalidrawAPIRef.current = api }}
initialData={{ elements: scene.elements, files: scene.files }}
theme={isDarkMode ? 'dark' : 'light'}
onChange={handleChange}
libraryReturnUrl={typeof window !== 'undefined' ? window.location.origin + window.location.pathname + window.location.search : undefined}
UIOptions={{
canvasActions: {
loadScene: false,
loadScene: true,
saveToActiveFile: false,
clearCanvas: true
clearCanvas: true,
}
}}
validateEmbeddable={false}
renderTopRightUI={() => null}
/>
</div>
)

View File

@@ -185,7 +185,16 @@ export function MasonryGrid({
if (notes !== prevNotesRef.current) {
const localSizeMap = new Map(localNotes.map(n => [n.id, n.size]));
const localOrderMap = new Map(localNotes.map((n, i) => [n.id, i]));
const newLocalNotes = notes.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }));
newLocalNotes.sort((a, b) => {
const oA = localOrderMap.get(a.id)
const oB = localOrderMap.get(b.id)
if (oA !== undefined && oB !== undefined) return oA - oB
if (oA !== undefined) return -1
if (oB !== undefined) return 1
return 0
})
setLocalNotes(newLocalNotes);
prevNotesRef.current = notes;
}
@@ -239,16 +248,20 @@ export function MasonryGrid({
if (!over || active.id === over.id) return;
const reordered = arrayMove(
localNotesRef.current,
localNotesRef.current.findIndex(n => n.id === active.id),
localNotesRef.current.findIndex(n => n.id === over.id),
);
const current = localNotesRef.current
const activeIdx = current.findIndex(n => n.id === active.id)
const overIdx = current.findIndex(n => n.id === over.id)
if (activeIdx === -1 || overIdx === -1) return
const activeNote = current[activeIdx]
const overNote = current[overIdx]
if (activeNote.isPinned !== overNote.isPinned) return
const reordered = arrayMove(current, activeIdx, overIdx);
if (reordered.length === 0) return;
setLocalNotes(reordered);
// Persist order outside of setState to avoid "setState in render" warning
const ids = reordered.map(n => n.id);
updateFullOrderWithoutRevalidation(ids).catch(err => {
console.error('Failed to persist order:', err);

View File

@@ -1,7 +1,6 @@
'use client'
import { useState, useTransition } from 'react'
import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
@@ -117,59 +116,71 @@ export function McpSettingsPanel({ initialKeys, serverStatus }: McpSettingsPanel
}
return (
<div className="space-y-6">
<div className="columns-1 lg:columns-2 gap-6 space-y-6">
{/* Section 1: What is MCP */}
<Card className="p-6">
<div className="flex items-start gap-3">
<Info className="h-5 w-5 text-blue-500 mt-0.5 shrink-0" />
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden break-inside-avoid">
<div className="flex items-center gap-3 p-6 border-b border-border">
<div className="w-10 h-10 rounded-full bg-blue-500/10 flex items-center justify-center text-blue-500 shrink-0">
<Info className="h-5 w-5" />
</div>
<div>
<h2 className="text-lg font-semibold">{t('mcpSettings.whatIsMcp.title')}</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{t('mcpSettings.whatIsMcp.description')}
</p>
<a
href="https://modelcontextprotocol.io"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline mt-2"
>
{t('mcpSettings.whatIsMcp.learnMore')}
<ExternalLink className="h-3 w-3" />
</a>
<h2 className="font-semibold text-foreground">{t('mcpSettings.whatIsMcp.title')}</h2>
</div>
</div>
</Card>
<div className="p-6">
<p className="text-sm text-muted-foreground mt-1">
{t('mcpSettings.whatIsMcp.description')}
</p>
<a
href="https://modelcontextprotocol.io"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:underline mt-4"
>
{t('mcpSettings.whatIsMcp.learnMore')}
<ExternalLink className="h-3 w-3" />
</a>
</div>
</div>
{/* Section 2: Server Status */}
<Card className="p-6">
<div className="flex items-center gap-3 mb-4">
<Server className="h-5 w-5 shrink-0" />
<h2 className="text-lg font-semibold">{t('mcpSettings.serverStatus.title')}</h2>
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<span className="text-gray-500">{t('mcpSettings.serverStatus.mode')}:</span>
<Badge variant="secondary">{serverStatus.mode.toUpperCase()}</Badge>
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden break-inside-avoid">
<div className="flex items-center gap-3 p-6 border-b border-border">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
<Server className="h-5 w-5" />
</div>
<div>
<h2 className="font-semibold text-foreground">{t('mcpSettings.serverStatus.title')}</h2>
</div>
{serverStatus.mode === 'sse' && serverStatus.url && (
<div className="flex items-center gap-2">
<span className="text-gray-500">{t('mcpSettings.serverStatus.url')}:</span>
<code className="text-xs bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">
{serverStatus.url}
</code>
</div>
)}
</div>
</Card>
<div className="p-6">
<div className="space-y-4 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{t('mcpSettings.serverStatus.mode')}</span>
<Badge variant="secondary">{serverStatus.mode.toUpperCase()}</Badge>
</div>
{serverStatus.mode === 'sse' && serverStatus.url && (
<div className="space-y-1.5">
<span className="text-muted-foreground block">{t('mcpSettings.serverStatus.url')}</span>
<code className="text-xs bg-muted p-2 rounded block break-all font-mono">
{serverStatus.url}
</code>
</div>
)}
</div>
</div>
</div>
{/* Section 3: API Keys */}
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden break-inside-avoid">
<div className="flex items-center justify-between p-6 border-b border-border">
<div className="flex items-center gap-3">
<Key className="h-5 w-5 shrink-0" />
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
<Key className="h-5 w-5" />
</div>
<div>
<h2 className="text-lg font-semibold">{t('mcpSettings.apiKeys.title')}</h2>
<p className="text-sm text-gray-500">
<h2 className="font-semibold text-foreground">{t('mcpSettings.apiKeys.title')}</h2>
<p className="text-sm text-muted-foreground">
{t('mcpSettings.apiKeys.description')}
</p>
</div>
@@ -188,28 +199,32 @@ export function McpSettingsPanel({ initialKeys, serverStatus }: McpSettingsPanel
</Dialog>
</div>
{keys.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Key className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>{t('mcpSettings.apiKeys.empty')}</p>
</div>
) : (
<div className="space-y-3">
{keys.map(k => (
<KeyCard
key={k.shortId}
keyInfo={k}
onRevoke={handleRevoke}
onDelete={handleDelete}
isPending={isPending}
/>
))}
</div>
)}
</Card>
<div className="p-6">
{keys.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Key className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>{t('mcpSettings.apiKeys.empty')}</p>
</div>
) : (
<div className="space-y-3">
{keys.map(k => (
<KeyCard
key={k.shortId}
keyInfo={k}
onRevoke={handleRevoke}
onDelete={handleDelete}
isPending={isPending}
/>
))}
</div>
)}
</div>
</div>
{/* Section 4: Configuration Instructions */}
<ConfigInstructions serverStatus={serverStatus} />
<div className="break-inside-avoid">
<ConfigInstructions serverStatus={serverStatus} />
</div>
{/* Raw Key Display Dialog */}
<Dialog open={!!showRawKey} onOpenChange={(open) => { if (!open) setShowRawKey(null) }}>
@@ -222,9 +237,9 @@ export function McpSettingsPanel({ initialKeys, serverStatus }: McpSettingsPanel
</DialogHeader>
<div className="space-y-3">
<div>
<Label className="text-xs text-gray-500">{rawKeyName}</Label>
<Label className="text-xs text-muted-foreground">{rawKeyName}</Label>
<div className="flex items-center gap-2 mt-1">
<code className="flex-1 text-xs bg-gray-100 dark:bg-gray-800 p-3 rounded break-all font-mono">
<code className="flex-1 text-xs bg-muted p-3 rounded break-all font-mono">
{showRawKey}
</code>
<Button
@@ -330,7 +345,7 @@ function KeyCard({
}
return (
<div className="flex items-center justify-between p-4 rounded-lg border bg-gray-50 dark:bg-gray-900">
<div className="flex items-center justify-between p-4 rounded-lg border bg-muted/50">
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{keyInfo.name}</span>
@@ -340,7 +355,7 @@ function KeyCard({
: t('mcpSettings.apiKeys.revoked')}
</Badge>
</div>
<div className="flex gap-4 text-xs text-gray-500">
<div className="flex gap-4 text-xs text-muted-foreground">
<span>
{t('mcpSettings.apiKeys.createdAt')}: {formatDate(keyInfo.createdAt)}
</span>
@@ -436,23 +451,25 @@ Transport: Streamable HTTP`,
]
return (
<Card className="p-6">
<div className="flex items-center gap-3 mb-4">
<ExternalLink className="h-5 w-5 shrink-0" />
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
<div className="flex items-center gap-3 p-6 border-b border-border">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
<ExternalLink className="h-5 w-5" />
</div>
<div>
<h2 className="text-lg font-semibold">
<h2 className="font-semibold text-foreground">
{t('mcpSettings.configInstructions.title')}
</h2>
<p className="text-sm text-gray-500">
<p className="text-sm text-muted-foreground">
{t('mcpSettings.configInstructions.description')}
</p>
</div>
</div>
<div className="space-y-2">
<div className="p-6 space-y-2">
{configs.map(cfg => (
<div key={cfg.id} className="border rounded-lg overflow-hidden">
<button
className="w-full flex items-center justify-between px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-900 transition-colors"
className="w-full flex items-center justify-between px-4 py-3 text-left hover:bg-muted transition-colors"
onClick={() => setExpanded(expanded === cfg.id ? null : cfg.id)}
>
<span className="font-medium text-sm">{cfg.title}</span>
@@ -464,8 +481,8 @@ Transport: Streamable HTTP`,
</button>
{expanded === cfg.id && (
<div className="px-4 pb-4">
<p className="text-sm text-gray-500 mb-2">{cfg.description}</p>
<pre className="text-xs bg-gray-100 dark:bg-gray-800 p-3 rounded overflow-x-auto">
<p className="text-sm text-muted-foreground mb-2">{cfg.description}</p>
<pre className="text-xs bg-muted p-3 rounded overflow-x-auto">
<code>{cfg.snippet}</code>
</pre>
</div>
@@ -473,6 +490,6 @@ Transport: Streamable HTTP`,
</div>
))}
</div>
</Card>
</div>
)
}

View File

@@ -21,7 +21,8 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Pin, Bell, GripVertical, X, Link2, FolderOpen, StickyNote, LucideIcon, Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2, LogOut, Trash2, AlignLeft, FileCode2, PenLine, ListChecks } from 'lucide-react'
import { useState, useEffect, useTransition, useOptimistic, memo } from 'react'
import { useState, useEffect, useTransition, useOptimistic, memo, useMemo } from 'react'
import dynamic from 'next/dynamic'
import { useSession } from 'next-auth/react'
import { useRouter, useSearchParams } from 'next/navigation'
import { deleteNote, toggleArchive, togglePin, updateColor, updateNote, updateSize, getNoteAllUsers, leaveSharedNote, removeFusedBadge, createNote, restoreNote, permanentDeleteNote } from '@/app/actions/notes'
@@ -42,17 +43,20 @@ import { ar } from 'date-fns/locale/ar'
import { hi } from 'date-fns/locale/hi'
import { nl } from 'date-fns/locale/nl'
import { pl } from 'date-fns/locale/pl'
import { MarkdownContent } from './markdown-content'
import { LabelBadge } from './label-badge'
import { NoteImages } from './note-images'
import { NoteChecklist } from './note-checklist'
import { NoteActions } from './note-actions'
import { CollaboratorDialog } from './collaborator-dialog'
import { CollaboratorAvatars } from './collaborator-avatars'
import { ConnectionsBadge } from './connections-badge'
import { ConnectionsOverlay } from './connections-overlay'
import { ComparisonModal } from './comparison-modal'
import { FusionModal } from './fusion-modal'
const MarkdownContent = dynamic(() => import('./markdown-content').then(m => ({ default: m.MarkdownContent })), {
loading: () => <div className="text-sm text-muted-foreground animate-pulse"></div>,
})
const CollaboratorDialog = dynamic(() => import('./collaborator-dialog').then(m => ({ default: m.CollaboratorDialog })), { ssr: false })
const ConnectionsOverlay = dynamic(() => import('./connections-overlay').then(m => ({ default: m.ConnectionsOverlay })), { ssr: false })
const ComparisonModal = dynamic(() => import('./comparison-modal').then(m => ({ default: m.ComparisonModal })), { ssr: false })
const FusionModal = dynamic(() => import('./fusion-modal').then(m => ({ default: m.FusionModal })), { ssr: false })
import { useConnectionsCompare } from '@/hooks/use-connections-compare'
import { useLabels } from '@/context/LabelContext'
import { useNoteRefresh } from '@/context/NoteRefreshContext'
@@ -186,6 +190,14 @@ export const NoteCard = memo(function NoteCard({
const [showNotebookMenu, setShowNotebookMenu] = useState(false)
const [reminderDate, setReminderDate] = useState<Date | null>(note.reminder ? new Date(note.reminder) : null)
const sanitizedHtml = useMemo(() => {
if (note.type !== 'richtext' || !note.content) return ''
if (typeof window !== 'undefined') {
return require('isomorphic-dompurify').sanitize(note.content)
}
return note.content
}, [note.type, note.content])
const handleUpdateReminder = async (noteId: string, reminder: Date | null) => {
startTransition(async () => {
try {
@@ -257,6 +269,8 @@ export const NoteCard = memo(function NoteCard({
return
}
if (!isSharedNote) return
let isMounted = true
const loadCollaborators = async () => {
@@ -270,7 +284,6 @@ export const NoteCard = memo(function NoteCard({
}
}
} catch (error) {
console.error('Failed to load collaborators:', error)
if (isMounted) {
setCollaborators([])
}
@@ -633,7 +646,7 @@ export const NoteCard = memo(function NoteCard({
onToggleItem={handleCheckItem}
/>
) : note.type === 'richtext' ? (
<div className="text-sm text-foreground line-clamp-10 rt-preview" dangerouslySetInnerHTML={{ __html: note.content || '' }} />
<div className="text-sm text-foreground line-clamp-10 rt-preview" dangerouslySetInnerHTML={{ __html: sanitizedHtml }} />
) : (
<div className="text-sm text-foreground line-clamp-10">
<MarkdownContent

View File

@@ -646,7 +646,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
<Dialog open={true} onOpenChange={onClose}>
<DialogContent
className={cn(
'!max-w-[min(95vw,1600px)] max-h-[90vh] overflow-hidden p-0 flex flex-row items-stretch',
'!max-w-[min(95vw,1600px)] max-h-[90vh] overflow-hidden p-0 flex flex-row items-stretch rounded-lg',
colorClasses.bg
)}
>
@@ -875,18 +875,18 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
{!readOnly && (
<>
{/* Reminder */}
<Button variant="ghost" size="icon" className={cn('h-8 w-8', currentReminder && 'text-primary')}
<Button variant="ghost" size="icon" className={cn('h-8 w-8 rounded-md', currentReminder && 'text-primary')}
onClick={() => setShowReminderDialog(true)} title={t('notes.setReminder')}>
<Bell className="h-4 w-4" />
</Button>
{/* Add Image */}
<Button variant="ghost" size="icon" className="h-8 w-8"
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
onClick={() => fileInputRef.current?.click()} title={t('notes.addImage')}>
<ImageIcon className="h-4 w-4" />
</Button>
{/* Add Link */}
<Button variant="ghost" size="icon" className="h-8 w-8"
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
onClick={() => setShowLinkDialog(true)} title={t('notes.addLink')}>
<LinkIcon className="h-4 w-4" />
</Button>
@@ -894,7 +894,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
<NoteTypeSelector value={noteType} onChange={(newType) => { setNoteType(newType); if (newType !== 'markdown') setShowMarkdownPreview(false) }} />
{noteType === 'markdown' && (
<Button variant="ghost" size="icon" className="h-8 w-8"
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
onClick={() => setShowMarkdownPreview(!showMarkdownPreview)}
title={showMarkdownPreview ? t('general.edit') : t('notes.preview')}>
<Eye className="h-4 w-4" />
@@ -904,7 +904,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
{/* AI Copilot */}
{noteType !== 'checklist' && aiAssistantEnabled && (
<Button variant="ghost" size="sm"
className={cn('h-8 gap-1.5 px-2 text-xs font-medium transition-colors', aiOpen && 'bg-primary/10 text-primary')}
className={cn('h-8 gap-1.5 px-2 text-xs font-medium transition-all duration-200 rounded-md', aiOpen && 'bg-primary/10 text-primary')}
onClick={() => setAiOpen(!aiOpen)} title="IA Note">
<Sparkles className="h-3.5 w-3.5" />
<span className="hidden sm:inline">IA Note</span>
@@ -914,7 +914,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
{/* Size Selector */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" title={t('notes.changeSize')}>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md" title={t('notes.changeSize')}>
<Maximize2 className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
@@ -934,7 +934,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
{/* Color Picker */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" title={t('notes.changeColor')}>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md" title={t('notes.changeColor')}>
<Palette className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
@@ -1023,6 +1023,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
noteTitle={title}
noteContent={content}
noteImages={allImages}
noteId={note.id}
onApplyToNote={(newContent) => {
setPreviousContentForCopilot(content)
setContent(newContent)

View File

@@ -81,7 +81,7 @@ function VersionPreview({ entry, language }: { entry: NoteHistoryEntry; language
<MarkdownContent content={entry.content || ''} />
</div>
) : isRt ? (
<div dangerouslySetInnerHTML={{ __html: entry.content || '' }} />
<div dangerouslySetInnerHTML={{ __html: require('isomorphic-dompurify').sanitize(entry.content || '') }} />
) : (
<pre className="whitespace-pre-wrap font-sans">{entry.content || ''}</pre>
)}

View File

@@ -18,6 +18,7 @@ export function NoteImages({ images: rawImages, title }: NoteImagesProps) {
<img
src={images[0]}
alt=""
loading="lazy"
className="w-full h-auto rounded-lg"
/>
) : images.length === 2 ? (
@@ -27,6 +28,7 @@ export function NoteImages({ images: rawImages, title }: NoteImagesProps) {
key={idx}
src={img}
alt=""
loading="lazy"
className="w-full h-auto rounded-lg"
/>
))}
@@ -36,6 +38,7 @@ export function NoteImages({ images: rawImages, title }: NoteImagesProps) {
<img
src={images[0]}
alt=""
loading="lazy"
className="col-span-2 w-full h-auto rounded-lg"
/>
{images.slice(1).map((img, idx) => (
@@ -43,6 +46,7 @@ export function NoteImages({ images: rawImages, title }: NoteImagesProps) {
key={idx}
src={img}
alt=""
loading="lazy"
className="w-full h-auto rounded-lg"
/>
))}
@@ -54,6 +58,7 @@ export function NoteImages({ images: rawImages, title }: NoteImagesProps) {
key={idx}
src={img}
alt=""
loading="lazy"
className="w-full h-auto rounded-lg"
/>
))}

View File

@@ -919,6 +919,7 @@ export function NoteInlineEditor({
noteTitle={title}
noteContent={content}
noteImages={allImages}
noteId={note.id}
onApplyToNote={(newContent) => {
setPreviousContent(content)
changeContent(newContent)

View File

@@ -1,10 +1,11 @@
'use client'
import { useState, useCallback } from 'react'
import { useState, useCallback, useRef } from 'react'
import Link from 'next/link'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { cn } from '@/lib/utils'
import { StickyNote, Plus, Tag, Folder, ChevronDown, ChevronRight } from 'lucide-react'
import { StickyNote, Plus, Tag, Folder, ChevronDown, ChevronRight, GripVertical } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { useNotebooks } from '@/context/notebooks-context'
import { useNotebookDrag } from '@/context/notebook-drag-context'
import { Button } from '@/components/ui/button'
@@ -19,12 +20,22 @@ import { LabelManagementDialog } from '@/components/label-management-dialog'
import { Notebook } from '@/lib/types'
import { getNotebookIcon } from '@/lib/notebook-icon'
function NotebookName({ children }: { name: string; children: React.ReactNode }) {
return (
<span className="relative truncate min-w-0 group-hover:overflow-visible group-hover:text-nowrap">
<span className="group-hover:font-bold group-hover:relative group-hover:z-20 group-hover:inline-block group-hover:bg-white dark:group-hover:bg-[#1e2128] group-hover:pr-4 group-hover:shadow-[4px_0_12px_8px] group-hover:shadow-white dark:group-hover:shadow-[#1e2128]">
{children}
</span>
</span>
)
}
export function NotebooksList() {
const pathname = usePathname()
const searchParams = useSearchParams()
const router = useRouter()
const { t, language } = useLanguage()
const { notebooks, currentNotebook, deleteNotebook, moveNoteToNotebookOptimistic, isLoading } = useNotebooks()
const { notebooks, currentNotebook, deleteNotebook, moveNoteToNotebookOptimistic, updateNotebookOrderOptimistic, isLoading } = useNotebooks()
const { draggedNoteId, dragOverNotebookId, dragOver } = useNotebookDrag()
const { labels } = useLabels()
@@ -35,29 +46,77 @@ export function NotebooksList() {
const [expandedNotebook, setExpandedNotebook] = useState<string | null>(null)
const [labelsDialogOpen, setLabelsDialogOpen] = useState(false)
// ── Notebook reorder drag state ──
const draggingNbRef = useRef<string | null>(null)
const [draggingNbId, setDraggingNbId] = useState<string | null>(null)
const [overNbId, setOverNbId] = useState<string | null>(null)
const currentNotebookId = searchParams.get('notebook')
// Handle drop on a notebook
// Handle drop on a notebook (note-to-notebook OR notebook reorder)
const handleDrop = useCallback(async (e: React.DragEvent, notebookId: string | null) => {
e.preventDefault()
e.stopPropagation()
const sourceNbId = e.dataTransfer.getData('application/x-notebook')
const noteId = e.dataTransfer.getData('text/plain')
if (noteId) {
if (sourceNbId && notebookId && sourceNbId !== notebookId) {
// ── Reorder notebooks ──
const currentIds = notebooks.map((nb: Notebook) => nb.id)
const fromIdx = currentIds.indexOf(sourceNbId)
const toIdx = currentIds.indexOf(notebookId)
if (fromIdx !== -1 && toIdx !== -1) {
const newIds = [...currentIds]
newIds.splice(fromIdx, 1)
newIds.splice(toIdx, 0, sourceNbId)
await updateNotebookOrderOptimistic(newIds)
}
} else if (noteId) {
// ── Move note to notebook ──
await moveNoteToNotebookOptimistic(noteId, notebookId)
}
dragOver(null)
}, [moveNoteToNotebookOptimistic, dragOver])
draggingNbRef.current = null
setDraggingNbId(null)
setOverNbId(null)
}, [notebooks, moveNoteToNotebookOptimistic, updateNotebookOrderOptimistic, dragOver])
// Handle drag over a notebook
const handleDragOver = useCallback((e: React.DragEvent, notebookId: string | null) => {
e.preventDefault()
dragOver(notebookId)
if (draggingNbRef.current) {
// Notebook reorder mode — just track the insertion target
setOverNbId(notebookId)
} else {
// Note-to-notebook mode
dragOver(notebookId)
}
}, [dragOver])
// Handle drag leave
const handleDragLeave = useCallback(() => {
if (draggingNbRef.current) {
setOverNbId(null)
} else {
dragOver(null)
}
}, [dragOver])
// ── Notebook reorder handlers ──
const handleNotebookDragStart = useCallback((e: React.DragEvent, notebookId: string) => {
e.dataTransfer.setData('application/x-notebook', notebookId)
e.dataTransfer.effectAllowed = 'move'
draggingNbRef.current = notebookId
// Slight delay so the drag ghost renders before opacity change
setTimeout(() => setDraggingNbId(notebookId), 0)
}, [])
const handleNotebookDragEnd = useCallback(() => {
draggingNbRef.current = null
setDraggingNbId(null)
setOverNbId(null)
dragOver(null)
}, [dragOver])
@@ -131,7 +190,20 @@ export function NotebooksList() {
const NotebookIcon = getNotebookIcon(notebook.icon || 'folder')
return (
<div key={notebook.id} className="group flex flex-col">
<div
key={notebook.id}
className={cn(
"group flex flex-col transition-opacity duration-150",
draggingNbId === notebook.id && "opacity-40"
)}
draggable
onDragStart={(e) => handleNotebookDragStart(e, notebook.id)}
onDragEnd={handleNotebookDragEnd}
>
{/* Insertion indicator above this notebook when reordering */}
{overNbId === notebook.id && draggingNbId !== notebook.id && (
<div className="h-0.5 bg-primary/70 mx-4 rounded-full mb-0.5 transition-all" />
)}
{isActive ? (
// Active notebook with expanded labels
<div
@@ -152,12 +224,14 @@ export function NotebooksList() {
className={cn("w-5 h-5 flex-shrink-0 fill-current", !notebook.color && "text-primary dark:text-primary-foreground")}
style={notebook.color ? { color: notebook.color } : undefined}
/>
<span
className={cn("text-[15px] font-medium tracking-wide truncate min-w-0", !notebook.color && "text-primary dark:text-primary-foreground")}
style={notebook.color ? { color: notebook.color } : undefined}
>
{notebook.name}
</span>
<NotebookName name={notebook.name}>
<span
className={cn("text-[15px] font-medium tracking-wide", !notebook.color && "text-primary dark:text-primary-foreground")}
style={notebook.color ? { color: notebook.color } : undefined}
>
{notebook.name}
</span>
</NotebookName>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{/* Actions menu for active notebook */}
@@ -233,19 +307,31 @@ export function NotebooksList() {
isDragOver && "ring-2 ring-blue-500 ring-dashed rounded-e-full me-2"
)}
>
<button
onClick={() => handleSelectNotebook(notebook.id)}
className={cn(
"pointer-events-auto flex items-center gap-4 px-6 py-3 rounded-e-full me-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800/50 transition-colors w-full pe-24",
isDragOver && "opacity-50"
)}
>
<NotebookIcon className="w-5 h-5 flex-shrink-0" />
<span className="text-[15px] font-medium tracking-wide truncate min-w-0 text-start">{notebook.name}</span>
{(notebook as any).notesCount > 0 && (
<span className="text-xs text-gray-400 ms-2 flex-shrink-0">({new Intl.NumberFormat(language).format((notebook as any).notesCount)})</span>
)}
</button>
<TooltipProvider delayDuration={600}>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => handleSelectNotebook(notebook.id)}
className={cn(
"pointer-events-auto flex items-center gap-3 px-4 py-3 rounded-e-full me-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800/50 transition-colors w-full pe-14",
isDragOver && "opacity-50"
)}
>
<GripVertical className="w-3.5 h-3.5 flex-shrink-0 text-gray-300 dark:text-gray-600 opacity-0 group-hover:opacity-100 transition-opacity cursor-grab active:cursor-grabbing" />
<NotebookIcon className="w-5 h-5 flex-shrink-0" />
<span className="text-[15px] font-medium tracking-wide text-start truncate min-w-0 flex-1">
{notebook.name}
</span>
{(notebook as any).notesCount > 0 && (
<span className="text-xs text-gray-400 ms-auto flex-shrink-0">({new Intl.NumberFormat(language).format((notebook as any).notesCount)})</span>
)}
</button>
</TooltipTrigger>
<TooltipContent side="right" className="text-sm font-medium">
{notebook.name}
</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* Actions + expand on the right — always rendered, visible on hover */}
<div className="absolute end-3 top-1/2 -translate-y-1/2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity z-10">

View File

@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Bell, Check, X, Clock, AlertCircle, CheckCircle2, Circle, Share2, Bot, Trash2 } from 'lucide-react'
import { Bell, Check, X, Clock, AlertCircle, CheckCircle2, Circle, Share2, Bot, Trash2, Download, Pencil, Presentation } from 'lucide-react'
import {
Popover,
PopoverContent,
@@ -196,26 +196,39 @@ export function NotificationPanel() {
) : (
<div className="max-h-96 overflow-y-auto">
{/* App notifications (agents, system) */}
{appNotifications.map((notif) => (
{appNotifications.map((notif) => {
const isSlides = notif.type === 'agent_slides_ready'
const isCanvas = notif.type === 'agent_canvas_ready'
const canvasId = notif.relatedId
return (
<div
key={notif.id}
className="p-3 border-b last:border-0 hover:bg-accent/50 transition-colors duration-150 cursor-pointer"
onClick={() => {
if (notif.actionUrl) {
handleMarkNotifRead(notif.id)
setOpen(false)
router.push(notif.actionUrl)
}
}}
className="p-3 border-b last:border-0 hover:bg-accent/50 transition-colors duration-150"
>
<div className="flex items-start gap-3">
<div
className="flex items-start gap-3 cursor-pointer"
onClick={() => {
if (notif.actionUrl) {
handleMarkNotifRead(notif.id)
setOpen(false)
router.push(notif.actionUrl)
}
}}
>
<div className={cn(
"mt-0.5 flex-none rounded-full p-1",
notif.type === 'agent_success' && 'bg-green-100 dark:bg-green-900/30 text-green-600',
notif.type === 'agent_slides_ready' && 'bg-purple-100 dark:bg-purple-900/30 text-purple-600',
notif.type === 'agent_canvas_ready' && 'bg-blue-100 dark:bg-blue-900/30 text-blue-600',
notif.type === 'agent_failure' && 'bg-red-100 dark:bg-red-900/30 text-red-600',
notif.type === 'system' && 'bg-blue-100 dark:bg-blue-900/30 text-blue-600',
)}>
{notif.type.startsWith('agent') ? (
{isSlides ? (
<Presentation className="w-3.5 h-3.5" />
) : isCanvas ? (
<Pencil className="w-3.5 h-3.5" />
) : notif.type.startsWith('agent') ? (
<Bot className="w-3.5 h-3.5" />
) : (
<AlertCircle className="w-3.5 h-3.5" />
@@ -226,9 +239,13 @@ export function NotificationPanel() {
<span className={cn(
"text-[10px] font-semibold uppercase tracking-wider",
notif.type === 'agent_success' && 'text-green-600 dark:text-green-400',
notif.type === 'agent_slides_ready' && 'text-purple-600 dark:text-purple-400',
notif.type === 'agent_canvas_ready' && 'text-blue-600 dark:text-blue-400',
notif.type === 'agent_failure' && 'text-red-600 dark:text-red-400',
notif.type === 'system' && 'text-blue-600 dark:text-blue-400',
)}>
{notif.type === 'agent_slides_ready' && (t('notification.slidesReady') || 'Slides Ready')}
{notif.type === 'agent_canvas_ready' && (t('notification.canvasReady') || 'Diagram Ready')}
{notif.type === 'agent_success' && (t('notification.agentSuccess') || 'Agent completed')}
{notif.type === 'agent_failure' && (t('notification.agentFailed') || 'Agent failed')}
{notif.type === 'system' && 'System'}
@@ -251,8 +268,23 @@ export function NotificationPanel() {
<X className="w-3.5 h-3.5" />
</button>
</div>
{isSlides && canvasId && (
<div className="mt-2 ml-8">
<button
onClick={async () => {
handleMarkNotifRead(notif.id)
window.open(`/api/canvas/download?id=${canvasId}`, '_blank')
}}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold rounded-md bg-purple-500 text-white hover:bg-purple-600 shadow-sm transition-all active:scale-95"
>
<Download className="w-3 h-3" />
{t('notification.downloadPptx') || 'Download .pptx'}
</button>
</div>
)}
</div>
))}
)
})}
{/* Overdue reminders */}
{overdueReminders.map((note) => (

View File

@@ -201,11 +201,13 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
<BubbleMenu
editor={editor}
className="notion-bubble-menu"
tippyOptions={{
appendTo: () => document.body,
zIndex: 99999,
fallbackPlacements: ['bottom', 'top']
}}
{...({
tippyOptions: {
appendTo: () => document.body,
zIndex: 99999,
fallbackPlacements: ['bottom', 'top']
}
} as any)}
shouldShow={({ editor: e, state }: { editor: Editor; state: EditorState }) => {
const { from, to } = state.selection
const isImage = e.isActive('image')

View File

@@ -1,87 +0,0 @@
'use client'
import { useState } from 'react'
import { Label } from '@/components/ui/label'
import { Loader2, Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
interface SettingInputProps {
label: string
description?: string
value: string
type?: 'text' | 'password' | 'email' | 'url'
onChange: (value: string) => Promise<void>
placeholder?: string
disabled?: boolean
}
export function SettingInput({
label,
description,
value,
type = 'text',
onChange,
placeholder,
disabled
}: SettingInputProps) {
const { t } = useLanguage()
const [isLoading, setIsLoading] = useState(false)
const [isSaved, setIsSaved] = useState(false)
const handleChange = async (newValue: string) => {
setIsLoading(true)
setIsSaved(false)
try {
await onChange(newValue)
setIsSaved(true)
toast.success(t('toast.saved'))
setTimeout(() => setIsSaved(false), 2000)
} catch (err) {
console.error('Error updating setting:', err)
toast.error(t('toast.saveFailed'))
} finally {
setIsLoading(false)
}
}
return (
<div className={cn('py-4', 'border-b last:border-0 dark:border-gray-800')}>
<Label className="font-medium text-gray-900 dark:text-gray-100 block mb-1">
{label}
</Label>
{description && (
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
{description}
</p>
)}
<div className="relative">
<input
type={type}
value={value}
onChange={(e) => handleChange(e.target.value)}
placeholder={placeholder}
disabled={disabled || isLoading}
className={cn(
'w-full px-3 py-2 border rounded-lg',
'focus:ring-2 focus:ring-primary-500 focus:border-transparent',
'disabled:opacity-50 disabled:cursor-not-allowed',
'bg-white dark:bg-gray-900',
'border-gray-300 dark:border-gray-700',
'text-gray-900 dark:text-gray-100',
'placeholder:text-gray-400 dark:placeholder:text-gray-600'
)}
/>
{isLoading && (
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-gray-500" />
)}
{isSaved && !isLoading && (
<Check className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-green-500" />
)}
</div>
</div>
)
}

View File

@@ -1,86 +0,0 @@
'use client'
import { useState } from 'react'
import { Label } from '@/components/ui/label'
import { Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
interface SelectOption {
value: string
label: string
description?: string
}
interface SettingSelectProps {
label: string
description?: string
value: string
options: SelectOption[]
onChange: (value: string) => Promise<void>
disabled?: boolean
}
export function SettingSelect({
label,
description,
value,
options,
onChange,
disabled
}: SettingSelectProps) {
const { t } = useLanguage()
const [isLoading, setIsLoading] = useState(false)
const handleChange = async (newValue: string) => {
setIsLoading(true)
try {
await onChange(newValue)
toast.success(t('toast.saved'))
} catch (err) {
console.error('Error updating setting:', err)
toast.error(t('toast.saveFailed'))
} finally {
setIsLoading(false)
}
}
return (
<div className={cn('py-4', 'border-b last:border-0 dark:border-gray-800')}>
<Label className="font-medium text-gray-900 dark:text-gray-100 block mb-1">
{label}
</Label>
{description && (
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
{description}
</p>
)}
<div className="relative">
<select
value={value}
onChange={(e) => handleChange(e.target.value)}
disabled={disabled || isLoading}
className={cn(
'w-full px-3 py-2 border rounded-lg',
'focus:ring-2 focus:ring-primary-500 focus:border-transparent',
'disabled:opacity-50 disabled:cursor-not-allowed',
'appearance-none bg-white dark:bg-gray-900',
'border-gray-300 dark:border-gray-700',
'text-gray-900 dark:text-gray-100'
)}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{isLoading && (
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-gray-500" />
)}
</div>
</div>
)
}

View File

@@ -1,73 +0,0 @@
'use client'
import { useState } from 'react'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { Loader2, Check, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
interface SettingToggleProps {
label: string
description?: string
checked: boolean
onChange: (checked: boolean) => Promise<void>
disabled?: boolean
}
export function SettingToggle({
label,
description,
checked,
onChange,
disabled
}: SettingToggleProps) {
const { t } = useLanguage()
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState(false)
const handleChange = async (newChecked: boolean) => {
setIsLoading(true)
setError(false)
try {
await onChange(newChecked)
toast.success(t('toast.saved'))
} catch (err) {
console.error('Error updating setting:', err)
setError(true)
toast.error(t('toast.saveFailed'))
} finally {
setIsLoading(false)
}
}
return (
<div className={cn(
'flex items-center justify-between py-4',
'border-b last:border-0 dark:border-gray-800'
)}>
<div className="flex-1 pr-4">
<Label className="font-medium text-gray-900 dark:text-gray-100 cursor-pointer">
{label}
</Label>
{description && (
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{description}
</p>
)}
</div>
<div className="flex items-center gap-2">
{isLoading && <Loader2 className="h-4 w-4 animate-spin text-gray-500" />}
{!isLoading && !error && checked && <Check className="h-4 w-4 text-green-500" />}
{!isLoading && !error && !checked && <X className="h-4 w-4 text-gray-400" />}
<Switch
checked={checked}
onCheckedChange={handleChange}
disabled={disabled || isLoading}
/>
</div>
</div>
)
}

View File

@@ -1,97 +0,0 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { Settings, Sparkles, Palette, User, Database, Info, Check, Key } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
interface SettingsSection {
id: string
label: string
icon: React.ReactNode
href: string
}
interface SettingsNavProps {
className?: string
}
export function SettingsNav({ className }: SettingsNavProps) {
const pathname = usePathname()
const { t } = useLanguage()
const sections: SettingsSection[] = [
{
id: 'general',
label: t('generalSettings.title'),
icon: <Settings className="h-5 w-5" />,
href: '/settings/general'
},
{
id: 'ai',
label: t('aiSettings.title'),
icon: <Sparkles className="h-5 w-5" />,
href: '/settings/ai'
},
{
id: 'appearance',
label: t('appearance.title'),
icon: <Palette className="h-5 w-5" />,
href: '/settings/appearance'
},
{
id: 'profile',
label: t('profile.title'),
icon: <User className="h-5 w-5" />,
href: '/settings/profile'
},
{
id: 'data',
label: t('dataManagement.title'),
icon: <Database className="h-5 w-5" />,
href: '/settings/data'
},
{
id: 'mcp',
label: t('mcpSettings.title'),
icon: <Key className="h-5 w-5" />,
href: '/settings/mcp'
},
{
id: 'about',
label: t('about.title'),
icon: <Info className="h-5 w-5" />,
href: '/settings/about'
}
]
const isActive = (href: string) => pathname === href || pathname.startsWith(href + '/')
return (
<nav className={cn('space-y-1', className)}>
{sections.map((section) => (
<Link
key={section.id}
href={section.href}
className={cn(
'flex items-center gap-3 px-4 py-3 rounded-lg transition-colors',
'hover:bg-gray-100 dark:hover:bg-gray-800',
isActive(section.href)
? 'bg-gray-100 dark:bg-gray-800 text-primary'
: 'text-gray-700 dark:text-gray-300'
)}
>
{isActive(section.href) && (
<Check className="h-4 w-4 text-primary" />
)}
{!isActive(section.href) && (
<div className="w-4" />
)}
{section.icon}
<span className="font-medium">{section.label}</span>
</Link>
))}
</nav>
)
}

View File

@@ -1,105 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import { Search, X } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
export interface Section {
id: string
label: string
description: string
icon: React.ReactNode
href: string
}
interface SettingsSearchProps {
sections: Section[]
onFilter: (filteredSections: Section[]) => void
placeholder?: string
className?: string
}
export function SettingsSearch({
sections,
onFilter,
placeholder,
className
}: SettingsSearchProps) {
const { t } = useLanguage()
const [query, setQuery] = useState('')
const [filteredSections, setFilteredSections] = useState<Section[]>(sections)
const searchPlaceholder = placeholder || t('settings.searchNoResults') || 'Search settings...'
useEffect(() => {
if (!query.trim()) {
setFilteredSections(sections)
return
}
const queryLower = query.toLowerCase()
const filtered = sections.filter(section => {
const labelMatch = section.label.toLowerCase().includes(queryLower)
const descMatch = section.description.toLowerCase().includes(queryLower)
return labelMatch || descMatch
})
setFilteredSections(filtered)
}, [query, sections])
const handleClearSearch = () => {
setQuery('')
setFilteredSections(sections)
}
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
handleClearSearch()
e.stopPropagation()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [])
const handleSearchChange = (value: string) => {
setQuery(value)
}
const hasResults = query.trim() && filteredSections.length < sections.length
const isEmptySearch = query.trim() && filteredSections.length === 0
return (
<div className={cn('relative', className)}>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
type="text"
value={query}
onChange={(e) => handleSearchChange(e.target.value)}
placeholder={searchPlaceholder}
className="pl-10"
autoFocus
/>
{hasResults && (
<button
type="button"
onClick={handleClearSearch}
className="absolute right-2 top-1/2 text-gray-400 hover:text-gray-600"
aria-label={t('search.placeholder')}
>
<X className="h-4 w-4" />
</button>
)}
{isEmptySearch && (
<div className="absolute top-full left-0 right-0 mt-1 p-2 bg-white rounded-lg shadow-lg border z-50">
<p className="text-sm text-gray-600">{t('settings.searchNoResults')}</p>
</div>
)}
</div>
)
}

View File

@@ -1,36 +0,0 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
interface SettingsSectionProps {
title: string
description?: string
icon?: React.ReactNode
children: React.ReactNode
className?: string
}
export function SettingsSection({
title,
description,
icon,
children,
className
}: SettingsSectionProps) {
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{icon}
{title}
</CardTitle>
{description && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{description}
</p>
)}
</CardHeader>
<CardContent className="space-y-4">
{children}
</CardContent>
</Card>
)
}

View File

@@ -1,6 +0,0 @@
export { SettingsNav } from './SettingsNav'
export { SettingsSection } from './SettingsSection'
export { SettingToggle } from './SettingToggle'
export { SettingSelect } from './SettingSelect'
export { SettingInput } from './SettingInput'
export { SettingsSearch } from './SettingsSearch'

View File

@@ -2,7 +2,7 @@
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { Settings, Sparkles, Palette, User, Database, Info, Check, Key } from 'lucide-react'
import { Settings, Sparkles, Palette, User, Database, Info, Key } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
@@ -22,74 +22,32 @@ export function SettingsNav({ className }: SettingsNavProps) {
const { t } = useLanguage()
const sections: SettingsSection[] = [
{
id: 'general',
label: t('generalSettings.title'),
icon: <Settings className="h-5 w-5" />,
href: '/settings/general'
},
{
id: 'ai',
label: t('aiSettings.title'),
icon: <Sparkles className="h-5 w-5" />,
href: '/settings/ai'
},
{
id: 'appearance',
label: t('appearance.title'),
icon: <Palette className="h-5 w-5" />,
href: '/settings/appearance'
},
{
id: 'profile',
label: t('profile.title'),
icon: <User className="h-5 w-5" />,
href: '/settings/profile'
},
{
id: 'data',
label: t('dataManagement.title'),
icon: <Database className="h-5 w-5" />,
href: '/settings/data'
},
{
id: 'mcp',
label: t('mcpSettings.title'),
icon: <Key className="h-5 w-5" />,
href: '/settings/mcp'
},
{
id: 'about',
label: t('about.title'),
icon: <Info className="h-5 w-5" />,
href: '/settings/about'
}
{ id: 'general', label: t('generalSettings.title'), icon: <Settings className="h-4 w-4" />, href: '/settings/general' },
{ id: 'ai', label: t('aiSettings.title'), icon: <Sparkles className="h-4 w-4" />, href: '/settings/ai' },
{ id: 'appearance', label: t('appearance.title'), icon: <Palette className="h-4 w-4" />, href: '/settings/appearance' },
{ id: 'profile', label: t('profile.title'), icon: <User className="h-4 w-4" />, href: '/settings/profile' },
{ id: 'data', label: t('dataManagement.title'), icon: <Database className="h-4 w-4" />, href: '/settings/data' },
{ id: 'mcp', label: t('mcpSettings.title'), icon: <Key className="h-4 w-4" />, href: '/settings/mcp' },
{ id: 'about', label: t('about.title'), icon: <Info className="h-4 w-4" />, href: '/settings/about' },
]
const isActive = (href: string) => pathname === href || pathname.startsWith(href + '/')
return (
<nav className={cn('space-y-1', className)}>
<nav className={cn('flex items-center gap-1', className)}>
{sections.map((section) => (
<Link
key={section.id}
href={section.href}
className={cn(
'flex items-center gap-3 px-4 py-3 rounded-lg transition-colors',
'hover:bg-gray-100 dark:hover:bg-gray-800',
'flex items-center gap-2 px-3 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap',
isActive(section.href)
? 'bg-gray-100 dark:bg-gray-800 text-primary'
: 'text-gray-700 dark:text-gray-300'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
)}
>
{isActive(section.href) && (
<Check className="h-4 w-4 text-primary" />
)}
{!isActive(section.href) && (
<div className="w-4" />
)}
{section.icon}
<span className="font-medium">{section.label}</span>
<span>{section.label}</span>
</Link>
))}
</nav>

View File

@@ -46,7 +46,7 @@ export function Sidebar({ className, user }: { className?: string, user?: any })
useEffect(() => {
if (HIDDEN_ROUTES.some(r => pathname.startsWith(r))) return
getTrashCount().then(setTrashCount)
}, [pathname, searchKey, refreshKey])
}, [pathname, refreshKey])
// Hide sidebar on Agents, Chat IA and Lab routes
if (HIDDEN_ROUTES.some(r => pathname.startsWith(r))) return null

View File

@@ -5,7 +5,7 @@ import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-normal transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
@@ -21,14 +21,14 @@ const buttonVariants = cva(
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
default: "min-h-9 h-auto px-4 py-2 has-[>svg]:px-3",
xs: "min-h-6 h-auto gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "min-h-8 h-auto gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "min-h-10 h-auto rounded-md px-6 has-[>svg]:px-4",
icon: "size-9 shrink-0",
"icon-xs": "size-6 rounded-md shrink-0 [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8 shrink-0",
"icon-lg": "size-10 shrink-0",
},
},
defaultVariants: {

View File

@@ -9,7 +9,7 @@ export const toast = sonnerToast
export function Toaster() {
return (
<SonnerToaster
position="top-right"
position="bottom-right"
expand={false}
richColors
closeButton

View File

@@ -5,18 +5,25 @@ import { createContext, useContext, useState, useCallback, useMemo } from 'react
interface NoteRefreshContextType {
refreshKey: number
triggerRefresh: () => void
notebooksRefreshKey: number
triggerNotebooksRefresh: () => void
}
const NoteRefreshContext = createContext<NoteRefreshContextType | undefined>(undefined)
export function NoteRefreshProvider({ children }: { children: React.ReactNode }) {
const [refreshKey, setRefreshKey] = useState(0)
const [notebooksRefreshKey, setNotebooksRefreshKey] = useState(0)
const triggerRefresh = useCallback(() => {
setRefreshKey(prev => prev + 1)
}, [])
const value = useMemo(() => ({ refreshKey, triggerRefresh }), [refreshKey, triggerRefresh])
const triggerNotebooksRefresh = useCallback(() => {
setNotebooksRefreshKey(prev => prev + 1)
}, [])
const value = useMemo(() => ({ refreshKey, triggerRefresh, notebooksRefreshKey, triggerNotebooksRefresh }), [refreshKey, triggerRefresh, notebooksRefreshKey, triggerNotebooksRefresh])
return (
<NoteRefreshContext.Provider value={value}>
@@ -33,11 +40,7 @@ export function useNoteRefresh() {
return context
}
/**
* Same as useNoteRefresh but tolerates being called outside the provider
* (e.g. shared header rendered in admin pages). Returns a no-op when absent.
*/
export function useNoteRefreshOptional(): NoteRefreshContextType {
const context = useContext(NoteRefreshContext)
return context ?? { refreshKey: 0, triggerRefresh: () => {} }
return context ?? { refreshKey: 0, triggerRefresh: () => {}, notebooksRefreshKey: 0, triggerNotebooksRefresh: () => {} }
}

View File

@@ -84,7 +84,7 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
const [isLoading, setIsLoading] = useState(true)
const [isMovingNote, setIsMovingNote] = useState(false)
const [error, setError] = useState<string | null>(null)
const { triggerRefresh, refreshKey } = useNoteRefresh()
const { triggerRefresh, triggerNotebooksRefresh, notebooksRefreshKey } = useNoteRefresh()
// ===== DERIVED STATE =====
const currentLabels = useMemo(() => {
@@ -115,10 +115,9 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
loadNotebooks()
}, [loadNotebooks])
// Recharge les carnets à chaque fois qu'une note est modifiée/supprimée
useEffect(() => {
if (refreshKey > 0) loadNotebooks()
}, [refreshKey, loadNotebooks])
if (notebooksRefreshKey > 0) loadNotebooks()
}, [notebooksRefreshKey, loadNotebooks])
// ===== ACTIONS: NOTEBOOKS =====
const createNotebookOptimistic = useCallback(async (data: CreateNotebookInput) => {
@@ -134,8 +133,9 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
// Reload notebooks from server to update sidebar state
await loadNotebooks()
triggerNotebooksRefresh()
triggerRefresh()
}, [loadNotebooks, triggerRefresh])
}, [loadNotebooks, triggerNotebooksRefresh, triggerRefresh])
const updateNotebook = useCallback(async (notebookId: string, data: UpdateNotebookInput) => {
const response = await fetch(`/api/notebooks/${notebookId}`, {
@@ -149,8 +149,9 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
}
await loadNotebooks()
triggerNotebooksRefresh()
triggerRefresh()
}, [loadNotebooks, triggerRefresh])
}, [loadNotebooks, triggerNotebooksRefresh, triggerRefresh])
const deleteNotebook = useCallback(async (notebookId: string) => {
const response = await fetch(`/api/notebooks/${notebookId}`, {
@@ -162,8 +163,9 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
}
await loadNotebooks()
triggerNotebooksRefresh()
triggerRefresh()
}, [loadNotebooks, triggerRefresh])
}, [loadNotebooks, triggerNotebooksRefresh, triggerRefresh])
const updateNotebookOrderOptimistic = useCallback(async (notebookIds: string[]) => {
const response = await fetch('/api/notebooks/reorder', {
@@ -177,8 +179,9 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
}
await loadNotebooks()
triggerNotebooksRefresh()
triggerRefresh()
}, [loadNotebooks, triggerRefresh])
}, [loadNotebooks, triggerNotebooksRefresh, triggerRefresh])
// ===== ACTIONS: LABELS =====
const createLabel = useCallback(async (data: CreateLabelInput) => {
@@ -233,6 +236,7 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
}
await loadNotebooks()
triggerNotebooksRefresh()
triggerRefresh()
} catch (error) {
toast.error('Failed to move note. Please try again.')

View File

@@ -26,6 +26,19 @@ export class CustomOpenAIProvider implements AIProvider {
const headers = new Headers(options?.headers);
headers.set('HTTP-Referer', 'https://localhost:3000');
headers.set('X-Title', 'Memento AI');
// Disable DeepSeek extended thinking for reliable tool/function calling
if (options?.body) {
try {
const body = JSON.parse(options.body as string)
if (
typeof body.model === 'string' &&
(body.model.includes('deepseek') || body.model.includes('thinking') || body.model.includes('reasoner'))
) {
body.thinking = { type: 'disabled' }
}
return fetch(url, { ...options, headers, body: JSON.stringify(body) })
} catch { /* ignore parse errors */ }
}
return fetch(url, { ...options, headers });
}
});
@@ -119,25 +132,40 @@ export class CustomOpenAIProvider implements AIProvider {
async generateWithTools(options: ToolUseOptions): Promise<ToolCallResult> {
const { tools, maxSteps = 10, systemPrompt, messages, prompt } = options
const opts: Record<string, any> = {
model: this.model,
tools,
stopWhen: stepCountIs(maxSteps),
}
if (systemPrompt) opts.system = systemPrompt
if (messages) opts.messages = messages
else if (prompt) opts.prompt = prompt
const result = await aiGenerateText(opts as any)
return {
toolCalls: result.toolCalls?.map((tc: any) => ({ toolName: tc.toolName, input: tc.input })) || [],
toolResults: result.toolResults?.map((tr: any) => ({ toolName: tr.toolName, input: tr.input, output: tr.output })) || [],
text: result.text,
steps: result.steps?.map((step: any) => ({
const buildOpts = (steps: number): Record<string, any> => {
const opts: Record<string, any> = { model: this.model, tools, stopWhen: stepCountIs(steps) }
if (systemPrompt) opts.system = systemPrompt
if (messages) opts.messages = messages
else if (prompt) opts.prompt = prompt
return opts
}
const toResult = (r: any): ToolCallResult => ({
toolCalls: r.toolCalls?.map((tc: any) => ({ toolName: tc.toolName, input: tc.input })) || [],
toolResults: r.toolResults?.map((tr: any) => ({ toolName: tr.toolName, input: tr.input, output: tr.output })) || [],
text: r.text,
steps: r.steps?.map((step: any) => ({
text: step.text,
toolCalls: step.toolCalls?.map((tc: any) => ({ toolName: tc.toolName, input: tc.input })) || [],
toolResults: step.toolResults?.map((tr: any) => ({ toolName: tr.toolName, input: tr.input, output: tr.output })) || []
})) || []
toolResults: step.toolResults?.map((tr: any) => ({ toolName: tr.toolName, input: tr.input, output: tr.output })) || [],
})) || [],
})
try {
const result = await aiGenerateText(buildOpts(maxSteps) as any)
return toResult(result)
} catch (err: any) {
// DeepSeek reasoning/thinking models require reasoning_content to be passed back
// between multi-step calls, which the AI SDK doesn't handle via the OpenAI-compat layer.
// Retry with a single step so the model calls the tool directly.
const msg: string = err?.message || String(err)
if (msg.includes('reasoning_content') || msg.includes('thinking mode')) {
console.warn('[CustomOpenAI] Reasoning model detected — retrying with maxSteps=1')
const result = await aiGenerateText(buildOpts(1) as any)
return toResult(result)
}
throw err
}
}

Some files were not shown because too many files have changed in this diff Show More