Compare commits
29 Commits
0f48df114a
...
ancien-ui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d8252aec0 | ||
|
|
33ad874e5d | ||
|
|
3b72e4afbb | ||
|
|
ccc94a4b35 | ||
|
|
fb6823e25e | ||
|
|
7326cfc98f | ||
|
|
4f950740eb | ||
|
|
db200bbc9f | ||
|
|
08a49a04ce | ||
|
|
ab914f0587 | ||
|
|
b55f558a62 | ||
|
|
19d0b2759a | ||
|
|
34a977b5c4 | ||
|
|
75b08ef53b | ||
|
|
98e246e257 | ||
|
|
ff0b56f805 | ||
|
|
d1e08f64c8 | ||
|
|
e7f28abccc | ||
|
|
129d5541e6 | ||
|
|
21fb56de3f | ||
|
|
0ebf10344d | ||
|
|
0311c97a35 | ||
|
|
1ed7839334 | ||
|
|
718f4c6900 | ||
|
|
aee4b17306 | ||
|
|
b611ec874d | ||
| 635e516616 | |||
| a7c3251b49 | |||
| 5375f874cd |
@@ -121,7 +121,8 @@ jobs:
|
|||||||
|
|
||||||
echo "=== Git pull ==="
|
echo "=== Git pull ==="
|
||||||
git config --global --add safe.directory /opt/memento
|
git config --global --add safe.directory /opt/memento
|
||||||
git pull origin main
|
git fetch origin main
|
||||||
|
git reset --hard origin/main
|
||||||
|
|
||||||
echo "=== Building ==="
|
echo "=== Building ==="
|
||||||
docker compose build memento-note
|
docker compose build memento-note
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ services:
|
|||||||
cpus: '0.25'
|
cpus: '0.25'
|
||||||
memory: 128M
|
memory: 128M
|
||||||
healthcheck:
|
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
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -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
|
1. Add an **MCP Client** node to your workflow
|
||||||
2. **Server Transport** : `Streamable HTTP`
|
2. **Server Transport**: `Streamable HTTP`
|
||||||
3. **MCP Endpoint URL** : `http://memento-mcp:3001/mcp` (Docker) ou `http://VOTRE_IP:3001/mcp`
|
3. **MCP Endpoint URL**: `http://memento-mcp:3001/mcp` (Docker) or `http://YOUR_IP:3001/mcp`
|
||||||
4. **Authentication** : Header Auth
|
4. **Authentication**: Header Auth
|
||||||
- Header Name : `x-api-key`
|
- Header Name: `x-api-key`
|
||||||
- Header Value : votre cle API (`mcp_sk_...`)
|
- Header Value: your API key (`mcp_sk_...`)
|
||||||
|
|
||||||
### Alternative : curl
|
### Alternative: curl
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Health check
|
# 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 \
|
curl -X POST http://localhost:3001/mcp \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-H "x-api-key: mcp_sk_votrecle" \
|
-H "x-api-key: mcp_sk_yourkey" \
|
||||||
-d '{
|
-d '{
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
"id": 1,
|
"id": 1,
|
||||||
@@ -36,10 +36,10 @@ curl -X POST http://localhost:3001/mcp \
|
|||||||
"params": {}
|
"params": {}
|
||||||
}'
|
}'
|
||||||
|
|
||||||
# Creer une note
|
# Create a note
|
||||||
curl -X POST http://localhost:3001/mcp \
|
curl -X POST http://localhost:3001/mcp \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-H "x-api-key: mcp_sk_votrecle" \
|
-H "x-api-key: mcp_sk_yourkey" \
|
||||||
-d '{
|
-d '{
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
"id": 2,
|
"id": 2,
|
||||||
@@ -47,72 +47,81 @@ curl -X POST http://localhost:3001/mcp \
|
|||||||
"params": {
|
"params": {
|
||||||
"name": "create_note",
|
"name": "create_note",
|
||||||
"arguments": {
|
"arguments": {
|
||||||
"title": "Ma note",
|
"title": "My note",
|
||||||
"content": "Contenu de la note"
|
"content": "Note content here"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
## Outils disponibles (22)
|
## Available Tools (22)
|
||||||
|
|
||||||
### Notes (11)
|
### Notes (11)
|
||||||
|
|
||||||
| Outil | Description |
|
| Tool | Description |
|
||||||
|-------|-------------|
|
|------|-------------|
|
||||||
| `create_note` | Creer une note |
|
| `create_note` | Create a note |
|
||||||
| `get_notes` | Lister les notes |
|
| `get_notes` | List notes |
|
||||||
| `get_note` | Recuperer une note par ID |
|
| `get_note` | Get a note by ID |
|
||||||
| `update_note` | Modifier une note |
|
| `update_note` | Update a note |
|
||||||
| `delete_note` | Supprimer une note |
|
| `delete_note` | Delete a note |
|
||||||
| `search_notes` | Rechercher par mot-cle |
|
| `search_notes` | Search by keyword |
|
||||||
| `move_note` | Deplacer vers un notebook |
|
| `move_note` | Move to a notebook |
|
||||||
| `toggle_pin` | Epingler/Depingler |
|
| `toggle_pin` | Pin / unpin |
|
||||||
| `toggle_archive` | Archiver/Desarchiver |
|
| `toggle_archive` | Archive / unarchive |
|
||||||
| `export_notes` | Exporter en JSON |
|
| `export_notes` | Export as JSON |
|
||||||
| `import_notes` | Importer depuis 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)
|
### Notebooks (6)
|
||||||
|
|
||||||
| Outil | Description |
|
| Tool | Description |
|
||||||
|-------|-------------|
|
|------|-------------|
|
||||||
| `create_notebook` | Creer un notebook |
|
| `create_notebook` | Create a notebook |
|
||||||
| `get_notebooks` | Lister les notebooks |
|
| `get_notebooks` | List all notebooks |
|
||||||
| `get_notebook` | Details d'un notebook |
|
| `get_notebook` | Get notebook details |
|
||||||
| `update_notebook` | Modifier un notebook |
|
| `update_notebook` | Update a notebook |
|
||||||
| `delete_notebook` | Supprimer un notebook |
|
| `delete_notebook` | Delete a notebook |
|
||||||
| `reorder_notebooks` | Reordonner |
|
| `reorder_notebooks` | Reorder notebooks |
|
||||||
|
|
||||||
### Labels (4)
|
### Labels (4)
|
||||||
|
|
||||||
| Outil | Description |
|
| Tool | Description |
|
||||||
|-------|-------------|
|
|------|-------------|
|
||||||
| `create_label` | Creer un label |
|
| `create_label` | Create a label |
|
||||||
| `get_labels` | Lister les labels |
|
| `get_labels` | List labels |
|
||||||
| `update_label` | Modifier un label |
|
| `update_label` | Update a label |
|
||||||
| `delete_label` | Supprimer un label |
|
| `delete_label` | Delete a label |
|
||||||
|
|
||||||
### Rappels (1)
|
### Reminders (1)
|
||||||
|
|
||||||
| Outil | Description |
|
| Tool | Description |
|
||||||
|-------|-------------|
|
|------|-------------|
|
||||||
| `get_due_reminders` | Recuperer les rappels dus |
|
| `get_due_reminders` | Get due reminders |
|
||||||
|
|
||||||
## Endpoints HTTP
|
## HTTP Endpoints
|
||||||
|
|
||||||
| Endpoint | Methode | Description |
|
| Endpoint | Method | Description |
|
||||||
|----------|---------|-------------|
|
|----------|--------|-------------|
|
||||||
| `/` | GET | Health check |
|
| `/` | GET | Health check |
|
||||||
| `/mcp` | GET/POST | Endpoint MCP principal |
|
| `/mcp` | GET/POST | Main MCP endpoint |
|
||||||
| `/sse` | GET/POST | Legacy (redirige vers `/mcp`) |
|
| `/sse` | GET/POST | Legacy (redirects to `/mcp`) |
|
||||||
| `/sessions` | GET | Sessions actives |
|
| `/sessions` | GET | Active sessions |
|
||||||
|
|
||||||
## Securite
|
## Security
|
||||||
|
|
||||||
- **Authentification obligatoire** en production (`MCP_REQUIRE_AUTH=true` dans Docker)
|
- **Authentication required** in production (`MCP_REQUIRE_AUTH=true` in Docker)
|
||||||
- Les cles API sont gerees depuis **Parametres > MCP** dans Memento
|
- API keys are managed from **Settings > MCP** in Memento
|
||||||
- Chaque cle est scopee a un utilisateur : seules ses notes sont accessibles
|
- Each key is scoped to a single user: only their notes are accessible
|
||||||
- Les cles sont hashees en base (SHA256), seul le raw key est montre a la creation
|
- Keys are hashed in the database (SHA256); the raw key is only shown once at creation
|
||||||
|
|
||||||
## Ports
|
## Ports
|
||||||
|
|
||||||
|
|||||||
571
mcp-server/N8N-EXAMPLES.md
Normal file
571
mcp-server/N8N-EXAMPLES.md
Normal 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 }}"]
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -1,24 +1,24 @@
|
|||||||
# Workflows N8N pour Memento MCP
|
# N8N Workflows for Memento MCP
|
||||||
|
|
||||||
## Introduction
|
## 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`
|
- **Server Transport**: `Streamable HTTP`
|
||||||
- **MCP Endpoint URL** : `http://memento-mcp:3001/mcp` (Docker) ou `http://VOTRE_IP:3001/mcp`
|
- **MCP Endpoint URL**: `http://memento-mcp:3001/mcp` (Docker) or `http://YOUR_IP:3001/mcp`
|
||||||
- **Authentication** : Header Auth avec `x-api-key` = votre cle API
|
- **Authentication**: Header Auth with `x-api-key` = your API key
|
||||||
|
|
||||||
## Workflows disponibles
|
## Available Workflows
|
||||||
|
|
||||||
### 1. Create Note (`n8n-workflow-create-note.json`)
|
### 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
|
```json
|
||||||
{
|
{
|
||||||
@@ -34,7 +34,7 @@ Cree des notes dans Memento avec classification par IA.
|
|||||||
|
|
||||||
### 2. Search & Summary (`n8n-workflow-search-summary.json`)
|
### 2. Search & Summary (`n8n-workflow-search-summary.json`)
|
||||||
|
|
||||||
Recherche des notes et genere un resume.
|
Searches notes and generates a summary.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -47,7 +47,7 @@ Recherche des notes et genere un resume.
|
|||||||
|
|
||||||
### 3. Notebook Manager (`n8n-workflow-notebook-management.json`)
|
### 3. Notebook Manager (`n8n-workflow-notebook-management.json`)
|
||||||
|
|
||||||
CRUD complet des notebooks.
|
Full CRUD for notebooks.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -62,14 +62,14 @@ CRUD complet des notebooks.
|
|||||||
|
|
||||||
### 4. Reminder Notifications (`n8n-workflow-reminder-notifications.json`)
|
### 4. Reminder Notifications (`n8n-workflow-reminder-notifications.json`)
|
||||||
|
|
||||||
Automatisation des rappels avec notifications.
|
Automates reminders with notifications.
|
||||||
|
|
||||||
- Declencheur : Schedule (cron: `0 */30 * * * *`)
|
- Trigger: Schedule (cron: `0 */30 * * * *`)
|
||||||
- Appelle `get_due_reminders` et envoie les notifications
|
- Calls `get_due_reminders` and sends notifications
|
||||||
|
|
||||||
### 5. Label Manager (`n8n-workflow-label-management.json`)
|
### 5. Label Manager (`n8n-workflow-label-management.json`)
|
||||||
|
|
||||||
Gestion des labels.
|
Label management.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -84,20 +84,20 @@ Gestion des labels.
|
|||||||
|
|
||||||
### 6. Email to Note (`n8n-workflow-email-integration.json`)
|
### 6. Email to Note (`n8n-workflow-email-integration.json`)
|
||||||
|
|
||||||
Conversion automatique d'emails en notes.
|
Automatically converts emails into notes.
|
||||||
|
|
||||||
- Declencheur : Email Trigger (IMAP)
|
- Trigger: Email Trigger (IMAP)
|
||||||
- Cree une note avec le contenu de l'email
|
- Creates a note from the email content
|
||||||
|
|
||||||
## Importation
|
## Import Instructions
|
||||||
|
|
||||||
1. Ouvrir N8N
|
1. Open N8N
|
||||||
2. **Import from File** -> selectionner le fichier JSON
|
2. **Import from File** → select the JSON file
|
||||||
3. Configurer le noeud MCP Client (URL + cle API)
|
3. Configure the MCP Client node (URL + API key)
|
||||||
4. Activer le workflow
|
4. Activate the workflow
|
||||||
|
|
||||||
## Securite
|
## Security
|
||||||
|
|
||||||
- Toujours utiliser une cle API dediee par workflow
|
- Always use a dedicated API key per workflow
|
||||||
- Ne jamais exposer la cle dans les logs
|
- Never expose the key in logs
|
||||||
- Restreindre l'acces reseau au port 3001 si possible
|
- Restrict network access to port 3001 when possible
|
||||||
|
|||||||
@@ -96,33 +96,26 @@ export async function validateApiKey(prisma, rawKey) {
|
|||||||
|
|
||||||
const keyHash = hashKey(rawKey);
|
const keyHash = hashKey(rawKey);
|
||||||
|
|
||||||
// Check cache first
|
|
||||||
const cached = getCachedKey(keyHash);
|
const cached = getCachedKey(keyHash);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optimized: Use startsWith to leverage index, then filter by hash
|
const shortId = rawKey.substring(7, 15);
|
||||||
// This is much faster than loading all keys
|
const configKey = `${KEY_PREFIX}${shortId}`;
|
||||||
const shortIdFromKey = rawKey.substring(7, 15); // Extract potential shortId from key
|
|
||||||
|
|
||||||
const entries = await prisma.systemConfig.findMany({
|
const entry = await prisma.systemConfig.findUnique({ where: { key: configKey } });
|
||||||
where: {
|
if (!entry) return null;
|
||||||
key: { startsWith: KEY_PREFIX },
|
|
||||||
},
|
|
||||||
take: 100, // Limit to prevent loading too many keys
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
try {
|
try {
|
||||||
const info = JSON.parse(entry.value);
|
const info = JSON.parse(entry.value);
|
||||||
if (info.keyHash === keyHash && info.active) {
|
if (info.keyHash !== keyHash || !info.active) return null;
|
||||||
// Update lastUsedAt (fire and forget - don't wait)
|
|
||||||
info.lastUsedAt = new Date().toISOString();
|
info.lastUsedAt = new Date().toISOString();
|
||||||
prisma.systemConfig.update({
|
prisma.systemConfig.update({
|
||||||
where: { key: entry.key },
|
where: { key: configKey },
|
||||||
data: { value: JSON.stringify(info) },
|
data: { value: JSON.stringify(info) },
|
||||||
}).catch(() => {}); // Ignore errors
|
}).catch(() => {});
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
apiKeyId: info.shortId,
|
apiKeyId: info.shortId,
|
||||||
@@ -131,17 +124,11 @@ export async function validateApiKey(prisma, rawKey) {
|
|||||||
userName: info.userName,
|
userName: info.userName,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
setCachedKey(keyHash, result);
|
setCachedKey(keyHash, result);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Invalid JSON, skip
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,30 +1,27 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/**
|
/**
|
||||||
* Memento MCP Server - Streamable HTTP Transport (Optimized)
|
* Memento MCP Server - Streamable HTTP Transport (Fast)
|
||||||
*
|
*
|
||||||
* Performance improvements:
|
|
||||||
* - Prisma connection pooling
|
* - Prisma connection pooling
|
||||||
* - Request timeout handling
|
* - Compact JSON output
|
||||||
* - Response compression
|
* - Bounded session cache
|
||||||
* - Connection keep-alive
|
* - Proper keep-alive & timeouts
|
||||||
* - Request batching support
|
* - O(1) API key validation
|
||||||
*
|
*
|
||||||
* Environment variables:
|
* Environment:
|
||||||
* PORT - Server port (default: 3001)
|
* PORT Server port (default: 3001)
|
||||||
* DATABASE_URL - Prisma database URL (default: ../../memento-note/prisma/dev.db)
|
* DATABASE_URL Prisma database URL
|
||||||
* USER_ID - Optional user ID to filter data
|
* USER_ID Optional user ID filter
|
||||||
* APP_BASE_URL - Optional Next.js app URL for AI features (default: http://localhost:3000)
|
* APP_BASE_URL Next.js app URL (default: http://localhost:3000)
|
||||||
* MCP_REQUIRE_AUTH - Set to 'true' to require x-api-key or x-user-id header
|
* MCP_REQUIRE_AUTH Set 'true' to require authentication
|
||||||
* MCP_API_KEY - Static API key for authentication (when MCP_REQUIRE_AUTH=true)
|
* MCP_API_KEY Static fallback API key
|
||||||
* MCP_LOG_LEVEL - Log level: debug, info, warn, error (default: info)
|
* MCP_LOG_LEVEL debug, info, warn, error (default: info)
|
||||||
* MCP_REQUEST_TIMEOUT - Request timeout in ms (default: 30000)
|
* MCP_REQUEST_TIMEOUT Timeout in ms (default: 30000)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import { dirname, join } from 'path';
|
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
@@ -32,13 +29,12 @@ import { registerTools } from './tools.js';
|
|||||||
import { validateApiKey, resolveUser } from './auth.js';
|
import { validateApiKey, resolveUser } from './auth.js';
|
||||||
import { requestContext } from './request-context.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 PORT = process.env.PORT || 3001;
|
||||||
const LOG_LEVEL = process.env.MCP_LOG_LEVEL || 'info';
|
const LOG_LEVEL = process.env.MCP_LOG_LEVEL || 'info';
|
||||||
const REQUEST_TIMEOUT = parseInt(process.env.MCP_REQUEST_TIMEOUT, 10) || 30000;
|
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 logLevels = { debug: 0, info: 1, warn: 2, error: 3 };
|
||||||
const currentLogLevel = logLevels[LOG_LEVEL] ?? 1;
|
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;
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
if (!databaseUrl) {
|
if (!databaseUrl) {
|
||||||
console.error('ERROR: DATABASE_URL environment variable is required');
|
console.error('ERROR: DATABASE_URL is required');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// OPTIMIZED: Prisma client with connection pooling
|
const isPostgres = databaseUrl.startsWith('postgresql://') || databaseUrl.startsWith('postgres://');
|
||||||
|
|
||||||
const prisma = new PrismaClient({
|
const prisma = new PrismaClient({
|
||||||
datasources: {
|
datasources: { db: { url: databaseUrl } },
|
||||||
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'],
|
log: LOG_LEVEL === 'debug' ? ['query', 'info', 'warn', 'error'] : ['warn', 'error'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const appBaseUrl = process.env.APP_BASE_URL || 'http://localhost:3000';
|
const appBaseUrl = process.env.APP_BASE_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
// ── Auth Middleware ──────────────────────────────────────────────────────────
|
// ── Bounded Session Cache ───────────────────────────────────────────────────
|
||||||
|
|
||||||
const userSessions = {};
|
const sessions = new Map();
|
||||||
const SESSION_TIMEOUT = 3600000; // 1 hour
|
|
||||||
|
|
||||||
// Cleanup old sessions periodically
|
function cleanupSessions() {
|
||||||
setInterval(() => {
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
let cleaned = 0;
|
let cleaned = 0;
|
||||||
for (const [key, session] of Object.entries(userSessions)) {
|
for (const [key, s] of sessions) {
|
||||||
if (now - new Date(session.lastSeen).getTime() > SESSION_TIMEOUT) {
|
if (now - s._lastSeen > SESSION_TTL) {
|
||||||
delete userSessions[key];
|
sessions.delete(key);
|
||||||
cleaned++;
|
cleaned++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (cleaned > 0) {
|
if (cleaned > 0) log('debug', `Cleaned ${cleaned} sessions`);
|
||||||
log('debug', `Cleaned up ${cleaned} expired 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) => {
|
app.use(async (req, res, next) => {
|
||||||
// Dev mode: no auth required
|
|
||||||
if (process.env.MCP_REQUIRE_AUTH !== 'true') {
|
if (process.env.MCP_REQUIRE_AUTH !== 'true') {
|
||||||
req.userSession = { id: 'dev-user', name: 'Development User', isAuth: false };
|
req.userSession = { id: 'dev-user', name: 'Development User', isAuth: false };
|
||||||
return next();
|
return next();
|
||||||
@@ -102,189 +108,128 @@ app.use(async (req, res, next) => {
|
|||||||
const headerUserId = req.headers['x-user-id'];
|
const headerUserId = req.headers['x-user-id'];
|
||||||
|
|
||||||
if (!apiKey && !headerUserId) {
|
if (!apiKey && !headerUserId) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({ error: 'Authentication required', message: 'Provide x-api-key or x-user-id header' });
|
||||||
error: 'Authentication required',
|
|
||||||
message: 'Provide x-api-key header (recommended) or x-user-id header',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Method 1: API Key (recommended) ──────────────────────────────
|
|
||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
const keyUser = await validateApiKey(prisma, apiKey);
|
const keyUser = await validateApiKey(prisma, apiKey);
|
||||||
if (keyUser) {
|
if (keyUser) {
|
||||||
const sessionKey = `key:${keyUser.apiKeyId}`;
|
req.userSession = getOrCreateSession(`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})`,
|
name: `${keyUser.userName} (${keyUser.apiKeyName})`,
|
||||||
userId: keyUser.userId,
|
userId: keyUser.userId,
|
||||||
userName: keyUser.userName,
|
userName: keyUser.userName,
|
||||||
apiKeyId: keyUser.apiKeyId,
|
apiKeyId: keyUser.apiKeyId,
|
||||||
connectedAt: new Date().toISOString(),
|
|
||||||
lastSeen: new Date().toISOString(),
|
|
||||||
requestCount: 0,
|
|
||||||
isAuth: true,
|
|
||||||
authMethod: 'api-key',
|
authMethod: 'api-key',
|
||||||
};
|
});
|
||||||
userSessions[sessionKey] = req.userSession;
|
|
||||||
}
|
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: static env var key
|
|
||||||
if (process.env.MCP_API_KEY && apiKey === process.env.MCP_API_KEY) {
|
if (process.env.MCP_API_KEY && apiKey === process.env.MCP_API_KEY) {
|
||||||
const sessionKey = `static:${apiKey.substring(0, 8)}`;
|
req.userSession = getOrCreateSession(`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',
|
name: 'Static API Key User',
|
||||||
userId: process.env.USER_ID || null,
|
userId: process.env.USER_ID || null,
|
||||||
connectedAt: new Date().toISOString(),
|
|
||||||
lastSeen: new Date().toISOString(),
|
|
||||||
requestCount: 0,
|
|
||||||
isAuth: true,
|
|
||||||
authMethod: 'static-key',
|
authMethod: 'static-key',
|
||||||
};
|
});
|
||||||
userSessions[sessionKey] = req.userSession;
|
|
||||||
}
|
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(401).json({ error: 'Invalid API key' });
|
return res.status(401).json({ error: 'Invalid API key' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Method 2: User ID header (validate against DB) ──────────────
|
|
||||||
if (headerUserId) {
|
if (headerUserId) {
|
||||||
const user = await resolveUser(prisma, headerUserId);
|
const user = await resolveUser(prisma, headerUserId);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return res.status(401).json({ error: 'User not found', message: `No user matching: ${headerUserId}` });
|
return res.status(401).json({ error: 'User not found' });
|
||||||
}
|
}
|
||||||
|
req.userSession = getOrCreateSession(`user:${user.id}`, {
|
||||||
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,
|
name: user.name,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
userName: user.name,
|
userName: user.name,
|
||||||
userEmail: user.email,
|
userEmail: user.email,
|
||||||
userRole: user.role,
|
userRole: user.role,
|
||||||
connectedAt: new Date().toISOString(),
|
|
||||||
lastSeen: new Date().toISOString(),
|
|
||||||
requestCount: 0,
|
|
||||||
isAuth: true,
|
|
||||||
authMethod: 'user-id',
|
authMethod: 'user-id',
|
||||||
};
|
});
|
||||||
userSessions[sessionKey] = req.userSession;
|
|
||||||
}
|
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(401).json({ error: 'Authentication failed' });
|
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) => {
|
app.use((req, res, next) => {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
|
|
||||||
if (req.userSession) {
|
|
||||||
req.userSession.requestCount = (req.userSession.requestCount || 0) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.on('finish', () => {
|
res.on('finish', () => {
|
||||||
const duration = Date.now() - start;
|
const ms = Date.now() - start;
|
||||||
const sessionId = req.userSession?.id?.substring(0, 8) || 'anon';
|
const sid = req.userSession?.id?.substring(0, 8) || 'anon';
|
||||||
log('debug', `[${sessionId}] ${req.method} ${req.path} - ${res.statusCode} (${duration}ms)`);
|
log('debug', `[${sid}] ${req.method} ${req.path} ${res.statusCode} ${ms}ms`);
|
||||||
});
|
});
|
||||||
|
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Request Timeout Middleware ──────────────────────────────────────────────
|
// ── Timeout ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
|
req.setTimeout(REQUEST_TIMEOUT);
|
||||||
res.setTimeout(REQUEST_TIMEOUT, () => {
|
res.setTimeout(REQUEST_TIMEOUT, () => {
|
||||||
log('warn', `Request timeout: ${req.method} ${req.path}`);
|
if (!res.headersSent) {
|
||||||
res.status(504).json({ error: 'Gateway Timeout', message: 'Request took too long' });
|
res.status(504).json({ error: 'Gateway Timeout' });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── MCP Server Setup ────────────────────────────────────────────────────────
|
// ── MCP Server ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const server = new Server(
|
const server = new Server(
|
||||||
{
|
{ name: 'memento-mcp-server', version: '3.2.0' },
|
||||||
name: 'memento-mcp-server',
|
{ capabilities: { tools: {} } },
|
||||||
version: '3.1.0',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
capabilities: { tools: {} },
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
registerTools(server, prisma);
|
registerTools(server, prisma);
|
||||||
|
|
||||||
// ── HTTP Endpoints ──────────────────────────────────────────────────────────
|
// ── Routes ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const transports = {};
|
|
||||||
|
|
||||||
// Health check
|
|
||||||
app.get('/', (req, res) => {
|
app.get('/', (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
name: 'Memento MCP Server',
|
name: 'Memento MCP Server',
|
||||||
version: '3.1.0',
|
version: '3.2.0',
|
||||||
status: 'running',
|
status: 'running',
|
||||||
endpoints: { mcp: '/mcp', health: '/', sessions: '/sessions' },
|
endpoints: { mcp: '/mcp', health: '/health', sessions: '/sessions' },
|
||||||
auth: {
|
auth: { enabled: process.env.MCP_REQUIRE_AUTH === 'true' },
|
||||||
enabled: process.env.MCP_REQUIRE_AUTH === 'true',
|
tools: 22,
|
||||||
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',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Session status
|
|
||||||
app.get('/sessions', (req, res) => {
|
app.get('/sessions', (req, res) => {
|
||||||
const sessions = Object.values(userSessions).map((s) => ({
|
const list = [...sessions.values()].map(s => ({
|
||||||
id: s.id,
|
id: s.id, name: s.name, connectedAt: s.connectedAt,
|
||||||
name: s.name,
|
requestCount: s.requestCount || 0, authMethod: s.authMethod,
|
||||||
connectedAt: s.connectedAt,
|
|
||||||
lastSeen: s.lastSeen,
|
|
||||||
requestCount: s.requestCount || 0,
|
|
||||||
}));
|
}));
|
||||||
res.json({
|
res.json({ activeUsers: list.length, sessions: list, uptime: process.uptime() });
|
||||||
activeUsers: sessions.length,
|
|
||||||
sessions,
|
|
||||||
uptime: process.uptime(),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// MCP endpoint - Streamable HTTP
|
// MCP endpoint — Streamable HTTP
|
||||||
app.all('/mcp', async (req, res) => {
|
app.all('/mcp', async (req, res) => {
|
||||||
const sessionId = req.headers['mcp-session-id'];
|
const sessionId = req.headers['mcp-session-id'];
|
||||||
let transport;
|
let transport;
|
||||||
@@ -295,15 +240,15 @@ app.all('/mcp', async (req, res) => {
|
|||||||
transport = new StreamableHTTPServerTransport({
|
transport = new StreamableHTTPServerTransport({
|
||||||
sessionIdGenerator: () => randomUUID(),
|
sessionIdGenerator: () => randomUUID(),
|
||||||
onsessioninitialized: (id) => {
|
onsessioninitialized: (id) => {
|
||||||
log('debug', `Session initialized: ${id}`);
|
log('debug', `Session init: ${id}`);
|
||||||
transports[id] = transport;
|
transports[id] = transport;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
transport.onclose = () => {
|
transport.onclose = () => {
|
||||||
const sid = transport.sessionId;
|
const sid = transport.sessionId;
|
||||||
if (sid && transports[sid]) {
|
if (sid) {
|
||||||
log('debug', `Session closed: ${sid}`);
|
log('debug', `Session close: ${sid}`);
|
||||||
delete transports[sid];
|
delete transports[sid];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -311,79 +256,45 @@ app.all('/mcp', async (req, res) => {
|
|||||||
await server.connect(transport);
|
await server.connect(transport);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass authenticated userId to tool handlers via AsyncLocalStorage
|
|
||||||
const ctx = { userId: req.userSession?.userId || null };
|
const ctx = { userId: req.userSession?.userId || null };
|
||||||
await requestContext.run(ctx, async () => {
|
await requestContext.run(ctx, async () => {
|
||||||
await transport.handleRequest(req, res, req.body);
|
await transport.handleRequest(req, res, req.body);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Legacy /sse redirect for backward compat
|
// Legacy /sse → /mcp redirect
|
||||||
app.all('/sse', async (req, res) => {
|
app.all('/sse', (req, res) => {
|
||||||
// Redirect to /mcp
|
res.redirect(307, '/mcp');
|
||||||
req.url = '/mcp';
|
|
||||||
return app._router.handle(req, res, () => {
|
|
||||||
res.status(404).json({ error: 'Not found' });
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Start Server ────────────────────────────────────────────────────────────
|
const transports = {};
|
||||||
|
|
||||||
|
// ── Start ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.listen(PORT, '0.0.0.0', () => {
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`
|
console.log(`
|
||||||
╔═══════════════════════════════════════════════════════════════╗
|
╔═══════════════════════════════════════════════════════╗
|
||||||
║ Memento MCP Server v3.1.0 (Streamable HTTP) - Optimized ║
|
║ Memento MCP Server v3.2.0 (Streamable HTTP) ║
|
||||||
╚═══════════════════════════════════════════════════════════════╝
|
╚═══════════════════════════════════════════════════════╝
|
||||||
|
|
||||||
Server: http://localhost:${PORT}
|
Server: http://localhost:${PORT}
|
||||||
MCP: http://localhost:${PORT}/mcp
|
MCP: http://localhost:${PORT}/mcp
|
||||||
Health: http://localhost:${PORT}/
|
Auth: ${process.env.MCP_REQUIRE_AUTH === 'true' ? 'ENABLED' : 'DISABLED (dev)'}
|
||||||
Sessions: http://localhost:${PORT}/sessions
|
Timeout: ${REQUEST_TIMEOUT}ms
|
||||||
|
Database: ${isPostgres ? 'PostgreSQL' : 'SQLite'}
|
||||||
Database: ${databaseUrl}
|
Tools: 22
|
||||||
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
|
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Graceful shutdown
|
// ── Shutdown ─────────────────────────────────────────────────────────────────
|
||||||
process.on('SIGINT', async () => {
|
|
||||||
log('info', '\nShutting down MCP server...');
|
|
||||||
await prisma.$disconnect();
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on('SIGTERM', async () => {
|
async function shutdown() {
|
||||||
log('info', '\nShutting down MCP server...');
|
log('info', 'Shutting down...');
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
process.exit(0);
|
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));
|
||||||
|
|||||||
@@ -1,31 +1,19 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/**
|
/**
|
||||||
* Memento MCP Server - Stdio Transport (Optimized)
|
* Memento MCP Server - Stdio Transport
|
||||||
*
|
*
|
||||||
* Performance improvements:
|
* Environment:
|
||||||
* - Prisma connection pooling
|
* DATABASE_URL Prisma database URL
|
||||||
* - Prepared statements caching
|
* USER_ID Optional user ID filter
|
||||||
* - Optimized JSON serialization
|
* APP_BASE_URL Next.js app URL (default: http://localhost:3000)
|
||||||
* - Lazy user resolution
|
* MCP_LOG_LEVEL debug, info, warn, error (default: info)
|
||||||
*
|
|
||||||
* 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)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import { dirname, join } from 'path';
|
|
||||||
import { registerTools } from './tools.js';
|
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 LOG_LEVEL = process.env.MCP_LOG_LEVEL || 'info';
|
||||||
const logLevels = { debug: 0, info: 1, warn: 2, error: 3 };
|
const logLevels = { debug: 0, info: 1, warn: 2, error: 3 };
|
||||||
const currentLogLevel = logLevels[LOG_LEVEL] ?? 1;
|
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;
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
if (!databaseUrl) {
|
if (!databaseUrl) {
|
||||||
console.error('ERROR: DATABASE_URL environment variable is required');
|
console.error('ERROR: DATABASE_URL is required');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// OPTIMIZED: Prisma client with connection pooling and prepared statements
|
const isPostgres = databaseUrl.startsWith('postgresql://') || databaseUrl.startsWith('postgres://');
|
||||||
|
|
||||||
const prisma = new PrismaClient({
|
const prisma = new PrismaClient({
|
||||||
datasources: {
|
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'],
|
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(
|
const server = new Server(
|
||||||
{
|
{ name: 'memento-mcp-server', version: '3.2.0' },
|
||||||
name: 'memento-mcp-server',
|
{ capabilities: { tools: {} } },
|
||||||
version: '3.1.0',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
capabilities: { tools: {} },
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const appBaseUrl = process.env.APP_BASE_URL || 'http://localhost:3000';
|
const appBaseUrl = process.env.APP_BASE_URL || 'http://localhost:3000';
|
||||||
@@ -84,21 +52,19 @@ registerTools(server, prisma, {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
// Verify database connection on startup
|
try {
|
||||||
const connected = await checkConnection();
|
await prisma.$queryRaw`SELECT 1`;
|
||||||
if (!connected) {
|
} catch (error) {
|
||||||
console.error('FATAL: Could not connect to database');
|
console.error('FATAL: Database connection failed:', error.message);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const transport = new StdioServerTransport();
|
const transport = new StdioServerTransport();
|
||||||
await server.connect(transport);
|
await server.connect(transport);
|
||||||
|
|
||||||
log('info', `Memento MCP Server v3.1.0 (stdio) - Optimized`);
|
log('info', `Memento MCP Server v3.2.0 (stdio)`);
|
||||||
log('info', `Database: ${databaseUrl}`);
|
log('info', `Database: ${isPostgres ? 'PostgreSQL' : 'SQLite'}`);
|
||||||
log('info', `App URL: ${appBaseUrl}`);
|
log('info', `User filter: ${process.env.USER_ID || 'none'}`);
|
||||||
log('info', `User filter: ${process.env.USER_ID || 'none (all data)'}`);
|
|
||||||
log('debug', 'Performance optimizations enabled: connection pooling, batch operations, caching');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((error) => {
|
main().catch((error) => {
|
||||||
@@ -106,24 +72,13 @@ main().catch((error) => {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Graceful shutdown
|
async function shutdown() {
|
||||||
process.on('SIGINT', async () => {
|
log('info', 'Shutting down...');
|
||||||
log('info', 'Shutting down gracefully...');
|
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
}
|
||||||
|
|
||||||
process.on('SIGTERM', async () => {
|
process.on('SIGINT', shutdown);
|
||||||
log('info', 'Shutting down gracefully...');
|
process.on('SIGTERM', shutdown);
|
||||||
await prisma.$disconnect();
|
process.on('uncaughtException', (err) => log('error', 'Uncaught:', err.message));
|
||||||
process.exit(0);
|
process.on('unhandledRejection', (reason) => log('error', 'Unhandled:', reason));
|
||||||
});
|
|
||||||
|
|
||||||
// Handle uncaught errors
|
|
||||||
process.on('uncaughtException', (error) => {
|
|
||||||
log('error', 'Uncaught exception:', error.message);
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on('unhandledRejection', (reason) => {
|
|
||||||
log('error', 'Unhandled rejection:', reason);
|
|
||||||
});
|
|
||||||
|
|||||||
137
mcp-server/n8n-workflow-mcp-daily-digest.json
Normal file
137
mcp-server/n8n-workflow-mcp-daily-digest.json
Normal 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"
|
||||||
|
}
|
||||||
109
mcp-server/n8n-workflow-mcp-email-to-note.json
Normal file
109
mcp-server/n8n-workflow-mcp-email-to-note.json
Normal 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"
|
||||||
|
}
|
||||||
155
mcp-server/n8n-workflow-mcp-reminder-bot.json
Normal file
155
mcp-server/n8n-workflow-mcp-reminder-bot.json
Normal 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"
|
||||||
|
}
|
||||||
92
mcp-server/n8n-workflow-mcp-webhook-to-note.json
Normal file
92
mcp-server/n8n-workflow-mcp-webhook-to-note.json
Normal 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"
|
||||||
|
}
|
||||||
14
mcp-server/node_modules/.package-lock.json
generated
vendored
14
mcp-server/node_modules/.package-lock.json
generated
vendored
@@ -848,20 +848,6 @@
|
|||||||
"node": ">= 0.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": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
|
|||||||
158
mcp-server/node_modules/.prisma/client/edge.js
generated
vendored
158
mcp-server/node_modules/.prisma/client/edge.js
generated
vendored
File diff suppressed because one or more lines are too long
130
mcp-server/node_modules/.prisma/client/index-browser.js
generated
vendored
130
mcp-server/node_modules/.prisma/client/index-browser.js
generated
vendored
@@ -116,40 +116,26 @@ Prisma.NullTypes = {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
exports.Prisma.TransactionIsolationLevel = makeStrictEnum({
|
exports.Prisma.TransactionIsolationLevel = makeStrictEnum({
|
||||||
|
ReadUncommitted: 'ReadUncommitted',
|
||||||
|
ReadCommitted: 'ReadCommitted',
|
||||||
|
RepeatableRead: 'RepeatableRead',
|
||||||
Serializable: 'Serializable'
|
Serializable: 'Serializable'
|
||||||
});
|
});
|
||||||
|
|
||||||
exports.Prisma.NoteScalarFieldEnum = {
|
exports.Prisma.UserScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
title: 'title',
|
name: 'name',
|
||||||
content: 'content',
|
email: 'email',
|
||||||
color: 'color',
|
emailVerified: 'emailVerified',
|
||||||
isPinned: 'isPinned',
|
password: 'password',
|
||||||
isArchived: 'isArchived',
|
role: 'role',
|
||||||
type: 'type',
|
image: 'image',
|
||||||
checkItems: 'checkItems',
|
theme: 'theme',
|
||||||
labels: 'labels',
|
cardSizeMode: 'cardSizeMode',
|
||||||
images: 'images',
|
resetToken: 'resetToken',
|
||||||
links: 'links',
|
resetTokenExpiry: 'resetTokenExpiry',
|
||||||
reminder: 'reminder',
|
|
||||||
isReminderDone: 'isReminderDone',
|
|
||||||
reminderRecurrence: 'reminderRecurrence',
|
|
||||||
reminderLocation: 'reminderLocation',
|
|
||||||
isMarkdown: 'isMarkdown',
|
|
||||||
size: 'size',
|
|
||||||
embedding: 'embedding',
|
|
||||||
sharedWith: 'sharedWith',
|
|
||||||
userId: 'userId',
|
|
||||||
order: 'order',
|
|
||||||
notebookId: 'notebookId',
|
|
||||||
createdAt: 'createdAt',
|
createdAt: 'createdAt',
|
||||||
updatedAt: 'updatedAt',
|
updatedAt: 'updatedAt'
|
||||||
autoGenerated: 'autoGenerated',
|
|
||||||
aiProvider: 'aiProvider',
|
|
||||||
aiConfidence: 'aiConfidence',
|
|
||||||
language: 'language',
|
|
||||||
languageConfidence: 'languageConfidence',
|
|
||||||
lastAiAnalysis: 'lastAiAnalysis'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.Prisma.NotebookScalarFieldEnum = {
|
exports.Prisma.NotebookScalarFieldEnum = {
|
||||||
@@ -173,17 +159,57 @@ exports.Prisma.LabelScalarFieldEnum = {
|
|||||||
updatedAt: 'updatedAt'
|
updatedAt: 'updatedAt'
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.Prisma.UserScalarFieldEnum = {
|
exports.Prisma.NoteScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
name: 'name',
|
title: 'title',
|
||||||
email: 'email',
|
content: 'content',
|
||||||
emailVerified: 'emailVerified',
|
color: 'color',
|
||||||
password: 'password',
|
isPinned: 'isPinned',
|
||||||
role: 'role',
|
isArchived: 'isArchived',
|
||||||
image: 'image',
|
trashedAt: 'trashedAt',
|
||||||
theme: 'theme',
|
type: 'type',
|
||||||
resetToken: 'resetToken',
|
dismissedFromRecent: 'dismissedFromRecent',
|
||||||
resetTokenExpiry: 'resetTokenExpiry',
|
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',
|
createdAt: 'createdAt',
|
||||||
updatedAt: 'updatedAt'
|
updatedAt: 'updatedAt'
|
||||||
};
|
};
|
||||||
@@ -218,19 +244,6 @@ exports.Prisma.VerificationTokenScalarFieldEnum = {
|
|||||||
expires: 'expires'
|
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 = {
|
exports.Prisma.SystemConfigScalarFieldEnum = {
|
||||||
key: 'key',
|
key: 'key',
|
||||||
value: 'value'
|
value: 'value'
|
||||||
@@ -273,6 +286,7 @@ exports.Prisma.UserAISettingsScalarFieldEnum = {
|
|||||||
fontSize: 'fontSize',
|
fontSize: 'fontSize',
|
||||||
demoMode: 'demoMode',
|
demoMode: 'demoMode',
|
||||||
showRecentNotes: 'showRecentNotes',
|
showRecentNotes: 'showRecentNotes',
|
||||||
|
notesViewMode: 'notesViewMode',
|
||||||
emailNotifications: 'emailNotifications',
|
emailNotifications: 'emailNotifications',
|
||||||
desktopNotifications: 'desktopNotifications',
|
desktopNotifications: 'desktopNotifications',
|
||||||
anonymousAnalytics: 'anonymousAnalytics'
|
anonymousAnalytics: 'anonymousAnalytics'
|
||||||
@@ -283,6 +297,11 @@ exports.Prisma.SortOrder = {
|
|||||||
desc: 'desc'
|
desc: 'desc'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
exports.Prisma.QueryMode = {
|
||||||
|
default: 'default',
|
||||||
|
insensitive: 'insensitive'
|
||||||
|
};
|
||||||
|
|
||||||
exports.Prisma.NullsOrder = {
|
exports.Prisma.NullsOrder = {
|
||||||
first: 'first',
|
first: 'first',
|
||||||
last: 'last'
|
last: 'last'
|
||||||
@@ -290,14 +309,15 @@ exports.Prisma.NullsOrder = {
|
|||||||
|
|
||||||
|
|
||||||
exports.Prisma.ModelName = {
|
exports.Prisma.ModelName = {
|
||||||
Note: 'Note',
|
User: 'User',
|
||||||
Notebook: 'Notebook',
|
Notebook: 'Notebook',
|
||||||
Label: 'Label',
|
Label: 'Label',
|
||||||
User: 'User',
|
Note: 'Note',
|
||||||
|
NoteEmbedding: 'NoteEmbedding',
|
||||||
|
NoteShare: 'NoteShare',
|
||||||
Account: 'Account',
|
Account: 'Account',
|
||||||
Session: 'Session',
|
Session: 'Session',
|
||||||
VerificationToken: 'VerificationToken',
|
VerificationToken: 'VerificationToken',
|
||||||
NoteShare: 'NoteShare',
|
|
||||||
SystemConfig: 'SystemConfig',
|
SystemConfig: 'SystemConfig',
|
||||||
AiFeedback: 'AiFeedback',
|
AiFeedback: 'AiFeedback',
|
||||||
MemoryEchoInsight: 'MemoryEchoInsight',
|
MemoryEchoInsight: 'MemoryEchoInsight',
|
||||||
|
|||||||
15128
mcp-server/node_modules/.prisma/client/index.d.ts
generated
vendored
15128
mcp-server/node_modules/.prisma/client/index.d.ts
generated
vendored
File diff suppressed because it is too large
Load Diff
162
mcp-server/node_modules/.prisma/client/index.js
generated
vendored
162
mcp-server/node_modules/.prisma/client/index.js
generated
vendored
File diff suppressed because one or more lines are too long
2
mcp-server/node_modules/.prisma/client/package.json
generated
vendored
2
mcp-server/node_modules/.prisma/client/package.json
generated
vendored
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "prisma-client-07b35a59db17a461d4c7b787cc433edb9e7b79a627ae71660fd00cce5311cf75",
|
"name": "prisma-client-8c3c28a242bf05b03713c0c3d78783f929261d76a15352bcfc52a1cfa1e7f92a",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"types": "index.d.ts",
|
"types": "index.d.ts",
|
||||||
"browser": "index-browser.js",
|
"browser": "index-browser.js",
|
||||||
|
|||||||
201
mcp-server/node_modules/.prisma/client/schema.prisma
generated
vendored
201
mcp-server/node_modules/.prisma/client/schema.prisma
generated
vendored
@@ -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 {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
output = "../node_modules/.prisma/client"
|
output = "../node_modules/.prisma/client"
|
||||||
|
binaryTargets = ["linux-musl-openssl-3.0.x", "native"]
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "sqlite"
|
provider = "postgresql"
|
||||||
url = "file:../../memento-note/prisma/dev.db"
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Note {
|
// ── Core models (used by MCP tools) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
title String?
|
name String?
|
||||||
content String
|
email String @unique
|
||||||
color String @default("default")
|
emailVerified DateTime?
|
||||||
isPinned Boolean @default(false)
|
password String?
|
||||||
isArchived Boolean @default(false)
|
role String @default("USER")
|
||||||
type String @default("text")
|
image String?
|
||||||
checkItems String?
|
theme String @default("light")
|
||||||
labels String?
|
cardSizeMode String @default("variable")
|
||||||
images String?
|
resetToken String? @unique
|
||||||
links String?
|
resetTokenExpiry DateTime?
|
||||||
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())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
autoGenerated Boolean?
|
accounts Account[]
|
||||||
aiProvider String?
|
aiFeedback AiFeedback[]
|
||||||
aiConfidence Int?
|
labels Label[]
|
||||||
language String?
|
memoryEchoInsights MemoryEchoInsight[]
|
||||||
languageConfidence Float?
|
notes Note[]
|
||||||
lastAiAnalysis DateTime?
|
sentShares NoteShare[] @relation("SentShares")
|
||||||
|
receivedShares NoteShare[] @relation("ReceivedShares")
|
||||||
|
notebooks Notebook[]
|
||||||
|
sessions Session[]
|
||||||
|
aiSettings UserAISettings?
|
||||||
}
|
}
|
||||||
|
|
||||||
model Notebook {
|
model Notebook {
|
||||||
@@ -50,6 +52,12 @@ model Notebook {
|
|||||||
userId String
|
userId String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
labels Label[]
|
||||||
|
notes Note[]
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId, order])
|
||||||
|
@@index([userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Label {
|
model Label {
|
||||||
@@ -60,23 +68,99 @@ model Label {
|
|||||||
userId String?
|
userId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
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 {
|
model Note {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String?
|
title String?
|
||||||
email String @unique
|
content String
|
||||||
emailVerified DateTime?
|
color String @default("default")
|
||||||
password String?
|
isPinned Boolean @default(false)
|
||||||
role String @default("USER")
|
isArchived Boolean @default(false)
|
||||||
image String?
|
trashedAt DateTime?
|
||||||
theme String @default("light")
|
type String @default("text")
|
||||||
resetToken String? @unique
|
dismissedFromRecent Boolean @default(false)
|
||||||
resetTokenExpiry DateTime?
|
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())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
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 {
|
model Account {
|
||||||
userId String
|
userId String
|
||||||
type String
|
type String
|
||||||
@@ -91,6 +175,7 @@ model Account {
|
|||||||
session_state String?
|
session_state String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@id([provider, providerAccountId])
|
@@id([provider, providerAccountId])
|
||||||
}
|
}
|
||||||
@@ -101,6 +186,7 @@ model Session {
|
|||||||
expires DateTime
|
expires DateTime
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|
||||||
model VerificationToken {
|
model VerificationToken {
|
||||||
@@ -111,21 +197,6 @@ model VerificationToken {
|
|||||||
@@id([identifier, token])
|
@@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 {
|
model SystemConfig {
|
||||||
key String @id
|
key String @id
|
||||||
value String
|
value String
|
||||||
@@ -141,6 +212,12 @@ model AiFeedback {
|
|||||||
correctedContent String?
|
correctedContent String?
|
||||||
metadata String?
|
metadata String?
|
||||||
createdAt DateTime @default(now())
|
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 {
|
model MemoryEchoInsight {
|
||||||
@@ -154,8 +231,13 @@ model MemoryEchoInsight {
|
|||||||
viewed Boolean @default(false)
|
viewed Boolean @default(false)
|
||||||
feedback String?
|
feedback String?
|
||||||
dismissed Boolean @default(false)
|
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])
|
@@unique([userId, insightDate])
|
||||||
|
@@index([userId, insightDate])
|
||||||
|
@@index([userId, dismissed])
|
||||||
}
|
}
|
||||||
|
|
||||||
model UserAISettings {
|
model UserAISettings {
|
||||||
@@ -169,8 +251,15 @@ model UserAISettings {
|
|||||||
preferredLanguage String @default("auto")
|
preferredLanguage String @default("auto")
|
||||||
fontSize String @default("medium")
|
fontSize String @default("medium")
|
||||||
demoMode Boolean @default(false)
|
demoMode Boolean @default(false)
|
||||||
showRecentNotes Boolean @default(false)
|
showRecentNotes Boolean @default(true)
|
||||||
|
notesViewMode String @default("masonry")
|
||||||
emailNotifications Boolean @default(false)
|
emailNotifications Boolean @default(false)
|
||||||
desktopNotifications Boolean @default(false)
|
desktopNotifications Boolean @default(false)
|
||||||
anonymousAnalytics Boolean @default(false)
|
anonymousAnalytics Boolean @default(false)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([memoryEcho])
|
||||||
|
@@index([aiProvider])
|
||||||
|
@@index([memoryEchoFrequency])
|
||||||
|
@@index([preferredLanguage])
|
||||||
}
|
}
|
||||||
|
|||||||
130
mcp-server/node_modules/.prisma/client/wasm.js
generated
vendored
130
mcp-server/node_modules/.prisma/client/wasm.js
generated
vendored
@@ -116,40 +116,26 @@ Prisma.NullTypes = {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
exports.Prisma.TransactionIsolationLevel = makeStrictEnum({
|
exports.Prisma.TransactionIsolationLevel = makeStrictEnum({
|
||||||
|
ReadUncommitted: 'ReadUncommitted',
|
||||||
|
ReadCommitted: 'ReadCommitted',
|
||||||
|
RepeatableRead: 'RepeatableRead',
|
||||||
Serializable: 'Serializable'
|
Serializable: 'Serializable'
|
||||||
});
|
});
|
||||||
|
|
||||||
exports.Prisma.NoteScalarFieldEnum = {
|
exports.Prisma.UserScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
title: 'title',
|
name: 'name',
|
||||||
content: 'content',
|
email: 'email',
|
||||||
color: 'color',
|
emailVerified: 'emailVerified',
|
||||||
isPinned: 'isPinned',
|
password: 'password',
|
||||||
isArchived: 'isArchived',
|
role: 'role',
|
||||||
type: 'type',
|
image: 'image',
|
||||||
checkItems: 'checkItems',
|
theme: 'theme',
|
||||||
labels: 'labels',
|
cardSizeMode: 'cardSizeMode',
|
||||||
images: 'images',
|
resetToken: 'resetToken',
|
||||||
links: 'links',
|
resetTokenExpiry: 'resetTokenExpiry',
|
||||||
reminder: 'reminder',
|
|
||||||
isReminderDone: 'isReminderDone',
|
|
||||||
reminderRecurrence: 'reminderRecurrence',
|
|
||||||
reminderLocation: 'reminderLocation',
|
|
||||||
isMarkdown: 'isMarkdown',
|
|
||||||
size: 'size',
|
|
||||||
embedding: 'embedding',
|
|
||||||
sharedWith: 'sharedWith',
|
|
||||||
userId: 'userId',
|
|
||||||
order: 'order',
|
|
||||||
notebookId: 'notebookId',
|
|
||||||
createdAt: 'createdAt',
|
createdAt: 'createdAt',
|
||||||
updatedAt: 'updatedAt',
|
updatedAt: 'updatedAt'
|
||||||
autoGenerated: 'autoGenerated',
|
|
||||||
aiProvider: 'aiProvider',
|
|
||||||
aiConfidence: 'aiConfidence',
|
|
||||||
language: 'language',
|
|
||||||
languageConfidence: 'languageConfidence',
|
|
||||||
lastAiAnalysis: 'lastAiAnalysis'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.Prisma.NotebookScalarFieldEnum = {
|
exports.Prisma.NotebookScalarFieldEnum = {
|
||||||
@@ -173,17 +159,57 @@ exports.Prisma.LabelScalarFieldEnum = {
|
|||||||
updatedAt: 'updatedAt'
|
updatedAt: 'updatedAt'
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.Prisma.UserScalarFieldEnum = {
|
exports.Prisma.NoteScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
name: 'name',
|
title: 'title',
|
||||||
email: 'email',
|
content: 'content',
|
||||||
emailVerified: 'emailVerified',
|
color: 'color',
|
||||||
password: 'password',
|
isPinned: 'isPinned',
|
||||||
role: 'role',
|
isArchived: 'isArchived',
|
||||||
image: 'image',
|
trashedAt: 'trashedAt',
|
||||||
theme: 'theme',
|
type: 'type',
|
||||||
resetToken: 'resetToken',
|
dismissedFromRecent: 'dismissedFromRecent',
|
||||||
resetTokenExpiry: 'resetTokenExpiry',
|
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',
|
createdAt: 'createdAt',
|
||||||
updatedAt: 'updatedAt'
|
updatedAt: 'updatedAt'
|
||||||
};
|
};
|
||||||
@@ -218,19 +244,6 @@ exports.Prisma.VerificationTokenScalarFieldEnum = {
|
|||||||
expires: 'expires'
|
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 = {
|
exports.Prisma.SystemConfigScalarFieldEnum = {
|
||||||
key: 'key',
|
key: 'key',
|
||||||
value: 'value'
|
value: 'value'
|
||||||
@@ -273,6 +286,7 @@ exports.Prisma.UserAISettingsScalarFieldEnum = {
|
|||||||
fontSize: 'fontSize',
|
fontSize: 'fontSize',
|
||||||
demoMode: 'demoMode',
|
demoMode: 'demoMode',
|
||||||
showRecentNotes: 'showRecentNotes',
|
showRecentNotes: 'showRecentNotes',
|
||||||
|
notesViewMode: 'notesViewMode',
|
||||||
emailNotifications: 'emailNotifications',
|
emailNotifications: 'emailNotifications',
|
||||||
desktopNotifications: 'desktopNotifications',
|
desktopNotifications: 'desktopNotifications',
|
||||||
anonymousAnalytics: 'anonymousAnalytics'
|
anonymousAnalytics: 'anonymousAnalytics'
|
||||||
@@ -283,6 +297,11 @@ exports.Prisma.SortOrder = {
|
|||||||
desc: 'desc'
|
desc: 'desc'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
exports.Prisma.QueryMode = {
|
||||||
|
default: 'default',
|
||||||
|
insensitive: 'insensitive'
|
||||||
|
};
|
||||||
|
|
||||||
exports.Prisma.NullsOrder = {
|
exports.Prisma.NullsOrder = {
|
||||||
first: 'first',
|
first: 'first',
|
||||||
last: 'last'
|
last: 'last'
|
||||||
@@ -290,14 +309,15 @@ exports.Prisma.NullsOrder = {
|
|||||||
|
|
||||||
|
|
||||||
exports.Prisma.ModelName = {
|
exports.Prisma.ModelName = {
|
||||||
Note: 'Note',
|
User: 'User',
|
||||||
Notebook: 'Notebook',
|
Notebook: 'Notebook',
|
||||||
Label: 'Label',
|
Label: 'Label',
|
||||||
User: 'User',
|
Note: 'Note',
|
||||||
|
NoteEmbedding: 'NoteEmbedding',
|
||||||
|
NoteShare: 'NoteShare',
|
||||||
Account: 'Account',
|
Account: 'Account',
|
||||||
Session: 'Session',
|
Session: 'Session',
|
||||||
VerificationToken: 'VerificationToken',
|
VerificationToken: 'VerificationToken',
|
||||||
NoteShare: 'NoteShare',
|
|
||||||
SystemConfig: 'SystemConfig',
|
SystemConfig: 'SystemConfig',
|
||||||
AiFeedback: 'AiFeedback',
|
AiFeedback: 'AiFeedback',
|
||||||
MemoryEchoInsight: 'MemoryEchoInsight',
|
MemoryEchoInsight: 'MemoryEchoInsight',
|
||||||
|
|||||||
1
mcp-server/package-lock.json
generated
1
mcp-server/package-lock.json
generated
@@ -870,6 +870,7 @@
|
|||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Memento MCP Server - Optimized Tool Definitions & Handlers
|
* Memento MCP Server - Tool Definitions & Handlers
|
||||||
*
|
*
|
||||||
* Performance optimizations:
|
* Fast, minimal overhead. All queries filter trashed notes.
|
||||||
* - O(1) API key lookup with caching
|
* Compact JSON output. Direct DB lookups.
|
||||||
* - Batch operations for imports
|
|
||||||
* - Parallel promise execution
|
|
||||||
* - HTTP timeout wrapper
|
|
||||||
* - N+1 query fixes
|
|
||||||
* - Connection pooling
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -18,12 +13,12 @@ import {
|
|||||||
} from '@modelcontextprotocol/sdk/types.js';
|
} from '@modelcontextprotocol/sdk/types.js';
|
||||||
import { requestContext } from './request-context.js';
|
import { requestContext } from './request-context.js';
|
||||||
|
|
||||||
// ─── Configuration ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const DEFAULT_SEARCH_LIMIT = 50;
|
const DEFAULT_SEARCH_LIMIT = 50;
|
||||||
const DEFAULT_NOTES_LIMIT = 100;
|
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) {
|
export function parseNote(dbNote) {
|
||||||
if (!dbNote) return null;
|
if (!dbNote) return null;
|
||||||
@@ -66,65 +61,65 @@ export function parseNoteLightweight(dbNote) {
|
|||||||
|
|
||||||
function textResult(data) {
|
function textResult(data) {
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
|
content: [{ type: 'text', text: JSON.stringify(data) }],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Tool Schemas ───────────────────────────────────────────────────────────
|
// ─── Tool Definitions ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
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'];
|
|
||||||
|
|
||||||
const toolDefinitions = [
|
const toolDefinitions = [
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══ NOTES ═══
|
||||||
// NOTE TOOLS
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
{
|
{
|
||||||
name: 'create_note',
|
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: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
title: { type: 'string', description: 'Note title (optional)' },
|
title: { type: 'string', description: 'Note title' },
|
||||||
content: { type: 'string', description: 'Note content (required)' },
|
content: { type: 'string', description: 'Note body text (required)' },
|
||||||
color: { type: 'string', description: `Note color: ${NOTE_COLORS.join(', ')}`, default: 'default' },
|
color: { type: 'string', description: `Color: ${NOTE_COLORS}`, default: 'default' },
|
||||||
type: { type: 'string', enum: ['text', 'checklist'], description: 'Note type', default: 'text' },
|
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: {
|
checkItems: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
description: 'Checklist items (when type is checklist)',
|
description: 'Checklist items (when type=checklist)',
|
||||||
items: {
|
items: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: { id: { type: 'string' }, text: { type: 'string' }, checked: { type: 'boolean' } },
|
properties: { id: { type: 'string' }, text: { type: 'string' }, checked: { type: 'boolean' } },
|
||||||
required: ['id', 'text', 'checked'],
|
required: ['id', 'text', 'checked'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
labels: { type: 'array', description: 'Note labels/tags', items: { type: 'string' } },
|
labels: { type: 'array', description: 'Tags/labels', items: { type: 'string' } },
|
||||||
isPinned: { type: 'boolean', description: 'Pin the note', default: false },
|
isPinned: { type: 'boolean', description: 'Pin to top', default: false },
|
||||||
isArchived: { type: 'boolean', description: 'Create as archived', default: false },
|
isArchived: { type: 'boolean', description: 'Create as archived', default: false },
|
||||||
images: { type: 'array', description: 'Image URLs or base64 strings', items: { type: 'string' } },
|
images: { type: 'array', description: 'Image URLs', items: { type: 'string' } },
|
||||||
links: { type: 'array', description: 'URLs attached to the note', items: { type: 'string' } },
|
links: { type: 'array', description: 'Attached URLs', items: { type: 'string' } },
|
||||||
reminder: { type: 'string', description: 'Reminder datetime (ISO 8601)' },
|
reminder: { type: 'string', description: 'Reminder datetime (ISO 8601)' },
|
||||||
isReminderDone: { type: 'boolean', default: false },
|
isReminderDone: { type: 'boolean', default: false },
|
||||||
reminderRecurrence: { type: 'string', description: 'Recurrence: daily, weekly, monthly, yearly' },
|
reminderRecurrence: { type: 'string', description: 'daily, weekly, monthly, yearly' },
|
||||||
reminderLocation: { type: 'string', description: 'Location-based reminder' },
|
reminderLocation: { type: 'string', description: 'Location string' },
|
||||||
isMarkdown: { type: 'boolean', description: 'Enable markdown rendering', default: false },
|
isMarkdown: { type: 'boolean', description: '(Deprecated — use type="markdown" instead) Render as markdown', default: false },
|
||||||
size: { type: 'string', enum: ['small', 'medium', 'large'], default: 'small' },
|
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'],
|
required: ['content'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'get_notes',
|
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: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
includeArchived: { type: 'boolean', description: 'Include archived notes', default: false },
|
includeArchived: { type: 'boolean', description: 'Include archived notes', default: false },
|
||||||
search: { type: 'string', description: 'Filter by keyword in title/content' },
|
search: { type: 'string', description: 'Keyword filter on title/content' },
|
||||||
notebookId: { type: 'string', description: 'Filter by notebook ID. Use "inbox" for notes without a notebook' },
|
notebookId: { type: 'string', description: 'Filter by notebook. Use "inbox" for unfiled notes.' },
|
||||||
fullDetails: { type: 'boolean', description: 'Return full details including images (large payload)', default: false },
|
fullDetails: { type: 'boolean', description: 'Full payload including images', default: false },
|
||||||
limit: { type: 'number', description: `Max notes to return (default ${DEFAULT_NOTES_LIMIT})`, default: DEFAULT_NOTES_LIMIT },
|
limit: { type: 'number', description: `Max results (default ${DEFAULT_NOTES_LIMIT})`, default: DEFAULT_NOTES_LIMIT },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -139,15 +134,19 @@ const toolDefinitions = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'update_note',
|
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: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
id: { type: 'string', description: 'Note ID' },
|
id: { type: 'string', description: 'Note ID' },
|
||||||
title: { type: 'string' },
|
title: { type: 'string' },
|
||||||
content: { type: 'string' },
|
content: { type: 'string' },
|
||||||
color: { type: 'string', description: `One of: ${NOTE_COLORS.join(', ')}` },
|
color: { type: 'string', description: `One of: ${NOTE_COLORS}` },
|
||||||
type: { type: 'string', enum: ['text', 'checklist'] },
|
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: {
|
checkItems: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
items: {
|
items: {
|
||||||
@@ -164,7 +163,7 @@ const toolDefinitions = [
|
|||||||
isReminderDone: { type: 'boolean' },
|
isReminderDone: { type: 'boolean' },
|
||||||
reminderRecurrence: { type: 'string' },
|
reminderRecurrence: { type: 'string' },
|
||||||
reminderLocation: { type: 'string' },
|
reminderLocation: { type: 'string' },
|
||||||
isMarkdown: { type: 'boolean' },
|
isMarkdown: { type: 'boolean', description: '(Deprecated — use type="markdown" instead)' },
|
||||||
size: { type: 'string', enum: ['small', 'medium', 'large'] },
|
size: { type: 'string', enum: ['small', 'medium', 'large'] },
|
||||||
notebookId: { type: 'string' },
|
notebookId: { type: 'string' },
|
||||||
},
|
},
|
||||||
@@ -173,7 +172,7 @@ const toolDefinitions = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'delete_note',
|
name: 'delete_note',
|
||||||
description: 'Delete a note by ID.',
|
description: 'Permanently delete a note.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: { id: { type: 'string', description: 'Note ID' } },
|
properties: { id: { type: 'string', description: 'Note ID' } },
|
||||||
@@ -187,7 +186,7 @@ const toolDefinitions = [
|
|||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
query: { type: 'string', description: 'Search query' },
|
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 },
|
includeArchived: { type: 'boolean', default: false },
|
||||||
},
|
},
|
||||||
required: ['query'],
|
required: ['query'],
|
||||||
@@ -195,12 +194,12 @@ const toolDefinitions = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'move_note',
|
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: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
id: { type: 'string', description: 'Note ID' },
|
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'],
|
required: ['id'],
|
||||||
},
|
},
|
||||||
@@ -225,18 +224,18 @@ const toolDefinitions = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'export_notes',
|
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: {} },
|
inputSchema: { type: 'object', properties: {} },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'import_notes',
|
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: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
data: {
|
data: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
description: 'The exported JSON data (from export_notes)',
|
description: 'Export JSON (from export_notes)',
|
||||||
properties: {
|
properties: {
|
||||||
version: { type: 'string' },
|
version: { type: 'string' },
|
||||||
data: {
|
data: {
|
||||||
@@ -254,31 +253,29 @@ const toolDefinitions = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══ NOTEBOOKS ═══
|
||||||
// NOTEBOOK TOOLS
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
{
|
{
|
||||||
name: 'create_notebook',
|
name: 'create_notebook',
|
||||||
description: 'Create a new notebook.',
|
description: 'Create a notebook with a name, icon, and color.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
name: { type: 'string', description: 'Notebook name' },
|
name: { type: 'string', description: 'Notebook name' },
|
||||||
icon: { type: 'string', description: 'Notebook icon (emoji)', default: '📁' },
|
icon: { type: 'string', description: 'Icon (emoji)', default: '📁' },
|
||||||
color: { type: 'string', description: 'Hex color code', default: '#3B82F6' },
|
color: { type: 'string', description: 'Hex color', default: '#3B82F6' },
|
||||||
order: { type: 'number', description: 'Sort position (auto-assigned if omitted)' },
|
order: { type: 'number', description: 'Sort position (auto if omitted)' },
|
||||||
},
|
},
|
||||||
required: ['name'],
|
required: ['name'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'get_notebooks',
|
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: {} },
|
inputSchema: { type: 'object', properties: {} },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'get_notebook',
|
name: 'get_notebook',
|
||||||
description: 'Get a notebook by ID, including its notes.',
|
description: 'Get a notebook by ID including its notes.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: { id: { type: 'string', description: 'Notebook ID' } },
|
properties: { id: { type: 'string', description: 'Notebook ID' } },
|
||||||
@@ -287,7 +284,7 @@ const toolDefinitions = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'update_notebook',
|
name: 'update_notebook',
|
||||||
description: 'Update a notebook\'s name, icon, color, or order.',
|
description: 'Update a notebook name, icon, color, or order.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -302,7 +299,7 @@ const toolDefinitions = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'delete_notebook',
|
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: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: { id: { type: 'string', description: 'Notebook ID' } },
|
properties: { id: { type: 'string', description: 'Notebook ID' } },
|
||||||
@@ -311,13 +308,13 @@ const toolDefinitions = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'reorder_notebooks',
|
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: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
notebookIds: {
|
notebookIds: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
description: 'Notebook IDs in the desired order',
|
description: 'Notebook IDs in desired order',
|
||||||
items: { type: 'string' },
|
items: { type: 'string' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -325,35 +322,33 @@ const toolDefinitions = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══ LABELS ═══
|
||||||
// LABEL TOOLS
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
{
|
{
|
||||||
name: 'create_label',
|
name: 'create_label',
|
||||||
description: 'Create a label in a notebook.',
|
description: 'Create a label inside a notebook.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
name: { type: 'string', description: 'Label name' },
|
name: { type: 'string', description: 'Label name' },
|
||||||
color: { type: 'string', description: `Label color: ${LABEL_COLORS.join(', ')}` },
|
color: { type: 'string', description: `Color: ${LABEL_COLORS}` },
|
||||||
notebookId: { type: 'string', description: 'Notebook to attach the label to' },
|
notebookId: { type: 'string', description: 'Parent notebook ID' },
|
||||||
},
|
},
|
||||||
required: ['name', 'notebookId'],
|
required: ['name', 'notebookId'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'get_labels',
|
name: 'get_labels',
|
||||||
description: 'Get all labels, optionally filtered by notebook.',
|
description: 'List labels, optionally filtered by notebook.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
notebookId: { type: 'string', description: 'Filter labels by notebook ID' },
|
notebookId: { type: 'string', description: 'Filter by notebook' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'update_label',
|
name: 'update_label',
|
||||||
description: 'Update a label\'s name or color.',
|
description: 'Update a label name or color.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -366,7 +361,7 @@ const toolDefinitions = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'delete_label',
|
name: 'delete_label',
|
||||||
description: 'Delete a label by ID.',
|
description: 'Delete a label.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: { id: { type: 'string', description: 'Label ID' } },
|
properties: { id: { type: 'string', description: 'Label ID' } },
|
||||||
@@ -374,33 +369,23 @@ const toolDefinitions = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══ REMINDERS ═══
|
||||||
// REMINDER TOOLS
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
{
|
{
|
||||||
name: 'get_due_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: {} },
|
inputSchema: { type: 'object', properties: {} },
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// ─── Tool Handlers ──────────────────────────────────────────────────────────
|
// ─── 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) {
|
export function registerTools(server, prisma) {
|
||||||
|
|
||||||
// Resolve userId per-request from AsyncLocalStorage (set by auth middleware)
|
|
||||||
const getResolvedUserId = () => {
|
const getResolvedUserId = () => {
|
||||||
const store = requestContext.getStore();
|
const store = requestContext.getStore();
|
||||||
return store?.userId || null;
|
return store?.userId || null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fallback: auto-detect first user when no auth context
|
|
||||||
let fallbackUserId = null;
|
let fallbackUserId = null;
|
||||||
let fallbackPromise = null;
|
let fallbackPromise = null;
|
||||||
|
|
||||||
@@ -418,22 +403,24 @@ export function registerTools(server, prisma) {
|
|||||||
return fallbackPromise;
|
return fallbackPromise;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── List Tools ────────────────────────────────────────────────────────────
|
const noteWhere = (resolvedUserId, extra = {}) => ({
|
||||||
|
trashedAt: null,
|
||||||
|
...(resolvedUserId ? { userId: resolvedUserId } : {}),
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||||
return { tools: toolDefinitions };
|
return { tools: toolDefinitions };
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Call Tools ────────────────────────────────────────────────────────────
|
|
||||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||||
const { name, arguments: args } = request.params;
|
const { name, arguments: args } = request.params;
|
||||||
const resolvedUserId = getResolvedUserId();
|
const uid = getResolvedUserId();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (name) {
|
switch (name) {
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════
|
// ═══ NOTES ═══
|
||||||
// NOTES
|
|
||||||
// ═══════════════════════════════════════════════════════
|
|
||||||
case 'create_note': {
|
case 'create_note': {
|
||||||
const data = {
|
const data = {
|
||||||
title: args.title || null,
|
title: args.title || null,
|
||||||
@@ -453,31 +440,29 @@ export function registerTools(server, prisma) {
|
|||||||
isMarkdown: args.isMarkdown || false,
|
isMarkdown: args.isMarkdown || false,
|
||||||
size: args.size || 'small',
|
size: args.size || 'small',
|
||||||
notebookId: args.notebookId || null,
|
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 });
|
const note = await prisma.note.create({ data });
|
||||||
return textResult(parseNote(note));
|
return textResult(parseNote(note));
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'get_notes': {
|
case 'get_notes': {
|
||||||
const where = {};
|
const extra = {};
|
||||||
if (resolvedUserId) where.userId = resolvedUserId;
|
if (!args?.includeArchived) extra.isArchived = false;
|
||||||
if (!args?.includeArchived) where.isArchived = false;
|
|
||||||
if (args?.search) {
|
if (args?.search) {
|
||||||
where.OR = [
|
extra.OR = [
|
||||||
{ title: { contains: args.search } },
|
{ title: { contains: args.search } },
|
||||||
{ content: { contains: args.search } },
|
{ content: { contains: args.search } },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
if (args?.notebookId) {
|
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({
|
const notes = await prisma.note.findMany({
|
||||||
where,
|
where: noteWhere(uid, extra),
|
||||||
orderBy: [{ isPinned: 'desc' }, { order: 'asc' }, { updatedAt: 'desc' }],
|
orderBy: [{ isPinned: 'desc' }, { order: 'asc' }, { updatedAt: 'desc' }],
|
||||||
take: limit,
|
take: limit,
|
||||||
});
|
});
|
||||||
@@ -487,50 +472,50 @@ export function registerTools(server, prisma) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'get_note': {
|
case 'get_note': {
|
||||||
const where = { id: args.id };
|
const note = await prisma.note.findUnique({
|
||||||
if (resolvedUserId) where.userId = resolvedUserId;
|
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
|
||||||
const note = await prisma.note.findUnique({ where });
|
});
|
||||||
if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
|
if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
|
||||||
return textResult(parseNote(note));
|
return textResult(parseNote(note));
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'update_note': {
|
case 'update_note': {
|
||||||
const updateData = {};
|
const d = {};
|
||||||
const fields = ['title', 'color', 'type', 'isPinned', 'isArchived', 'isMarkdown', 'size', 'notebookId', 'isReminderDone', 'reminderRecurrence', 'reminderLocation'];
|
const simpleFields = ['title', 'color', 'type', 'isPinned', 'isArchived', 'isMarkdown', 'size', 'notebookId', 'isReminderDone', 'reminderRecurrence', 'reminderLocation'];
|
||||||
for (const f of fields) {
|
for (const f of simpleFields) {
|
||||||
if (f in args) updateData[f] = args[f];
|
if (f in args) d[f] = args[f];
|
||||||
}
|
}
|
||||||
if ('content' in args) updateData.content = args.content;
|
if ('content' in args) d.content = args.content;
|
||||||
if ('checkItems' in args) updateData.checkItems = args.checkItems ?? null;
|
if ('checkItems' in args) d.checkItems = args.checkItems ?? null;
|
||||||
if ('labels' in args) updateData.labels = args.labels ?? null;
|
if ('labels' in args) d.labels = args.labels ?? null;
|
||||||
if ('images' in args) updateData.images = args.images ?? null;
|
if ('images' in args) d.images = args.images ?? null;
|
||||||
if ('links' in args) updateData.links = args.links ?? null;
|
if ('links' in args) d.links = args.links ?? null;
|
||||||
if ('reminder' in args) updateData.reminder = args.reminder ? new Date(args.reminder) : null;
|
if ('reminder' in args) d.reminder = args.reminder ? new Date(args.reminder) : null;
|
||||||
updateData.updatedAt = new Date();
|
d.updatedAt = new Date();
|
||||||
|
|
||||||
const where = { id: args.id };
|
const note = await prisma.note.update({
|
||||||
if (resolvedUserId) where.userId = resolvedUserId;
|
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
|
||||||
const note = await prisma.note.update({ where, data: updateData });
|
data: d,
|
||||||
|
});
|
||||||
return textResult(parseNote(note));
|
return textResult(parseNote(note));
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'delete_note': {
|
case 'delete_note': {
|
||||||
const where = { id: args.id };
|
await prisma.note.delete({
|
||||||
if (resolvedUserId) where.userId = resolvedUserId;
|
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
|
||||||
await prisma.note.delete({ where });
|
});
|
||||||
return textResult({ success: true, message: 'Note deleted' });
|
return textResult({ success: true, deleted: args.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'search_notes': {
|
case 'search_notes': {
|
||||||
const where = {
|
const where = noteWhere(uid, {
|
||||||
isArchived: args.includeArchived || false,
|
isArchived: args.includeArchived || false,
|
||||||
OR: [
|
OR: [
|
||||||
{ title: { contains: args.query } },
|
{ title: { contains: args.query } },
|
||||||
{ content: { contains: args.query } },
|
{ content: { contains: args.query } },
|
||||||
],
|
],
|
||||||
};
|
...(args.notebookId ? { notebookId: args.notebookId } : {}),
|
||||||
if (resolvedUserId) where.userId = resolvedUserId;
|
});
|
||||||
if (args.notebookId) where.notebookId = args.notebookId;
|
|
||||||
|
|
||||||
const notes = await prisma.note.findMany({
|
const notes = await prisma.note.findMany({
|
||||||
where,
|
where,
|
||||||
@@ -541,79 +526,61 @@ export function registerTools(server, prisma) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'move_note': {
|
case 'move_note': {
|
||||||
const noteWhere = { id: args.id };
|
const targetId = args.notebookId || null;
|
||||||
if (resolvedUserId) noteWhere.userId = resolvedUserId;
|
|
||||||
|
|
||||||
const targetNotebookId = args.notebookId || null;
|
|
||||||
|
|
||||||
// Optimized: Parallel execution
|
|
||||||
const [note, notebook] = await Promise.all([
|
const [note, notebook] = await Promise.all([
|
||||||
prisma.note.update({
|
prisma.note.update({
|
||||||
where: noteWhere,
|
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
|
||||||
data: { notebookId: targetNotebookId, updatedAt: new Date() },
|
data: { notebookId: targetId, updatedAt: new Date() },
|
||||||
}),
|
}),
|
||||||
targetNotebookId
|
targetId
|
||||||
? prisma.notebook.findUnique({ where: { id: targetNotebookId }, select: { name: true } })
|
? prisma.notebook.findUnique({ where: { id: targetId }, select: { name: true } })
|
||||||
: Promise.resolve(null),
|
: Promise.resolve(null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const notebookName = notebook?.name || 'Inbox';
|
|
||||||
|
|
||||||
return textResult({
|
return textResult({
|
||||||
success: true,
|
success: true,
|
||||||
data: { id: note.id, notebookId: note.notebookId, notebook: { name: notebookName } },
|
id: note.id,
|
||||||
message: `Note moved to ${notebookName}`,
|
notebookId: note.notebookId,
|
||||||
|
notebookName: notebook?.name || 'Inbox',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'toggle_pin': {
|
case 'toggle_pin': {
|
||||||
const where = { id: args.id };
|
const note = await prisma.note.findUnique({
|
||||||
if (resolvedUserId) where.userId = resolvedUserId;
|
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
|
||||||
const note = await prisma.note.findUnique({ where });
|
});
|
||||||
if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
|
if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
|
||||||
const updated = await prisma.note.update({
|
const updated = await prisma.note.update({
|
||||||
where,
|
where: { id: args.id },
|
||||||
data: { isPinned: !note.isPinned },
|
data: { isPinned: !note.isPinned },
|
||||||
});
|
});
|
||||||
return textResult(parseNote(updated));
|
return textResult(parseNote(updated));
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'toggle_archive': {
|
case 'toggle_archive': {
|
||||||
const where = { id: args.id };
|
const note = await prisma.note.findUnique({
|
||||||
if (resolvedUserId) where.userId = resolvedUserId;
|
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
|
||||||
const note = await prisma.note.findUnique({ where });
|
});
|
||||||
if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
|
if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
|
||||||
const updated = await prisma.note.update({
|
const updated = await prisma.note.update({
|
||||||
where,
|
where: { id: args.id },
|
||||||
data: { isArchived: !note.isArchived },
|
data: { isArchived: !note.isArchived },
|
||||||
});
|
});
|
||||||
return textResult(parseNote(updated));
|
return textResult(parseNote(updated));
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'export_notes': {
|
case 'export_notes': {
|
||||||
const noteWhere = {};
|
const nbWhere = uid ? { userId: uid } : {};
|
||||||
const nbWhere = {};
|
|
||||||
if (resolvedUserId) { noteWhere.userId = resolvedUserId; nbWhere.userId = resolvedUserId; }
|
|
||||||
|
|
||||||
// Optimized: Parallel queries
|
|
||||||
const [notes, notebooks, labels] = await Promise.all([
|
const [notes, notebooks, labels] = await Promise.all([
|
||||||
prisma.note.findMany({
|
prisma.note.findMany({
|
||||||
where: noteWhere,
|
where: noteWhere(uid),
|
||||||
orderBy: { updatedAt: 'desc' },
|
orderBy: { updatedAt: 'desc' },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true, title: true, content: true, color: true, type: true,
|
||||||
title: true,
|
isPinned: true, isArchived: true, isMarkdown: true, size: true,
|
||||||
content: true,
|
labels: true, notebookId: true, createdAt: true, updatedAt: true,
|
||||||
color: true,
|
|
||||||
type: true,
|
|
||||||
isPinned: true,
|
|
||||||
isArchived: true,
|
|
||||||
isMarkdown: true,
|
|
||||||
size: true,
|
|
||||||
labels: true,
|
|
||||||
notebookId: true,
|
|
||||||
createdAt: true,
|
|
||||||
updatedAt: true,
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.notebook.findMany({
|
prisma.notebook.findMany({
|
||||||
@@ -626,9 +593,8 @@ export function registerTools(server, prisma) {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Filter labels by userId in memory (faster than multiple queries)
|
const filteredLabels = uid
|
||||||
const filteredLabels = resolvedUserId
|
? labels.filter(l => l.notebook?.userId === uid)
|
||||||
? labels.filter(l => l.notebook?.userId === resolvedUserId)
|
|
||||||
: labels;
|
: labels;
|
||||||
|
|
||||||
return textResult({
|
return textResult({
|
||||||
@@ -636,32 +602,16 @@ export function registerTools(server, prisma) {
|
|||||||
exportDate: new Date().toISOString(),
|
exportDate: new Date().toISOString(),
|
||||||
data: {
|
data: {
|
||||||
notes: notes.map(n => ({
|
notes: notes.map(n => ({
|
||||||
id: n.id,
|
id: n.id, title: n.title, content: n.content, color: n.color, type: n.type,
|
||||||
title: n.title,
|
isPinned: n.isPinned, isArchived: n.isArchived, isMarkdown: n.isMarkdown,
|
||||||
content: n.content,
|
size: n.size, labels: Array.isArray(n.labels) ? n.labels : [],
|
||||||
color: n.color,
|
notebookId: n.notebookId, createdAt: n.createdAt, updatedAt: n.updatedAt,
|
||||||
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 => ({
|
notebooks: notebooks.map(nb => ({
|
||||||
id: nb.id,
|
id: nb.id, name: nb.name, icon: nb.icon, color: nb.color, noteCount: nb._count.notes,
|
||||||
name: nb.name,
|
|
||||||
icon: nb.icon,
|
|
||||||
color: nb.color,
|
|
||||||
noteCount: nb._count.notes,
|
|
||||||
})),
|
})),
|
||||||
labels: filteredLabels.map(l => ({
|
labels: filteredLabels.map(l => ({
|
||||||
id: l.id,
|
id: l.id, name: l.name, color: l.color, notebookId: l.notebookId,
|
||||||
name: l.name,
|
|
||||||
color: l.color,
|
|
||||||
notebookId: l.notebookId,
|
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -671,60 +621,51 @@ export function registerTools(server, prisma) {
|
|||||||
const importData = args.data;
|
const importData = args.data;
|
||||||
let importedNotes = 0, importedLabels = 0, importedNotebooks = 0;
|
let importedNotes = 0, importedLabels = 0, importedNotebooks = 0;
|
||||||
|
|
||||||
// OPTIMIZED: Batch operations with Promise.all for notebooks
|
|
||||||
if (importData.data?.notebooks?.length > 0) {
|
if (importData.data?.notebooks?.length > 0) {
|
||||||
const existingNotebooks = await prisma.notebook.findMany({
|
const existing = await prisma.notebook.findMany({
|
||||||
where: resolvedUserId ? { userId: resolvedUserId } : {},
|
where: uid ? { userId: uid } : {},
|
||||||
select: { name: true },
|
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))
|
.filter(nb => !existingNames.has(nb.name))
|
||||||
.map(nb => prisma.notebook.create({
|
.map(nb => ({
|
||||||
data: {
|
|
||||||
name: nb.name,
|
name: nb.name,
|
||||||
icon: nb.icon || '📁',
|
icon: nb.icon || '📁',
|
||||||
color: nb.color || '#3B82F6',
|
color: nb.color || '#3B82F6',
|
||||||
...(resolvedUserId ? { userId: resolvedUserId } : {}),
|
...(uid ? { userId: uid } : {}),
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await Promise.all(notebooksToCreate);
|
if (toCreate.length > 0) {
|
||||||
importedNotebooks = notebooksToCreate.length;
|
await prisma.notebook.createMany({ data: toCreate, skipDuplicates: true });
|
||||||
|
importedNotebooks = toCreate.length;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OPTIMIZED: Batch labels
|
|
||||||
if (importData.data?.labels?.length > 0) {
|
if (importData.data?.labels?.length > 0) {
|
||||||
const notebooks = await prisma.notebook.findMany({
|
const notebooks = await prisma.notebook.findMany({
|
||||||
where: resolvedUserId ? { userId: resolvedUserId } : {},
|
where: uid ? { userId: uid } : {},
|
||||||
select: { id: true },
|
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({
|
const existing = await prisma.label.findMany({
|
||||||
where: { notebookId: { in: Array.from(notebookIds) } },
|
where: { notebookId: { in: Array.from(nbIds) } },
|
||||||
select: { name: true, notebookId: true },
|
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 = [];
|
const toCreate = importData.data.labels
|
||||||
for (const label of importData.data.labels) {
|
.filter(l => l.notebookId && nbIds.has(l.notebookId) && !existingKeys.has(`${l.notebookId}:${l.name}`))
|
||||||
if (label.notebookId && notebookIds.has(label.notebookId)) {
|
.map(l => ({ name: l.name, color: l.color, notebookId: l.notebookId }));
|
||||||
const key = `${label.notebookId}:${label.name}`;
|
|
||||||
if (!existingLabelKeys.has(key)) {
|
if (toCreate.length > 0) {
|
||||||
labelsToCreate.push(prisma.label.create({
|
await prisma.label.createMany({ data: toCreate, skipDuplicates: true });
|
||||||
data: { name: label.name, color: label.color, notebookId: label.notebookId },
|
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) {
|
if (importData.data?.notes?.length > 0) {
|
||||||
const notesData = importData.data.notes.map(note => ({
|
const notesData = importData.data.notes.map(note => ({
|
||||||
title: note.title,
|
title: note.title,
|
||||||
@@ -737,22 +678,14 @@ export function registerTools(server, prisma) {
|
|||||||
size: note.size || 'small',
|
size: note.size || 'small',
|
||||||
labels: note.labels ?? null,
|
labels: note.labels ?? null,
|
||||||
notebookId: note.notebookId || null,
|
notebookId: note.notebookId || null,
|
||||||
...(resolvedUserId ? { userId: resolvedUserId } : {}),
|
...(uid ? { userId: uid } : {}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Try createMany first (faster), fall back to Promise.all
|
|
||||||
try {
|
try {
|
||||||
const result = await prisma.note.createMany({
|
const result = await prisma.note.createMany({ data: notesData, skipDuplicates: true });
|
||||||
data: notesData,
|
|
||||||
skipDuplicates: true,
|
|
||||||
});
|
|
||||||
importedNotes = result.count;
|
importedNotes = result.count;
|
||||||
} catch {
|
} catch {
|
||||||
// Fallback to individual creates
|
const results = await Promise.all(notesData.map(d => prisma.note.create({ data: d }).catch(() => null)));
|
||||||
const creates = notesData.map(data =>
|
|
||||||
prisma.note.create({ data }).catch(() => null)
|
|
||||||
);
|
|
||||||
const results = await Promise.all(creates);
|
|
||||||
importedNotes = results.filter(r => r !== null).length;
|
importedNotes = results.filter(r => r !== null).length;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -763,27 +696,23 @@ export function registerTools(server, prisma) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════
|
// ═══ NOTEBOOKS ═══
|
||||||
// NOTEBOOKS
|
|
||||||
// ═══════════════════════════════════════════════════════
|
|
||||||
case 'create_notebook': {
|
case 'create_notebook': {
|
||||||
const highestOrder = await prisma.notebook.findFirst({
|
const highest = await prisma.notebook.findFirst({
|
||||||
where: resolvedUserId ? { userId: resolvedUserId } : {},
|
where: uid ? { userId: uid } : {},
|
||||||
orderBy: { order: 'desc' },
|
orderBy: { order: 'desc' },
|
||||||
select: { order: true },
|
select: { order: true },
|
||||||
});
|
});
|
||||||
const nextOrder = args.order !== undefined ? args.order : (highestOrder?.order ?? -1) + 1;
|
const nextOrder = args.order !== undefined ? args.order : (highest?.order ?? -1) + 1;
|
||||||
|
|
||||||
const data = {
|
const notebook = await prisma.notebook.create({
|
||||||
|
data: {
|
||||||
name: args.name.trim(),
|
name: args.name.trim(),
|
||||||
icon: args.icon || '📁',
|
icon: args.icon || '📁',
|
||||||
color: args.color || '#3B82F6',
|
color: args.color || '#3B82F6',
|
||||||
order: nextOrder,
|
order: nextOrder,
|
||||||
};
|
userId: uid || await ensureUserId(),
|
||||||
data.userId = resolvedUserId || await ensureUserId();
|
},
|
||||||
|
|
||||||
const notebook = await prisma.notebook.create({
|
|
||||||
data,
|
|
||||||
include: { labels: true, _count: { select: { notes: true } } },
|
include: { labels: true, _count: { select: { notes: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -791,9 +720,7 @@ export function registerTools(server, prisma) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'get_notebooks': {
|
case 'get_notebooks': {
|
||||||
const where = {};
|
const where = uid ? { userId: uid } : {};
|
||||||
if (resolvedUserId) where.userId = resolvedUserId;
|
|
||||||
|
|
||||||
const notebooks = await prisma.notebook.findMany({
|
const notebooks = await prisma.notebook.findMany({
|
||||||
where,
|
where,
|
||||||
include: {
|
include: {
|
||||||
@@ -807,12 +734,10 @@ export function registerTools(server, prisma) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'get_notebook': {
|
case 'get_notebook': {
|
||||||
const where = { id: args.id };
|
const where = { id: args.id, ...(uid ? { userId: uid } : {}) };
|
||||||
if (resolvedUserId) where.userId = resolvedUserId;
|
|
||||||
|
|
||||||
const notebook = await prisma.notebook.findUnique({
|
const notebook = await prisma.notebook.findUnique({
|
||||||
where,
|
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');
|
if (!notebook) throw new McpError(ErrorCode.InvalidRequest, 'Notebook not found');
|
||||||
|
|
||||||
@@ -824,18 +749,16 @@ export function registerTools(server, prisma) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'update_notebook': {
|
case 'update_notebook': {
|
||||||
const updateData = {};
|
const d = {};
|
||||||
if ('name' in args) updateData.name = args.name.trim();
|
if ('name' in args) d.name = args.name.trim();
|
||||||
if ('icon' in args) updateData.icon = args.icon;
|
if ('icon' in args) d.icon = args.icon;
|
||||||
if ('color' in args) updateData.color = args.color;
|
if ('color' in args) d.color = args.color;
|
||||||
if ('order' in args) updateData.order = args.order;
|
if ('order' in args) d.order = args.order;
|
||||||
|
|
||||||
const where = { id: args.id };
|
|
||||||
if (resolvedUserId) where.userId = resolvedUserId;
|
|
||||||
|
|
||||||
|
const where = { id: args.id, ...(uid ? { userId: uid } : {}) };
|
||||||
const notebook = await prisma.notebook.update({
|
const notebook = await prisma.notebook.update({
|
||||||
where,
|
where,
|
||||||
data: updateData,
|
data: d,
|
||||||
include: { labels: true, _count: { select: { notes: true } } },
|
include: { labels: true, _count: { select: { notes: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -843,16 +766,14 @@ export function registerTools(server, prisma) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'delete_notebook': {
|
case 'delete_notebook': {
|
||||||
const where = { id: args.id };
|
|
||||||
if (resolvedUserId) where.userId = resolvedUserId;
|
|
||||||
|
|
||||||
// Move notes to inbox before deleting
|
|
||||||
await prisma.$transaction([
|
await prisma.$transaction([
|
||||||
prisma.note.updateMany({
|
prisma.note.updateMany({
|
||||||
where: { notebookId: args.id, ...(resolvedUserId ? { userId: resolvedUserId } : {}) },
|
where: { notebookId: args.id, ...(uid ? { userId: uid } : {}) },
|
||||||
data: { notebookId: null },
|
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' });
|
return textResult({ success: true, message: 'Notebook deleted, notes moved to Inbox' });
|
||||||
@@ -860,21 +781,14 @@ export function registerTools(server, prisma) {
|
|||||||
|
|
||||||
case 'reorder_notebooks': {
|
case 'reorder_notebooks': {
|
||||||
const ids = args.notebookIds;
|
const ids = args.notebookIds;
|
||||||
|
const where = { id: { in: ids }, ...(uid ? { userId: uid } : {}) };
|
||||||
|
|
||||||
// Optimized: Verify ownership in one query
|
const existing = await prisma.notebook.findMany({ where, select: { id: true } });
|
||||||
const where = { id: { in: ids } };
|
const existingIds = new Set(existing.map(nb => nb.id));
|
||||||
if (resolvedUserId) where.userId = resolvedUserId;
|
const missing = ids.filter(id => !existingIds.has(id));
|
||||||
|
|
||||||
const existingNotebooks = await prisma.notebook.findMany({
|
if (missing.length > 0) {
|
||||||
where,
|
throw new McpError(ErrorCode.InvalidRequest, `Notebooks not found: ${missing.join(', ')}`);
|
||||||
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(', ')}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.$transaction(
|
await prisma.$transaction(
|
||||||
@@ -882,12 +796,10 @@ export function registerTools(server, prisma) {
|
|||||||
prisma.notebook.update({ where: { id }, data: { order: index } })
|
prisma.notebook.update({ where: { id }, data: { order: index } })
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
return textResult({ success: true, message: 'Notebooks reordered' });
|
return textResult({ success: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════
|
// ═══ LABELS ═══
|
||||||
// LABELS - OPTIMIZED to fix N+1 query
|
|
||||||
// ═══════════════════════════════════════════════════════
|
|
||||||
case 'create_label': {
|
case 'create_label': {
|
||||||
const existing = await prisma.label.findFirst({
|
const existing = await prisma.label.findFirst({
|
||||||
where: { name: args.name.trim(), notebookId: args.notebookId },
|
where: { name: args.name.trim(), notebookId: args.notebookId },
|
||||||
@@ -908,49 +820,37 @@ export function registerTools(server, prisma) {
|
|||||||
const where = {};
|
const where = {};
|
||||||
if (args?.notebookId) where.notebookId = args.notebookId;
|
if (args?.notebookId) where.notebookId = args.notebookId;
|
||||||
|
|
||||||
// OPTIMIZED: Single query with include, then filter in memory
|
|
||||||
const labels = await prisma.label.findMany({
|
const labels = await prisma.label.findMany({
|
||||||
where,
|
where,
|
||||||
include: { notebook: { select: { id: true, name: true, userId: true } } },
|
include: { notebook: { select: { id: true, name: true, userId: true } } },
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter by userId in memory (much faster than N+1 queries)
|
const filtered = uid ? labels.filter(l => l.notebook?.userId === uid) : labels;
|
||||||
let filteredLabels = labels;
|
return textResult(filtered);
|
||||||
if (resolvedUserId) {
|
|
||||||
filteredLabels = labels.filter(l => l.notebook?.userId === resolvedUserId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return textResult(filteredLabels);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'update_label': {
|
case 'update_label': {
|
||||||
const updateData = {};
|
const d = {};
|
||||||
if ('name' in args) updateData.name = args.name.trim();
|
if ('name' in args) d.name = args.name.trim();
|
||||||
if ('color' in args) updateData.color = args.color;
|
if ('color' in args) d.color = args.color;
|
||||||
|
|
||||||
const label = await prisma.label.update({
|
const label = await prisma.label.update({ where: { id: args.id }, data: d });
|
||||||
where: { id: args.id },
|
|
||||||
data: updateData,
|
|
||||||
});
|
|
||||||
return textResult(label);
|
return textResult(label);
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'delete_label': {
|
case 'delete_label': {
|
||||||
await prisma.label.delete({ where: { id: args.id } });
|
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': {
|
case 'get_due_reminders': {
|
||||||
const where = {
|
const where = noteWhere(uid, {
|
||||||
reminder: { not: null, lte: new Date() },
|
reminder: { not: null, lte: new Date() },
|
||||||
isReminderDone: false,
|
isReminderDone: false,
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
};
|
});
|
||||||
if (resolvedUserId) where.userId = resolvedUserId;
|
|
||||||
|
|
||||||
const reminders = await prisma.note.findMany({
|
const reminders = await prisma.note.findMany({
|
||||||
where,
|
where,
|
||||||
@@ -958,7 +858,6 @@ export function registerTools(server, prisma) {
|
|||||||
orderBy: { reminder: 'asc' },
|
orderBy: { reminder: 'asc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mark them as done
|
|
||||||
if (reminders.length > 0) {
|
if (reminders.length > 0) {
|
||||||
await prisma.note.updateMany({
|
await prisma.note.updateMany({
|
||||||
where: { id: { in: reminders.map(r => r.id) } },
|
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:
|
default:
|
||||||
@@ -974,7 +873,7 @@ export function registerTools(server, prisma) {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof McpError) throw 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}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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 (480–767px) : 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 (768–1023px) ────────────────────────── */
|
|
||||||
@media (min-width: 768px) and (max-width: 1023px) {
|
|
||||||
.masonry-css-grid {
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Desktop (1024–1279px) ─────────────────────── */
|
|
||||||
@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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -20,6 +20,10 @@ RUN npx prisma generate
|
|||||||
FROM node:22-bookworm-slim AS builder
|
FROM node:22-bookworm-slim AS builder
|
||||||
WORKDIR /app
|
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 --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { toast } from 'sonner'
|
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'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
interface TestResult {
|
interface TestResult {
|
||||||
@@ -65,14 +64,14 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' | 'chat' }) {
|
|||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
toast.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`
|
description: `Provider: ${data.provider} | Time: ${endTime - startTime}ms`
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
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'
|
description: data.error || 'Unknown error'
|
||||||
}
|
}
|
||||||
@@ -93,7 +92,7 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' | 'chat' }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getProviderInfo = () => {
|
const getProviderInfo = () => {
|
||||||
if (!config) return { provider: t('admin.aiTest.testing'), model: t('admin.aiTest.testing') }
|
if (!config) return { provider: '...', model: '...' }
|
||||||
|
|
||||||
if (type === 'tags') {
|
if (type === 'tags') {
|
||||||
return {
|
return {
|
||||||
@@ -116,127 +115,164 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' | 'chat' }) {
|
|||||||
const providerInfo = getProviderInfo()
|
const providerInfo = getProviderInfo()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-8">
|
||||||
{/* Provider Info */}
|
{/* Current Config Summary */}
|
||||||
<div className="space-y-3 p-4 bg-muted/50 rounded-lg">
|
<div className="rounded-2xl border border-border bg-muted/20 overflow-hidden shadow-inner">
|
||||||
<div className="flex items-center justify-between">
|
<div className="px-4 py-2.5 border-b border-border/50 bg-muted/40 flex items-center gap-2">
|
||||||
<span className="text-sm font-medium">{t('admin.aiTest.provider')}</span>
|
{type === 'tags' ? <Brain className="h-4 w-4 text-primary" /> :
|
||||||
<Badge variant="outline" className="text-xs">
|
type === 'embeddings' ? <Search className="h-4 w-4 text-green-600" /> :
|
||||||
{providerInfo.provider.toUpperCase()}
|
<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="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>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-6">
|
||||||
<span className="text-sm font-medium">{t('admin.aiTest.model')}</span>
|
<span className="text-sm font-bold text-muted-foreground truncate">{t('admin.aiTest.model')}</span>
|
||||||
<span className="text-sm text-muted-foreground font-mono">
|
<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}
|
{providerInfo.model}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Test Button */}
|
{/* Run Test Action */}
|
||||||
<Button
|
<Button
|
||||||
onClick={runTest}
|
onClick={runTest}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="w-full"
|
className={`w-full shadow-lg py-7 h-auto rounded-2xl transition-all duration-300 relative overflow-hidden group/btn ${
|
||||||
variant={result?.success ? 'default' : result?.success === false ? 'destructive' : 'default'}
|
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 ? (
|
{isLoading ? (
|
||||||
<>
|
<div className="flex flex-col items-center gap-2">
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
{t('admin.aiTest.testing')}
|
<span className="text-xs font-bold tracking-widest uppercase">{t('admin.aiTest.testing')}...</span>
|
||||||
</>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="flex items-center justify-center gap-4">
|
||||||
<Zap className="mr-2 h-4 w-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')}
|
{t('admin.aiTest.runTest')}
|
||||||
</>
|
</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Results */}
|
{/* Result Display */}
|
||||||
{result && (
|
{result && (
|
||||||
<Card className={result.success ? 'border-green-200 dark:border-green-900' : 'border-red-200 dark:border-red-900'}>
|
<div className={`rounded-xl border transition-all duration-300 animate-in fade-in slide-in-from-top-2 ${
|
||||||
<CardContent className="pt-6">
|
result.success
|
||||||
{/* Status */}
|
? 'bg-green-500/[0.03] border-green-500/20 shadow-green-500/[0.02] shadow-lg'
|
||||||
<div className="flex items-center gap-2 mb-4">
|
: '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 ? (
|
{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 text-green-600" />
|
<CheckCircle2 className="h-5 w-5" />
|
||||||
<span className="font-semibold text-green-600 dark:text-green-400">{t('admin.aiTest.testPassed')}</span>
|
</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 text-red-600" />
|
<XCircle className="h-5 w-5" />
|
||||||
<span className="font-semibold text-red-600 dark:text-red-400">{t('admin.aiTest.testFailed')}</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Response Time */}
|
|
||||||
{result.responseTime && (
|
{result.responseTime && (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-4">
|
<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-4 w-4" />
|
<Clock className="h-3 w-3" />
|
||||||
<span>{t('admin.aiTest.responseTime', { time: result.responseTime })}</span>
|
{result.responseTime}ms
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Tags Results */}
|
{/* Response Content - Tags */}
|
||||||
{type === 'tags' && result.success && result.tags && (
|
{type === 'tags' && result.success && result.tags && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3 pt-3 border-t border-border/10">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 text-xs font-semibold text-muted-foreground uppercase tracking-widest">
|
||||||
<Info className="h-4 w-4 text-primary" />
|
<Info className="h-3.5 w-3.5" />
|
||||||
<span className="text-sm font-medium">{t('admin.aiTest.generatedTags')}</span>
|
{t('admin.aiTest.generatedTags')}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{result.tags.map((tag, idx) => (
|
{result.tags.map((tag, idx) => (
|
||||||
<Badge
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
variant="secondary"
|
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"
|
||||||
className="text-sm"
|
|
||||||
>
|
>
|
||||||
{tag.tag}
|
<span className="text-foreground">{tag.tag}</span>
|
||||||
<span className="ml-1 text-xs opacity-70">
|
<span className="text-[10px] text-muted-foreground bg-muted px-1 rounded font-mono">
|
||||||
({Math.round(tag.confidence * 100)}%)
|
{Math.round(tag.confidence * 100)}%
|
||||||
</span>
|
</span>
|
||||||
</Badge>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Chat Response */}
|
{/* Response Content - Chat */}
|
||||||
{type === 'chat' && result.success && result.chatResponse && (
|
{type === 'chat' && result.success && result.chatResponse && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3 pt-3 border-t border-border/10">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 text-xs font-semibold text-muted-foreground uppercase tracking-widest">
|
||||||
<Info className="h-4 w-4 text-violet-600" />
|
<MessageSquare className="h-3.5 w-3.5" />
|
||||||
<span className="text-sm font-medium">Réponse du modèle</span>
|
Modèle Réponse
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-muted rounded-lg">
|
<div className="p-4 bg-background/50 border border-border/50 rounded-xl relative">
|
||||||
<p className="text-sm italic">"{result.chatResponse}"</p>
|
<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">
|
||||||
|
"{result.chatResponse}"
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Embeddings Results */}
|
{/* Response Content - Embeddings */}
|
||||||
{type === 'embeddings' && result.success && result.embeddingLength && (
|
{type === 'embeddings' && result.success && result.embeddingLength && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-4 pt-3 border-t border-border/10">
|
||||||
<div className="flex items-center gap-2">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Info className="h-4 w-4 text-green-600" />
|
<div className="bg-background/50 border border-border/50 rounded-xl p-4 text-center">
|
||||||
<span className="text-sm font-medium">{t('admin.aiTest.embeddingDimensions')}</span>
|
<div className="text-2xl font-black tracking-tighter text-foreground">
|
||||||
</div>
|
|
||||||
<div className="p-3 bg-muted rounded-lg">
|
|
||||||
<div className="text-2xl font-bold text-center">
|
|
||||||
{result.embeddingLength}
|
{result.embeddingLength}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-center text-muted-foreground mt-1">
|
<div className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mt-1">
|
||||||
{t('admin.aiTest.vectorDimensions')}
|
{t('admin.aiTest.vectorDimensions')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<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 && (
|
{result.firstValues && result.firstValues.length > 0 && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-2">
|
||||||
<span className="text-xs font-medium">{t('admin.aiTest.first5Values')}</span>
|
<div className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
<div className="p-2 bg-muted rounded font-mono text-xs">
|
{t('admin.aiTest.first5Values')}
|
||||||
[{result.firstValues.slice(0, 5).map((v, i) => v.toFixed(4)).join(', ')}]
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -245,32 +281,30 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' | 'chat' }) {
|
|||||||
|
|
||||||
{/* Error Details */}
|
{/* Error Details */}
|
||||||
{!result.success && result.error && (
|
{!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">
|
<div className="space-y-3 pt-3 border-t border-border/10">
|
||||||
<p className="text-sm font-medium text-red-900 dark:text-red-100">{t('admin.aiTest.error')}</p>
|
<div className="p-4 bg-destructive/[0.05] border border-destructive/20 rounded-xl">
|
||||||
<p className="text-sm text-red-700 dark:text-red-300 mt-1">{result.error}</p>
|
<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'erreur</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-destructive font-medium leading-relaxed">{result.error}</p>
|
||||||
|
|
||||||
{result.details && (
|
{result.details && (
|
||||||
<details className="mt-2">
|
<details className="mt-4 group">
|
||||||
<summary className="text-xs cursor-pointer text-red-600 dark:text-red-400">
|
<summary className="text-[10px] font-bold uppercase tracking-widest cursor-pointer text-muted-foreground hover:text-destructive transition-colors">
|
||||||
{t('admin.aiTest.technicalDetails')}
|
{t('admin.aiTest.technicalDetails')}
|
||||||
</summary>
|
</summary>
|
||||||
<pre className="mt-2 text-xs overflow-auto p-2 bg-red-100 dark:bg-red-900/30 rounded">
|
<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)}
|
{JSON.stringify(result.details, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
|
</div>
|
||||||
</details>
|
</details>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
import { Button } from '@/components/ui/button'
|
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 { AI_TESTER } from './ai-tester'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
@@ -10,109 +9,140 @@ export default function AITestPage() {
|
|||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto py-10 px-4 max-w-6xl">
|
<div className="bg-background flex flex-col min-h-screen">
|
||||||
<div className="flex justify-between items-center mb-8">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="flex items-center gap-3">
|
<div className="max-w-[1800px] mx-auto px-6 md:px-16 py-12 space-y-16">
|
||||||
<a href="/admin/settings">
|
{/* Header - Even larger and more spaced */}
|
||||||
<Button variant="outline" size="icon">
|
<div className="flex flex-col md:flex-row md:items-end justify-between gap-10 border-b border-border/40 pb-12">
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<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>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold flex items-center gap-2">
|
<div className="flex items-center gap-4 mb-3">
|
||||||
<TestTube className="h-8 w-8" />
|
<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')}
|
{t('admin.aiTest.title')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground mt-1">
|
<p className="text-muted-foreground mt-4 text-xl font-medium max-w-3xl leading-relaxed">
|
||||||
{t('admin.aiTest.description')}
|
{t('admin.aiTest.description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-3">
|
{/* New Horizontal Section for each Test - Maximum Width Utilization */}
|
||||||
{/* Tags Provider Test */}
|
<div className="space-y-12">
|
||||||
<Card className="border-primary/20 dark:border-primary/30">
|
{/* 1. Tags Test - Horizontal Layout */}
|
||||||
<CardHeader className="bg-primary/5 dark:bg-primary/10">
|
<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">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<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">
|
||||||
<span className="text-2xl">🏷️</span>
|
<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">
|
||||||
{t('admin.aiTest.tagsTestTitle')}
|
<Brain className="h-80 w-80 text-primary" />
|
||||||
</CardTitle>
|
</div>
|
||||||
<CardDescription>
|
<div className="relative space-y-8">
|
||||||
{t('admin.aiTest.tagsTestDescription')}
|
<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">
|
||||||
</CardDescription>
|
🏷️
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent className="pt-6">
|
<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" />
|
<AI_TESTER type="tags" />
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Embeddings Provider Test */}
|
{/* 2. Embeddings Test - Horizontal Layout */}
|
||||||
<Card className="border-green-200 dark:border-green-900">
|
<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">
|
||||||
<CardHeader className="bg-green-50/50 dark:bg-green-950/20">
|
<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">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<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">
|
||||||
<span className="text-2xl">🔍</span>
|
<Search className="h-80 w-80 text-green-500" />
|
||||||
{t('admin.aiTest.embeddingsTestTitle')}
|
</div>
|
||||||
</CardTitle>
|
<div className="relative space-y-8">
|
||||||
<CardDescription>
|
<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">
|
||||||
{t('admin.aiTest.embeddingsTestDescription')}
|
🔍
|
||||||
</CardDescription>
|
</div>
|
||||||
</CardHeader>
|
<div>
|
||||||
<CardContent className="pt-6">
|
<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" />
|
<AI_TESTER type="embeddings" />
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Chat Provider Test */}
|
{/* 3. Chat Test - Horizontal Layout */}
|
||||||
<Card className="border-violet-200 dark:border-violet-900">
|
<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">
|
||||||
<CardHeader className="bg-violet-50/50 dark:bg-violet-950/20">
|
<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">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<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">
|
||||||
<span className="text-2xl">💬</span>
|
<MessageSquare className="h-80 w-80 text-violet-500" />
|
||||||
Fournisseur de chat
|
</div>
|
||||||
</CardTitle>
|
<div className="relative space-y-8">
|
||||||
<CardDescription>
|
<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">
|
||||||
Testez le fournisseur IA responsable de l'assistant conversationnel
|
💬
|
||||||
</CardDescription>
|
</div>
|
||||||
</CardHeader>
|
<div>
|
||||||
<CardContent className="pt-6">
|
<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" />
|
<AI_TESTER type="chat" />
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tips Section - Broad and visible */}
|
||||||
{/* Info Section */}
|
<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">
|
||||||
<Card className="mt-6">
|
<div className="w-24 h-24 rounded-3xl bg-amber-500/10 flex items-center justify-center text-5xl shrink-0 shadow-inner">
|
||||||
<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>
|
||||||
<div>
|
<div className="space-y-4">
|
||||||
<h4 className="font-semibold mb-2">{t('admin.aiTest.embeddingsTestLabel')}</h4>
|
<h4 className="text-2xl font-black text-amber-700 dark:text-amber-400 uppercase tracking-tight">{t('admin.aiTest.tipTitle')}</h4>
|
||||||
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
|
<p className="text-xl text-amber-600/90 dark:text-amber-300/80 leading-relaxed font-bold">
|
||||||
<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')}
|
{t('admin.aiTest.tipContent')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,33 +56,33 @@ export function AdminAIPageClient({
|
|||||||
key: 'titleSuggestions' as const,
|
key: 'titleSuggestions' as const,
|
||||||
label: t('admin.ai.titleSuggestions'),
|
label: t('admin.ai.titleSuggestions'),
|
||||||
description: t('admin.ai.titleSuggestionsDesc'),
|
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,
|
key: 'paragraphRefactor' as const,
|
||||||
label: t('admin.ai.aiAssistant'),
|
label: t('admin.ai.aiAssistant'),
|
||||||
description: t('admin.ai.aiAssistantDesc'),
|
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,
|
key: 'memoryEcho' as const,
|
||||||
label: t('admin.ai.memoryEchoFeature'),
|
label: t('admin.ai.memoryEchoFeature'),
|
||||||
description: t('admin.ai.memoryEchoFeatureDesc'),
|
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,
|
key: 'languageDetection' as const,
|
||||||
label: t('admin.ai.languageDetection'),
|
label: t('admin.ai.languageDetection'),
|
||||||
description: t('admin.ai.languageDetectionDesc'),
|
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,
|
key: 'autoLabeling' as const,
|
||||||
label: t('admin.ai.autoLabeling'),
|
label: t('admin.ai.autoLabeling'),
|
||||||
description: t('admin.ai.autoLabelingDesc'),
|
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'),
|
title: t('admin.ai.activeFeatures'),
|
||||||
value: String(Object.values(features).filter(Boolean).length) + ' / ' + featureList.length,
|
value: String(Object.values(features).filter(Boolean).length) + ' / ' + featureList.length,
|
||||||
trend: { value: 0, isPositive: true },
|
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'),
|
title: t('admin.ai.successRate'),
|
||||||
value: '100%',
|
value: '100%',
|
||||||
trend: { value: 0, isPositive: true },
|
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'),
|
title: t('admin.ai.avgResponseTime'),
|
||||||
value: '—',
|
value: '—',
|
||||||
trend: { value: 0, isPositive: true },
|
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'),
|
title: t('admin.ai.configuredProviders'),
|
||||||
value: String(providers.filter(p => p.status !== 'Not Configured').length),
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-start">
|
||||||
<div>
|
<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')}
|
{t('admin.ai.pageTitle')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
<p className="text-muted-foreground mt-1">
|
||||||
{t('admin.ai.pageDescription')}
|
{t('admin.ai.pageDescription')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/admin/settings">
|
<Link href="/admin/settings">
|
||||||
<Button variant="outline">
|
<Button variant="outline" className="border-border">
|
||||||
<Settings className="mr-2 h-4 w-4" />
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
{t('admin.ai.configure')}
|
{t('admin.ai.configure')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -133,25 +133,31 @@ export function AdminAIPageClient({
|
|||||||
|
|
||||||
<AdminMetrics metrics={aiMetrics} />
|
<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 */}
|
{/* 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">
|
<div className="bg-card rounded-lg border border-border p-6 shadow-sm flex flex-col gap-4">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
{t('admin.ai.features')}
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||||
</h2>
|
<Zap className="h-5 w-5" />
|
||||||
<div className="space-y-4">
|
</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 }) => (
|
{featureList.map(({ key, label, description, icon }) => (
|
||||||
<div
|
<div
|
||||||
key={key}
|
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">
|
<div className="flex items-start gap-3 flex-1 min-w-0">
|
||||||
{icon}
|
<div className="mt-0.5 text-primary">{icon}</div>
|
||||||
<div className="min-w-0">
|
<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}
|
{label}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -160,7 +166,7 @@ export function AdminAIPageClient({
|
|||||||
checked={features[key]}
|
checked={features[key]}
|
||||||
onCheckedChange={(v) => handleToggle(key, v)}
|
onCheckedChange={(v) => handleToggle(key, v)}
|
||||||
disabled={saving === key}
|
disabled={saving === key}
|
||||||
className="ml-3 flex-shrink-0"
|
className="ml-3 mt-0.5 flex-shrink-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -168,24 +174,31 @@ export function AdminAIPageClient({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AI Provider Status */}
|
{/* 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">
|
<div className="flex flex-col gap-6">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
<div className="bg-card rounded-lg border border-border p-6 shadow-sm flex flex-col gap-4">
|
||||||
{t('admin.ai.providerStatus')}
|
<div className="flex items-center gap-3 mb-2">
|
||||||
</h2>
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||||
<div className="space-y-3">
|
<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) => (
|
{providers.map((provider) => (
|
||||||
<div
|
<div
|
||||||
key={provider.name}
|
key={provider.name}
|
||||||
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-zinc-800 rounded-lg"
|
className="flex items-center justify-between p-3 bg-muted rounded-lg border border-border/50"
|
||||||
>
|
>
|
||||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
<p className="text-sm font-medium text-foreground">
|
||||||
{provider.name}
|
{provider.name}
|
||||||
</p>
|
</p>
|
||||||
<span
|
<span
|
||||||
className={`px-2 py-1 text-xs font-medium rounded-full ${
|
className={`px-2 py-1 text-xs font-medium rounded-full ${
|
||||||
provider.status === 'Connected' || provider.status === 'Available'
|
provider.status === 'Connected' || provider.status === 'Available'
|
||||||
? 'text-green-700 dark:text-green-400 bg-green-100 dark:bg-green-900'
|
? 'text-green-700 bg-green-500/10 border border-green-500/20'
|
||||||
: 'text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-800'
|
: 'text-muted-foreground bg-muted-foreground/10 border border-muted-foreground/20'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{provider.status}
|
{provider.status}
|
||||||
@@ -194,16 +207,20 @@ export function AdminAIPageClient({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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">
|
<div className="bg-card rounded-lg border border-border p-6 shadow-sm">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
<h2 className="text-sm font-semibold text-foreground mb-2">
|
||||||
{t('admin.ai.recentRequests')}
|
{t('admin.ai.recentRequests')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
<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')}
|
{t('admin.ai.comingSoon')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { AdminHeader } from '@/components/admin-header'
|
import { AdminHeader } from '@/components/admin-header'
|
||||||
import { AdminSidebar } from '@/components/admin-sidebar'
|
import { AdminNav } from '@/components/admin-nav'
|
||||||
import { AdminContentArea } from '@/components/admin-content-area'
|
|
||||||
|
|
||||||
// Auth is enforced solely by middleware (auth.config.ts → authorized callback).
|
// Auth is enforced solely by middleware (auth.config.ts → authorized callback).
|
||||||
// All cross-group navigation (admin ↔ main) uses <a> tags (full page reload)
|
// All cross-group navigation (admin ↔ main) uses <a> tags (full page reload)
|
||||||
@@ -11,11 +10,19 @@ export default function AdminLayout({
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
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 />
|
<AdminHeader />
|
||||||
<div className="flex flex-1">
|
|
||||||
<AdminSidebar />
|
{/* Horizontal Tab Navigation */}
|
||||||
<AdminContentArea>{children}</AdminContentArea>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export default async function AdminPage() {
|
|||||||
|
|
||||||
<AdminMetrics metrics={metrics} />
|
<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">
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
Recent Activity
|
Recent Activity
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Combobox } from '@/components/ui/combobox'
|
|||||||
import { updateSystemConfig, testEmail } from '@/app/actions/admin-settings'
|
import { updateSystemConfig, testEmail } from '@/app/actions/admin-settings'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
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'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
type AIProvider = 'ollama' | 'openai' | 'custom' | 'deepseek' | 'openrouter' | 'mistral' | 'zai' | 'lmstudio'
|
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> }) {
|
export function AdminSettingsForm({ config }: { config: Record<string, string> }) {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
|
const [activeAiTab, setActiveAiTab] = useState<'tags' | 'embeddings' | 'chat'>('tags')
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [isTesting, setIsTesting] = useState(false)
|
const [isTesting, setIsTesting] = useState(false)
|
||||||
|
|
||||||
@@ -546,14 +547,19 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="columns-1 lg:columns-2 gap-6">
|
||||||
<Card>
|
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden break-inside-avoid mb-6">
|
||||||
<CardHeader>
|
<div className="flex items-center gap-3 p-6 border-b border-border">
|
||||||
<CardTitle>{t('admin.security.title')}</CardTitle>
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||||
<CardDescription>{t('admin.security.description')}</CardDescription>
|
<Shield className="h-5 w-5" />
|
||||||
</CardHeader>
|
</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)) }}>
|
<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">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="ALLOW_REGISTRATION"
|
id="ALLOW_REGISTRATION"
|
||||||
@@ -570,22 +576,34 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t('admin.security.allowPublicRegistrationDescription')}
|
{t('admin.security.allowPublicRegistrationDescription')}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</div>
|
||||||
<CardFooter>
|
<div className="px-6 pb-6">
|
||||||
<Button type="submit" disabled={isSaving}>{t('admin.security.title')}</Button>
|
<Button type="submit" disabled={isSaving}>{t('admin.security.title')}</Button>
|
||||||
</CardFooter>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden break-inside-avoid mb-6">
|
||||||
<CardHeader>
|
<div className="flex items-center gap-3 p-6 border-b border-border">
|
||||||
<CardTitle>{t('admin.ai.title')}</CardTitle>
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||||
<CardDescription>{t('admin.ai.description')}</CardDescription>
|
<Brain className="h-5 w-5" />
|
||||||
</CardHeader>
|
</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)) }}>
|
<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 */}
|
{/* 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">
|
<h3 className="text-base font-semibold flex items-center gap-2">
|
||||||
<span className="text-primary">🏷️</span> {t('admin.ai.tagsGenerationProvider')}
|
<span className="text-primary">🏷️</span> {t('admin.ai.tagsGenerationProvider')}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -615,7 +633,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Embeddings Provider */}
|
{/* 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">
|
<h3 className="text-base font-semibold flex items-center gap-2">
|
||||||
<span className="text-green-600">🔍</span> {t('admin.ai.embeddingsProvider')}
|
<span className="text-green-600">🔍</span> {t('admin.ai.embeddingsProvider')}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -650,7 +668,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chat Provider */}
|
{/* 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">
|
<h3 className="text-base font-semibold flex items-center gap-2">
|
||||||
<span className="text-blue-600">💬</span> {t('admin.ai.chatProvider')}
|
<span className="text-blue-600">💬</span> {t('admin.ai.chatProvider')}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -678,8 +696,8 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
<input type="hidden" name="AI_MODEL_CHAT" value={selectedChatModel} />
|
<input type="hidden" name="AI_MODEL_CHAT" value={selectedChatModel} />
|
||||||
{renderProviderConfig(chatProvider, 'chat', selectedChatModel, setSelectedChatModel)}
|
{renderProviderConfig(chatProvider, 'chat', selectedChatModel, setSelectedChatModel)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
<CardFooter className="flex justify-between pt-6">
|
<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>
|
<Button type="submit" disabled={isSaving}>{isSaving ? t('admin.ai.saving') : t('admin.ai.saveSettings')}</Button>
|
||||||
<a href="/admin/ai-test">
|
<a href="/admin/ai-test">
|
||||||
<Button type="button" variant="outline" className="gap-2">
|
<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" />
|
<ExternalLink className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
</CardFooter>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden break-inside-avoid mb-6">
|
||||||
<CardHeader>
|
<div className="flex items-center gap-3 p-6 border-b border-border">
|
||||||
<CardTitle>{t('admin.email.title')}</CardTitle>
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||||
<CardDescription>{t('admin.email.description')}</CardDescription>
|
<Mail className="h-5 w-5" />
|
||||||
</CardHeader>
|
</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)) }}>
|
<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">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">{t('admin.email.provider')}</label>
|
<label className="text-sm font-medium">{t('admin.email.provider')}</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -826,23 +849,28 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
<CardFooter className="flex justify-between pt-6">
|
<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="submit" disabled={isSaving}>{t('admin.email.saveSettings')}</Button>
|
||||||
<Button type="button" variant="secondary" onClick={handleTestEmail} disabled={isTesting}>
|
<Button type="button" variant="secondary" onClick={handleTestEmail} disabled={isTesting}>
|
||||||
{isTesting ? t('admin.smtp.sending') : t('admin.smtp.testEmail')}
|
{isTesting ? t('admin.smtp.sending') : t('admin.smtp.testEmail')}
|
||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden break-inside-avoid mb-6">
|
||||||
<CardHeader>
|
<div className="flex items-center gap-3 p-6 border-b border-border">
|
||||||
<CardTitle>{t('admin.tools.title')}</CardTitle>
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||||
<CardDescription>{t('admin.tools.description')}</CardDescription>
|
<Wrench className="h-5 w-5" />
|
||||||
</CardHeader>
|
</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)) }}>
|
<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">
|
<div className="space-y-2">
|
||||||
<label htmlFor="WEB_SEARCH_PROVIDER" className="text-sm font-medium">{t('admin.tools.searchProvider')}</label>
|
<label htmlFor="WEB_SEARCH_PROVIDER" className="text-sm font-medium">{t('admin.tools.searchProvider')}</label>
|
||||||
<select
|
<select
|
||||||
@@ -880,13 +908,13 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
|
|
||||||
{/* Search test result */}
|
{/* Search test result */}
|
||||||
{searchTestResult && (
|
{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 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>
|
<span>{searchTestResult.message}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
<CardFooter className="flex justify-between">
|
<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="submit" disabled={isSaving}>{t('admin.tools.saveSettings')}</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -896,9 +924,9 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
>
|
>
|
||||||
{isTestingSearch ? t('admin.tools.testing') : t('admin.tools.testSearch')}
|
{isTestingSearch ? t('admin.tools.testing') : t('admin.tools.testSearch')}
|
||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,9 @@ export default async function AdminSettingsPage() {
|
|||||||
const config = await getSystemConfig()
|
const config = await getSystemConfig()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
<SettingsHeader />
|
<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} />
|
<AdminSettingsForm config={config} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ export function SettingsHeader() {
|
|||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
return (
|
return (
|
||||||
<div>
|
<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')}
|
{t('admin.settings')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
<p className="text-muted-foreground mt-1">
|
||||||
{t('admin.settingsDescription')}
|
{t('admin.settingsDescription')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export default async function AdminUsersPage() {
|
|||||||
<CreateUserDialog />
|
<CreateUserDialog />
|
||||||
</div>
|
</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} />
|
<UserList initialUsers={users} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,18 +14,18 @@ export const dynamic = 'force-dynamic'
|
|||||||
export const revalidate = 0
|
export const revalidate = 0
|
||||||
|
|
||||||
export default async function LabPage(props: {
|
export default async function LabPage(props: {
|
||||||
searchParams: Promise<{ id?: string }>
|
searchParams: Promise<{ id?: string; canvas?: string }>
|
||||||
}) {
|
}) {
|
||||||
const searchParams = await props.searchParams
|
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()
|
const session = await auth()
|
||||||
if (!session?.user?.id) redirect('/login')
|
if (!session?.user?.id) redirect('/login')
|
||||||
|
|
||||||
const canvases = await getCanvases()
|
const canvases = await getCanvases()
|
||||||
|
|
||||||
// Resolve current canvas correctly
|
const currentCanvasId = requestedId || (canvases.length > 0 ? canvases[0].id : undefined)
|
||||||
const currentCanvasId = searchParams.id || (canvases.length > 0 ? canvases[0].id : undefined)
|
|
||||||
const currentCanvas = currentCanvasId ? canvases.find(c => c.id === currentCanvasId) : undefined
|
const currentCanvas = currentCanvasId ? canvases.find(c => c.id === currentCanvasId) : undefined
|
||||||
|
|
||||||
// Wrapper for server action creation
|
// Wrapper for server action creation
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { SettingsSection } from '@/components/settings'
|
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
import { FileText, Sparkles, MessageCircle } from 'lucide-react'
|
||||||
|
|
||||||
export default function AboutSettingsPage() {
|
export default function AboutSettingsPage() {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
@@ -11,126 +10,95 @@ export default function AboutSettingsPage() {
|
|||||||
const buildDate = '2026-01-17'
|
const buildDate = '2026-01-17'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold mb-2">{t('about.title')}</h1>
|
<h1 className="text-2xl font-bold tracking-tight text-foreground">{t('about.title')}</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
<p className="text-muted-foreground mt-1">{t('about.description')}</p>
|
||||||
{t('about.description')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SettingsSection
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
title={t('about.appName')}
|
{/* App info */}
|
||||||
icon={<span className="text-2xl">📝</span>}
|
<div className="bg-card rounded-lg border border-border p-6 shadow-sm flex flex-col gap-4">
|
||||||
description={t('about.appDescription')}
|
<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">
|
||||||
<Card>
|
<FileText className="h-5 w-5" />
|
||||||
<CardContent className="pt-6 space-y-4">
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<div>
|
||||||
<span className="font-medium">{t('about.version')}</span>
|
<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>
|
<Badge variant="secondary">{version}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center text-sm">
|
||||||
<span className="font-medium">{t('about.buildDate')}</span>
|
<span className="text-muted-foreground">{t('about.buildDate')}</span>
|
||||||
<Badge variant="outline">{buildDate}</Badge>
|
<Badge variant="outline">{buildDate}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center text-sm">
|
||||||
<span className="font-medium">{t('about.platform')}</span>
|
<span className="text-muted-foreground">{t('about.platform')}</span>
|
||||||
<Badge variant="outline">{t('about.platformWeb')}</Badge>
|
<Badge variant="outline">{t('about.platformWeb')}</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection
|
{/* Features */}
|
||||||
title={t('about.features.title')}
|
<div className="bg-card rounded-lg border border-border p-6 shadow-sm flex flex-col gap-4">
|
||||||
icon={<span className="text-2xl">✨</span>}
|
<div className="flex items-center gap-3 mb-2">
|
||||||
description={t('about.features.description')}
|
<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" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium mb-2">{t('about.support.reportIssues')}</p>
|
<h3 className="font-semibold text-foreground">{t('about.features.title')}</h3>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-muted-foreground">{t('about.features.description')}</p>
|
||||||
Found a bug? Report it in the issue tracker.
|
</div>
|
||||||
</p>
|
</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>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium mb-2">{t('about.support.feedback')}</p>
|
<h3 className="font-semibold text-foreground">{t('about.support.title')}</h3>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-muted-foreground">{t('about.support.description')}</p>
|
||||||
We value your feedback! Share your thoughts and suggestions.
|
</div>
|
||||||
</p>
|
</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>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</SettingsSection>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,9 @@ export function AISettingsHeader() {
|
|||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-6">
|
<div>
|
||||||
<h1 className="text-3xl font-bold">{t('aiSettings.title')}</h1>
|
<h1 className="text-2xl font-bold tracking-tight text-foreground">{t('aiSettings.title')}</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
<p className="text-muted-foreground mt-1">{t('aiSettings.description')}</p>
|
||||||
{t('aiSettings.description')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { SettingsSection, SettingSelect } from '@/components/settings'
|
|
||||||
import { updateAISettings } from '@/app/actions/ai-settings'
|
import { updateAISettings } from '@/app/actions/ai-settings'
|
||||||
import { updateUserSettings } from '@/app/actions/user-settings'
|
import { updateUserSettings } from '@/app/actions/user-settings'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { Palette, Type, LayoutGrid, Maximize2 } from 'lucide-react'
|
||||||
|
|
||||||
interface AppearanceSettingsClientProps {
|
interface AppearanceSettingsClientProps {
|
||||||
initialFontSize: string
|
initialFontSize: string
|
||||||
@@ -15,7 +15,13 @@ interface AppearanceSettingsClientProps {
|
|||||||
initialFontFamily?: string
|
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 { t } = useLanguage()
|
||||||
const [theme, setTheme] = useState(initialTheme || 'light')
|
const [theme, setTheme] = useState(initialTheme || 'light')
|
||||||
const [fontSize, setFontSize] = useState(initialFontSize || 'medium')
|
const [fontSize, setFontSize] = useState(initialFontSize || 'medium')
|
||||||
@@ -26,11 +32,9 @@ export function AppearanceSettingsClient({ initialFontSize, initialTheme, initia
|
|||||||
const handleThemeChange = async (value: string) => {
|
const handleThemeChange = async (value: string) => {
|
||||||
setTheme(value)
|
setTheme(value)
|
||||||
localStorage.setItem('theme-preference', value)
|
localStorage.setItem('theme-preference', value)
|
||||||
|
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
root.removeAttribute('data-theme')
|
root.removeAttribute('data-theme')
|
||||||
root.classList.remove('dark')
|
root.classList.remove('dark')
|
||||||
|
|
||||||
if (value === 'auto') {
|
if (value === 'auto') {
|
||||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) root.classList.add('dark')
|
if (window.matchMedia('(prefers-color-scheme: dark)').matches) root.classList.add('dark')
|
||||||
} else if (value === 'dark') {
|
} else if (value === 'dark') {
|
||||||
@@ -39,19 +43,14 @@ export function AppearanceSettingsClient({ initialFontSize, initialTheme, initia
|
|||||||
root.setAttribute('data-theme', value)
|
root.setAttribute('data-theme', value)
|
||||||
if (['midnight'].includes(value)) root.classList.add('dark')
|
if (['midnight'].includes(value)) root.classList.add('dark')
|
||||||
}
|
}
|
||||||
|
await updateUserSettings({ theme: value as any })
|
||||||
await updateUserSettings({ theme: value as 'light' | 'dark' | 'auto' })
|
|
||||||
toast.success(t('settings.settingsSaved') || 'Saved')
|
toast.success(t('settings.settingsSaved') || 'Saved')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFontSizeChange = async (value: string) => {
|
const handleFontSizeChange = async (value: string) => {
|
||||||
setFontSize(value)
|
setFontSize(value)
|
||||||
|
const map: Record<string, string> = { small: '14px', medium: '16px', large: '18px', 'extra-large': '20px' }
|
||||||
const fontSizeMap: Record<string, string> = {
|
document.documentElement.style.setProperty('--user-font-size', map[value] || '16px')
|
||||||
'small': '14px', 'medium': '16px', 'large': '18px', 'extra-large': '20px'
|
|
||||||
}
|
|
||||||
document.documentElement.style.setProperty('--user-font-size', fontSizeMap[value] || '16px')
|
|
||||||
|
|
||||||
await updateAISettings({ fontSize: value as any })
|
await updateAISettings({ fontSize: value as any })
|
||||||
toast.success(t('settings.settingsSaved') || 'Saved')
|
toast.success(t('settings.settingsSaved') || 'Saved')
|
||||||
}
|
}
|
||||||
@@ -76,31 +75,64 @@ export function AppearanceSettingsClient({ initialFontSize, initialTheme, initia
|
|||||||
setFontFamily(font)
|
setFontFamily(font)
|
||||||
localStorage.setItem('font-family', font)
|
localStorage.setItem('font-family', font)
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
if (font === 'system') {
|
font === 'system' ? root.classList.add('font-system') : root.classList.remove('font-system')
|
||||||
root.classList.add('font-system')
|
|
||||||
} else {
|
|
||||||
root.classList.remove('font-system')
|
|
||||||
}
|
|
||||||
await updateAISettings({ fontFamily: font })
|
await updateAISettings({ fontFamily: font })
|
||||||
toast.success(t('settings.settingsSaved') || 'Saved')
|
toast.success(t('settings.settingsSaved') || 'Saved')
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const SelectCard = ({
|
||||||
<div className="space-y-6">
|
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>
|
<div>
|
||||||
<h1 className="text-3xl font-bold mb-2">{t('appearance.title')}</h1>
|
<h3 className="font-semibold text-foreground">{title}</h3>
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-muted-foreground">{description}</p>
|
||||||
{t('appearance.description')}
|
</div>
|
||||||
</p>
|
</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-8">
|
||||||
|
<div>
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
<SettingsSection
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<SelectCard
|
||||||
|
icon={Palette}
|
||||||
title={t('settings.theme')}
|
title={t('settings.theme')}
|
||||||
icon={<span className="text-2xl">🎨</span>}
|
|
||||||
description={t('settings.themeLight') + ' / ' + t('settings.themeDark')}
|
|
||||||
>
|
|
||||||
<SettingSelect
|
|
||||||
label={t('settings.theme')}
|
|
||||||
description={t('appearance.selectTheme')}
|
description={t('appearance.selectTheme')}
|
||||||
value={theme}
|
value={theme}
|
||||||
options={[
|
options={[
|
||||||
@@ -118,50 +150,36 @@ export function AppearanceSettingsClient({ initialFontSize, initialTheme, initia
|
|||||||
]}
|
]}
|
||||||
onChange={handleThemeChange}
|
onChange={handleThemeChange}
|
||||||
/>
|
/>
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection
|
<SelectCard
|
||||||
|
icon={Type}
|
||||||
title={t('profile.fontSize')}
|
title={t('profile.fontSize')}
|
||||||
icon={<span className="text-2xl">📝</span>}
|
|
||||||
description={t('profile.fontSizeDescription')}
|
description={t('profile.fontSizeDescription')}
|
||||||
>
|
|
||||||
<SettingSelect
|
|
||||||
label={t('profile.fontSize')}
|
|
||||||
description={t('profile.selectFontSize')}
|
|
||||||
value={fontSize}
|
value={fontSize}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'small', label: t('profile.fontSizeSmall') },
|
{ value: 'small', label: t('profile.fontSizeSmall') },
|
||||||
{ value: 'medium', label: t('profile.fontSizeMedium') },
|
{ value: 'medium', label: t('profile.fontSizeMedium') },
|
||||||
{ value: 'large', label: t('profile.fontSizeLarge') },
|
{ value: 'large', label: t('profile.fontSizeLarge') },
|
||||||
|
{ value: 'extra-large', label: t('profile.fontSizeExtraLarge') },
|
||||||
]}
|
]}
|
||||||
onChange={handleFontSizeChange}
|
onChange={handleFontSizeChange}
|
||||||
/>
|
/>
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection
|
<SelectCard
|
||||||
title={t('appearance.fontFamilyLabel') || 'Font Family'}
|
icon={Type}
|
||||||
icon={<span className="text-2xl">🔤</span>}
|
title={t('appearance.fontFamilyLabel') || 'Police'}
|
||||||
description={t('appearance.fontFamilyDescription') || 'Choose the font used throughout the app'}
|
description={t('appearance.fontFamilyDescription') || 'Choisissez la police de l\'application'}
|
||||||
>
|
|
||||||
<SettingSelect
|
|
||||||
label={t('appearance.fontFamilyLabel') || 'Font Family'}
|
|
||||||
description={t('appearance.selectFontFamily') || 'Inter is optimized for readability, System uses your OS native font'}
|
|
||||||
value={fontFamily}
|
value={fontFamily}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'inter', label: 'Inter' },
|
{ value: 'inter', label: 'Inter' },
|
||||||
{ value: 'system', label: t('appearance.fontSystem') || 'System Default' },
|
{ value: 'system', label: t('appearance.fontSystem') || 'Système' },
|
||||||
]}
|
]}
|
||||||
onChange={handleFontFamilyChange}
|
onChange={handleFontFamilyChange}
|
||||||
/>
|
/>
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection
|
<SelectCard
|
||||||
|
icon={LayoutGrid}
|
||||||
title={t('appearance.notesViewLabel')}
|
title={t('appearance.notesViewLabel')}
|
||||||
icon={<span className="text-2xl">📋</span>}
|
|
||||||
description={t('appearance.notesViewDescription')}
|
|
||||||
>
|
|
||||||
<SettingSelect
|
|
||||||
label={t('appearance.notesViewLabel')}
|
|
||||||
description={t('appearance.notesViewDescription')}
|
description={t('appearance.notesViewDescription')}
|
||||||
value={notesViewMode}
|
value={notesViewMode}
|
||||||
options={[
|
options={[
|
||||||
@@ -170,16 +188,11 @@ export function AppearanceSettingsClient({ initialFontSize, initialTheme, initia
|
|||||||
]}
|
]}
|
||||||
onChange={handleNotesViewChange}
|
onChange={handleNotesViewChange}
|
||||||
/>
|
/>
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection
|
<SelectCard
|
||||||
|
icon={Maximize2}
|
||||||
title={t('settings.cardSizeMode')}
|
title={t('settings.cardSizeMode')}
|
||||||
icon={<span className="text-2xl">📐</span>}
|
|
||||||
description={t('settings.cardSizeModeDescription')}
|
description={t('settings.cardSizeModeDescription')}
|
||||||
>
|
|
||||||
<SettingSelect
|
|
||||||
label={t('settings.cardSizeMode')}
|
|
||||||
description={t('settings.selectCardSizeMode')}
|
|
||||||
value={cardSizeMode}
|
value={cardSizeMode}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'variable', label: t('settings.cardSizeVariable') },
|
{ value: 'variable', label: t('settings.cardSizeVariable') },
|
||||||
@@ -187,7 +200,7 @@ export function AppearanceSettingsClient({ initialFontSize, initialTheme, initia
|
|||||||
]}
|
]}
|
||||||
onChange={handleCardSizeModeChange}
|
onChange={handleCardSizeModeChange}
|
||||||
/>
|
/>
|
||||||
</SettingsSection>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { SettingsSection } from '@/components/settings'
|
|
||||||
import { Button } from '@/components/ui/button'
|
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 { toast } from 'sonner'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
@@ -14,6 +13,8 @@ export default function DataSettingsPage() {
|
|||||||
const [isExporting, setIsExporting] = useState(false)
|
const [isExporting, setIsExporting] = useState(false)
|
||||||
const [isImporting, setIsImporting] = useState(false)
|
const [isImporting, setIsImporting] = useState(false)
|
||||||
const [isDeleting, setIsDeleting] = useState(false)
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
|
const [isReindexing, setIsReindexing] = useState(false)
|
||||||
|
const [isCleaningUp, setIsCleaningUp] = useState(false)
|
||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
setIsExporting(true)
|
setIsExporting(true)
|
||||||
@@ -24,15 +25,16 @@ export default function DataSettingsPage() {
|
|||||||
const url = window.URL.createObjectURL(blob)
|
const url = window.URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
a.href = url
|
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)
|
document.body.appendChild(a)
|
||||||
a.click()
|
a.click()
|
||||||
document.body.removeChild(a)
|
document.body.removeChild(a)
|
||||||
window.URL.revokeObjectURL(url)
|
window.URL.revokeObjectURL(url)
|
||||||
toast.success(t('dataManagement.export.success'))
|
toast.success(t('dataManagement.export.success'))
|
||||||
|
} else {
|
||||||
|
throw new Error()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Export error:', error)
|
|
||||||
toast.error(t('dataManagement.export.failed'))
|
toast.error(t('dataManagement.export.failed'))
|
||||||
} finally {
|
} finally {
|
||||||
setIsExporting(false)
|
setIsExporting(false)
|
||||||
@@ -42,47 +44,73 @@ export default function DataSettingsPage() {
|
|||||||
const handleImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0]
|
const file = event.target.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
|
||||||
setIsImporting(true)
|
setIsImporting(true)
|
||||||
try {
|
try {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', file)
|
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) {
|
if (response.ok) {
|
||||||
const result = await response.json()
|
const result = await response.json()
|
||||||
toast.success(t('dataManagement.import.success', { count: result.count }))
|
toast.success(t('dataManagement.import.success', { count: result.count }))
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Import failed')
|
const error = await response.json()
|
||||||
|
throw new Error(error.error || 'Import failed')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (err: any) {
|
||||||
console.error('Import error:', error)
|
toast.error(err.message || t('dataManagement.import.failed'))
|
||||||
toast.error(t('dataManagement.import.failed'))
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsImporting(false)
|
setIsImporting(false)
|
||||||
event.target.value = ''
|
event.target.value = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteAll = async () => {
|
const handleReindex = async () => {
|
||||||
if (!confirm(t('dataManagement.delete.confirm'))) {
|
setIsReindexing(true)
|
||||||
return
|
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)
|
setIsDeleting(true)
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/notes/delete-all', { method: 'POST' })
|
const response = await fetch('/api/notes/delete-all', { method: 'POST' })
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
toast.success(t('dataManagement.delete.success'))
|
toast.success(t('dataManagement.delete.success'))
|
||||||
router.refresh()
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
throw new Error()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Delete error:', error)
|
|
||||||
toast.error(t('dataManagement.delete.failed'))
|
toast.error(t('dataManagement.delete.failed'))
|
||||||
} finally {
|
} finally {
|
||||||
setIsDeleting(false)
|
setIsDeleting(false)
|
||||||
@@ -90,102 +118,114 @@ export default function DataSettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="max-w-4xl mx-auto space-y-8 p-6">
|
||||||
<div>
|
<div className="space-y-1">
|
||||||
<h1 className="text-3xl font-bold mb-2">{t('dataManagement.title')}</h1>
|
<h1 className="text-3xl font-bold tracking-tight text-foreground">{t('dataManagement.title')}</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
<p className="text-muted-foreground">{t('dataManagement.toolsDescription')}</p>
|
||||||
{t('dataManagement.toolsDescription')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SettingsSection
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
title={`💾 ${t('dataManagement.export.title')}`}
|
{/* Export card */}
|
||||||
icon={<span className="text-2xl">💾</span>}
|
<div className="bg-card rounded-xl border border-border p-6 shadow-sm flex flex-col justify-between transition-all hover:shadow-md">
|
||||||
description={t('dataManagement.export.description')}
|
<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">
|
||||||
<div className="flex items-center justify-between py-4">
|
<Download className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{t('dataManagement.export.title')}</p>
|
<h3 className="text-lg font-semibold text-foreground">{t('dataManagement.export.title')}</h3>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
|
||||||
{t('dataManagement.export.description')}
|
{t('dataManagement.export.description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
</div>
|
||||||
onClick={handleExport}
|
<Button onClick={handleExport} disabled={isExporting} className="mt-6 w-full">
|
||||||
disabled={isExporting}
|
{isExporting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Download className="h-4 w-4 mr-2" />}
|
||||||
>
|
|
||||||
{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')}
|
{isExporting ? t('dataManagement.exporting') : t('dataManagement.export.button')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection
|
{/* Import card */}
|
||||||
title={`📥 ${t('dataManagement.import.title')}`}
|
<div className="bg-card rounded-xl border border-border p-6 shadow-sm flex flex-col justify-between transition-all hover:shadow-md">
|
||||||
icon={<span className="text-2xl">📥</span>}
|
<div className="space-y-4">
|
||||||
description={t('dataManagement.import.description')}
|
<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 className="flex items-center justify-between py-4">
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{t('dataManagement.import.title')}</p>
|
<h3 className="text-lg font-semibold text-foreground">{t('dataManagement.import.title')}</h3>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
|
||||||
{t('dataManagement.import.description')}
|
{t('dataManagement.import.description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<input
|
<input type="file" accept=".json" onChange={handleImport} disabled={isImporting} className="hidden" id="import-file" />
|
||||||
type="file"
|
<Button onClick={() => document.getElementById('import-file')?.click()} disabled={isImporting} variant="outline" className="mt-6 w-full">
|
||||||
accept=".json"
|
{isImporting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Upload className="h-4 w-4 mr-2" />}
|
||||||
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')}
|
{isImporting ? t('dataManagement.importing') : t('dataManagement.import.button')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection
|
{/* Reindex card */}
|
||||||
title={`⚠️ ${t('dataManagement.dangerZone')}`}
|
<div className="bg-card rounded-xl border border-border p-6 shadow-sm flex flex-col justify-between transition-all hover:shadow-md">
|
||||||
icon={<span className="text-2xl">⚠️</span>}
|
<div className="space-y-4">
|
||||||
description={t('dataManagement.dangerZoneDescription')}
|
<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 className="flex items-center justify-between py-4 border-t border-red-200 dark:border-red-900">
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-red-600 dark:text-red-400">{t('dataManagement.delete.title')}</p>
|
<h3 className="text-lg font-semibold text-foreground">{t('dataManagement.indexing.title')}</h3>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
|
||||||
{t('dataManagement.delete.description')}
|
{t('dataManagement.indexing.description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
</div>
|
||||||
variant="destructive"
|
<Button onClick={handleReindex} disabled={isReindexing} variant="secondary" className="mt-6 w-full">
|
||||||
onClick={handleDeleteAll}
|
{isReindexing ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <RefreshCw className="h-4 w-4 mr-2" />}
|
||||||
disabled={isDeleting}
|
{isReindexing ? t('dataManagement.exporting') : t('dataManagement.indexing.button')}
|
||||||
>
|
</Button>
|
||||||
{isDeleting ? (
|
</div>
|
||||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
||||||
) : (
|
{/* Cleanup card */}
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
<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>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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} 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')}
|
{isDeleting ? t('dataManagement.deleting') : t('dataManagement.delete.button')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</SettingsSection>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { SettingsSection, SettingToggle, SettingSelect } from '@/components/settings'
|
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { updateAISettings } from '@/app/actions/ai-settings'
|
import { updateAISettings } from '@/app/actions/ai-settings'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { Globe, Bell } from 'lucide-react'
|
||||||
|
|
||||||
interface GeneralSettingsClientProps {
|
interface GeneralSettingsClientProps {
|
||||||
initialSettings: {
|
initialSettings: {
|
||||||
@@ -25,7 +25,6 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
|
|||||||
const handleLanguageChange = async (value: string) => {
|
const handleLanguageChange = async (value: string) => {
|
||||||
setLanguage(value)
|
setLanguage(value)
|
||||||
await updateAISettings({ preferredLanguage: value as any })
|
await updateAISettings({ preferredLanguage: value as any })
|
||||||
|
|
||||||
if (value === 'auto') {
|
if (value === 'auto') {
|
||||||
localStorage.removeItem('user-language')
|
localStorage.removeItem('user-language')
|
||||||
toast.success(t('settings.languageAuto') || 'Language set to Auto')
|
toast.success(t('settings.languageAuto') || 'Language set to Auto')
|
||||||
@@ -34,7 +33,6 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
|
|||||||
setContextLanguage(value as any)
|
setContextLanguage(value as any)
|
||||||
toast.success(t('profile.languageUpdateSuccess') || 'Language updated')
|
toast.success(t('profile.languageUpdateSuccess') || 'Language updated')
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => router.refresh(), 300)
|
setTimeout(() => router.refresh(), 300)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,63 +49,102 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
|
{/* Page title */}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold mb-2">{t('generalSettings.title')}</h1>
|
<h1 className="text-2xl font-bold tracking-tight text-foreground">{t('generalSettings.title')}</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
<p className="text-muted-foreground mt-1">{t('generalSettings.description')}</p>
|
||||||
{t('generalSettings.description')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SettingsSection
|
{/* 2-column card grid */}
|
||||||
title={t('settings.language')}
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
icon={<span className="text-2xl">🌍</span>}
|
{/* Language card */}
|
||||||
description={t('profile.languagePreferencesDescription')}
|
<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">
|
||||||
<SettingSelect
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||||
label={t('settings.language')}
|
<Globe className="h-5 w-5" />
|
||||||
description={t('settings.selectLanguage')}
|
</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}
|
value={language}
|
||||||
options={[
|
onChange={(e) => handleLanguageChange(e.target.value)}
|
||||||
{ value: 'auto', label: t('profile.autoDetect') },
|
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"
|
||||||
{ 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>
|
|
||||||
|
|
||||||
<SettingsSection
|
|
||||||
title={t('settings.notifications')}
|
|
||||||
icon={<span className="text-2xl">🔔</span>}
|
|
||||||
description={t('settings.notificationsDesc')}
|
|
||||||
>
|
>
|
||||||
<SettingToggle
|
<option value="auto">{t('profile.autoDetect')}</option>
|
||||||
label={t('settings.emailNotifications')}
|
<option value="en">English</option>
|
||||||
description={t('settings.emailNotificationsDesc')}
|
<option value="fr">Français</option>
|
||||||
checked={emailNotifications}
|
<option value="es">Español</option>
|
||||||
onChange={handleEmailNotificationsChange}
|
<option value="de">Deutsch</option>
|
||||||
/>
|
<option value="fa">فارسی</option>
|
||||||
<SettingToggle
|
<option value="it">Italiano</option>
|
||||||
label={t('settings.desktopNotifications')}
|
<option value="pt">Português</option>
|
||||||
description={t('settings.desktopNotificationsDesc')}
|
<option value="ru">Русский</option>
|
||||||
checked={desktopNotifications}
|
<option value="zh">中文</option>
|
||||||
onChange={handleDesktopNotificationsChange}
|
<option value="ja">日本語</option>
|
||||||
/>
|
<option value="ko">한국어</option>
|
||||||
</SettingsSection>
|
<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>
|
||||||
|
|
||||||
|
{/* 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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,17 +8,17 @@ export default function SettingsLayout({
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto py-10 px-4 max-w-6xl">
|
<div className="flex flex-col h-full">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
{/* Horizontal Tab Navigation */}
|
||||||
{/* Sidebar Navigation */}
|
<header className="flex items-center gap-1 px-8 bg-background border-b border-border shrink-0">
|
||||||
<aside className="lg:col-span-1">
|
|
||||||
<SettingsNav />
|
<SettingsNav />
|
||||||
</aside>
|
</header>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Page Content */}
|
||||||
<main className="lg:col-span-3 space-y-6">
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<div className="max-w-5xl mx-auto px-8 py-8 space-y-8">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,10 +5,7 @@ import { listMcpKeys, getMcpServerStatus } from '@/app/actions/mcp-keys'
|
|||||||
|
|
||||||
export default async function McpSettingsPage() {
|
export default async function McpSettingsPage() {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
|
if (!session?.user) redirect('/api/auth/signin')
|
||||||
if (!session?.user) {
|
|
||||||
redirect('/api/auth/signin')
|
|
||||||
}
|
|
||||||
|
|
||||||
const [keys, serverStatus] = await Promise.all([
|
const [keys, serverStatus] = await Promise.all([
|
||||||
listMcpKeys(),
|
listMcpKeys(),
|
||||||
@@ -16,7 +13,11 @@ export default async function McpSettingsPage() {
|
|||||||
])
|
])
|
||||||
|
|
||||||
return (
|
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} />
|
<McpSettingsPanel initialKeys={keys} serverStatus={serverStatus} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,146 +1,63 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
import { updateProfile, changePassword } from '@/app/actions/profile'
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import { Switch } from '@/components/ui/switch'
|
|
||||||
|
|
||||||
import { updateProfile, changePassword, updateFontSize, updateShowRecentNotes } from '@/app/actions/profile'
|
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
import { User, Lock } from 'lucide-react'
|
||||||
|
|
||||||
|
|
||||||
export function ProfileForm({ user, userAISettings }: { user: any; userAISettings?: any }) {
|
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 { 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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
<Card>
|
<div>
|
||||||
<CardHeader>
|
<h1 className="text-2xl font-bold tracking-tight text-foreground">{t('profile.title')}</h1>
|
||||||
<CardTitle>{t('profile.title')}</CardTitle>
|
<p className="text-muted-foreground mt-1">{t('profile.description')}</p>
|
||||||
<CardDescription>{t('profile.description')}</CardDescription>
|
</div>
|
||||||
</CardHeader>
|
|
||||||
|
<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>
|
||||||
|
<h3 className="font-semibold text-foreground">{t('profile.title')}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('profile.description')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<form action={async (formData) => {
|
<form action={async (formData) => {
|
||||||
const result = await updateProfile({ name: formData.get('name') as string })
|
const result = await updateProfile({ name: formData.get('name') as string })
|
||||||
if (result?.error) {
|
if (result?.error) toast.error(t('profile.updateFailed'))
|
||||||
toast.error(t('profile.updateFailed'))
|
else toast.success(t('profile.updateSuccess'))
|
||||||
} else {
|
}} className="space-y-4">
|
||||||
toast.success(t('profile.updateSuccess'))
|
<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" />
|
||||||
<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>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label htmlFor="email" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">{t('profile.email')}</label>
|
<label htmlFor="email" className="text-sm font-medium text-foreground">{t('profile.email')}</label>
|
||||||
<Input id="email" value={user.email} disabled className="bg-muted" />
|
<Input id="email" value={user.email} disabled className="bg-muted border-border opacity-60" />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
<Button type="submit" className="w-full mt-2">{t('general.save')}</Button>
|
||||||
<CardFooter>
|
|
||||||
<Button type="submit">{t('general.save')}</Button>
|
|
||||||
</CardFooter>
|
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</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">
|
||||||
<Card>
|
<Lock className="h-5 w-5" />
|
||||||
<CardHeader>
|
</div>
|
||||||
<CardTitle>{t('profile.changePassword')}</CardTitle>
|
<div>
|
||||||
<CardDescription>{t('profile.changePasswordDescription')}</CardDescription>
|
<h3 className="font-semibold text-foreground">{t('profile.changePassword')}</h3>
|
||||||
</CardHeader>
|
<p className="text-sm text-muted-foreground">{t('profile.changePasswordDescription')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<form action={async (formData) => {
|
<form action={async (formData) => {
|
||||||
const result = await changePassword(formData)
|
const result = await changePassword(formData)
|
||||||
if (result?.error) {
|
if (result?.error) {
|
||||||
@@ -150,30 +67,26 @@ export function ProfileForm({ user, userAISettings }: { user: any; userAISetting
|
|||||||
toast.error(msg)
|
toast.error(msg)
|
||||||
} else {
|
} else {
|
||||||
toast.success(t('profile.passwordChangeSuccess'))
|
toast.success(t('profile.passwordChangeSuccess'))
|
||||||
// Reset form manually or redirect
|
|
||||||
const form = document.querySelector('form#password-form') as HTMLFormElement
|
const form = document.querySelector('form#password-form') as HTMLFormElement
|
||||||
form?.reset()
|
form?.reset()
|
||||||
}
|
}
|
||||||
}} id="password-form">
|
}} id="password-form" className="space-y-4">
|
||||||
<CardContent className="space-y-4">
|
<div className="space-y-1.5">
|
||||||
<div className="space-y-2">
|
<label htmlFor="currentPassword" className="text-sm font-medium text-foreground">{t('profile.currentPassword')}</label>
|
||||||
<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 className="bg-muted border-border focus:border-primary" />
|
||||||
<Input id="currentPassword" name="currentPassword" type="password" required />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label htmlFor="newPassword" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">{t('profile.newPassword')}</label>
|
<label htmlFor="newPassword" className="text-sm font-medium text-foreground">{t('profile.newPassword')}</label>
|
||||||
<Input id="newPassword" name="newPassword" type="password" required minLength={6} />
|
<Input id="newPassword" name="newPassword" type="password" required minLength={6} className="bg-muted border-border focus:border-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label htmlFor="confirmPassword" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">{t('profile.confirmPassword')}</label>
|
<label htmlFor="confirmPassword" className="text-sm font-medium text-foreground">{t('profile.confirmPassword')}</label>
|
||||||
<Input id="confirmPassword" name="confirmPassword" type="password" required minLength={6} />
|
<Input id="confirmPassword" name="confirmPassword" type="password" required minLength={6} className="bg-muted border-border focus:border-primary" />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
<Button type="submit" className="w-full mt-2">{t('profile.updatePassword')}</Button>
|
||||||
<CardFooter>
|
|
||||||
<Button type="submit">{t('profile.updatePassword')}</Button>
|
|
||||||
</CardFooter>
|
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export async function createAgent(data: {
|
|||||||
role: string
|
role: string
|
||||||
sourceUrls?: string[]
|
sourceUrls?: string[]
|
||||||
sourceNotebookId?: string
|
sourceNotebookId?: string
|
||||||
|
sourceNoteIds?: string[]
|
||||||
targetNotebookId?: string
|
targetNotebookId?: string
|
||||||
frequency?: string
|
frequency?: string
|
||||||
tools?: string[]
|
tools?: string[]
|
||||||
@@ -28,6 +29,8 @@ export async function createAgent(data: {
|
|||||||
scheduledTime?: string
|
scheduledTime?: string
|
||||||
scheduledDay?: number
|
scheduledDay?: number
|
||||||
timezone?: string
|
timezone?: string
|
||||||
|
slideTheme?: string
|
||||||
|
slideStyle?: string
|
||||||
}) {
|
}) {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
@@ -43,6 +46,7 @@ export async function createAgent(data: {
|
|||||||
role: data.role,
|
role: data.role,
|
||||||
sourceUrls: data.sourceUrls ? JSON.stringify(data.sourceUrls) : null,
|
sourceUrls: data.sourceUrls ? JSON.stringify(data.sourceUrls) : null,
|
||||||
sourceNotebookId: data.sourceNotebookId || null,
|
sourceNotebookId: data.sourceNotebookId || null,
|
||||||
|
sourceNoteIds: data.sourceNoteIds ? JSON.stringify(data.sourceNoteIds) : null,
|
||||||
targetNotebookId: data.targetNotebookId || null,
|
targetNotebookId: data.targetNotebookId || null,
|
||||||
frequency: data.frequency || 'manual',
|
frequency: data.frequency || 'manual',
|
||||||
tools: data.tools ? JSON.stringify(data.tools) : '[]',
|
tools: data.tools ? JSON.stringify(data.tools) : '[]',
|
||||||
@@ -52,6 +56,8 @@ export async function createAgent(data: {
|
|||||||
scheduledTime: data.scheduledTime || '08:00',
|
scheduledTime: data.scheduledTime || '08:00',
|
||||||
scheduledDay: data.scheduledDay ?? null,
|
scheduledDay: data.scheduledDay ?? null,
|
||||||
timezone: data.timezone || null,
|
timezone: data.timezone || null,
|
||||||
|
slideTheme: data.slideTheme || null,
|
||||||
|
slideStyle: data.slideStyle || null,
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -88,6 +94,7 @@ export async function updateAgent(id: string, data: {
|
|||||||
role?: string
|
role?: string
|
||||||
sourceUrls?: string[]
|
sourceUrls?: string[]
|
||||||
sourceNotebookId?: string | null
|
sourceNotebookId?: string | null
|
||||||
|
sourceNoteIds?: string[] | null
|
||||||
targetNotebookId?: string | null
|
targetNotebookId?: string | null
|
||||||
frequency?: string
|
frequency?: string
|
||||||
isEnabled?: boolean
|
isEnabled?: boolean
|
||||||
@@ -98,6 +105,8 @@ export async function updateAgent(id: string, data: {
|
|||||||
scheduledTime?: string
|
scheduledTime?: string
|
||||||
scheduledDay?: number | null
|
scheduledDay?: number | null
|
||||||
timezone?: string
|
timezone?: string
|
||||||
|
slideTheme?: string | null
|
||||||
|
slideStyle?: string | null
|
||||||
}) {
|
}) {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
if (!session?.user?.id) {
|
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.role !== undefined) updateData.role = data.role
|
||||||
if (data.sourceUrls !== undefined) updateData.sourceUrls = JSON.stringify(data.sourceUrls)
|
if (data.sourceUrls !== undefined) updateData.sourceUrls = JSON.stringify(data.sourceUrls)
|
||||||
if (data.sourceNotebookId !== undefined) updateData.sourceNotebookId = data.sourceNotebookId
|
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.targetNotebookId !== undefined) updateData.targetNotebookId = data.targetNotebookId
|
||||||
if (data.frequency !== undefined) updateData.frequency = data.frequency
|
if (data.frequency !== undefined) updateData.frequency = data.frequency
|
||||||
if (data.isEnabled !== undefined) updateData.isEnabled = data.isEnabled
|
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.scheduledTime !== undefined) updateData.scheduledTime = data.scheduledTime
|
||||||
if (data.scheduledDay !== undefined) updateData.scheduledDay = data.scheduledDay
|
if (data.scheduledDay !== undefined) updateData.scheduledDay = data.scheduledDay
|
||||||
if (data.timezone !== undefined) updateData.timezone = data.timezone
|
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
|
// Recalculate nextRun when scheduling fields change
|
||||||
const shouldRecalcNextRun =
|
const shouldRecalcNextRun =
|
||||||
@@ -192,7 +204,7 @@ export async function getAgents() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const agents = await prisma.agent.findMany({
|
const agents = await prisma.agent.findMany({
|
||||||
where: { userId: session.user.id },
|
where: { userId: session.user.id, NOT: { frequency: 'one-shot' } },
|
||||||
include: {
|
include: {
|
||||||
_count: { select: { actions: true } },
|
_count: { select: { actions: true } },
|
||||||
actions: {
|
actions: {
|
||||||
@@ -220,21 +232,21 @@ export async function runAgent(id: string) {
|
|||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
throw new Error('Non autorise')
|
throw new Error('Non autorise')
|
||||||
}
|
}
|
||||||
|
const userId = session.user.id
|
||||||
|
|
||||||
try {
|
// Verify ownership
|
||||||
const { executeAgent } = await import('@/lib/ai/services/agent-executor.service')
|
const agent = await prisma.agent.findUnique({ where: { id }, select: { id: true, userId: true } })
|
||||||
const result = await executeAgent(id, session.user.id)
|
if (!agent || agent.userId !== userId) {
|
||||||
revalidatePath('/agents')
|
return { success: false, agentId: id, error: 'Agent introuvable' }
|
||||||
revalidatePath('/')
|
|
||||||
return result
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error running agent:', error)
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
actionId: '',
|
|
||||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 ---
|
// --- History ---
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ export async function getCanvases() {
|
|||||||
|
|
||||||
return prisma.canvas.findMany({
|
return prisma.canvas.findMany({
|
||||||
where: { userId: session.user.id },
|
where: { userId: session.user.id },
|
||||||
orderBy: { createdAt: 'asc' }
|
/** Most recently updated first — default canvas matches latest work (agent output, edits) */
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1375,28 +1375,35 @@ export async function syncAllEmbeddings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get all notes including those shared with the user
|
// 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();
|
const session = await auth();
|
||||||
if (!session?.user?.id) return [];
|
if (!session?.user?.id) return [];
|
||||||
|
|
||||||
const userId = session.user.id;
|
const userId = session.user.id;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch own notes + shared notes in parallel — no embedding to keep transfer fast
|
const whereClause: any = {
|
||||||
const [ownNotes, acceptedShares] = await Promise.all([
|
|
||||||
prisma.note.findMany({
|
|
||||||
where: {
|
|
||||||
userId,
|
userId,
|
||||||
trashedAt: null,
|
trashedAt: null,
|
||||||
...(includeArchived ? {} : { isArchived: false }),
|
...(includeArchived ? {} : { isArchived: false }),
|
||||||
},
|
...(notebookId !== undefined ? { notebookId: notebookId || null } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownNotes = await prisma.note.findMany({
|
||||||
|
where: whereClause,
|
||||||
select: NOTE_LIST_SELECT,
|
select: NOTE_LIST_SELECT,
|
||||||
orderBy: [
|
orderBy: [
|
||||||
{ isPinned: 'desc' },
|
{ isPinned: 'desc' },
|
||||||
{ order: 'asc' },
|
{ order: 'asc' },
|
||||||
{ updatedAt: 'desc' }
|
{ updatedAt: 'desc' }
|
||||||
]
|
]
|
||||||
}),
|
})
|
||||||
|
|
||||||
|
if (notebookId) {
|
||||||
|
return ownNotes.map(parseNote)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [acceptedShares] = await Promise.all([
|
||||||
prisma.noteShare.findMany({
|
prisma.noteShare.findMany({
|
||||||
where: { userId, status: 'accepted' },
|
where: { userId, status: 'accepted' },
|
||||||
include: { note: { select: NOTE_LIST_SELECT } }
|
include: { note: { select: NOTE_LIST_SELECT } }
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { auth } from '@/auth';
|
|||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export async function GET() {
|
export async function POST() {
|
||||||
try {
|
try {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
if (!session?.user?.id || (session.user as any).role !== 'ADMIN') {
|
if (!session?.user?.id || (session.user as any).role !== 'ADMIN') {
|
||||||
|
|||||||
140
memento-note/app/api/agents/run-for-note/route.ts
Normal file
140
memento-note/app/api/agents/run-for-note/route.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
47
memento-note/app/api/canvas/download/route.ts
Normal file
47
memento-note/app/api/canvas/download/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
70
memento-note/app/api/canvas/slides/route.ts
Normal file
70
memento-note/app/api/canvas/slides/route.ts
Normal 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' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,14 +14,15 @@ export const dynamic = 'force-dynamic'
|
|||||||
* Authorization: Bearer <CRON_SECRET>
|
* Authorization: Bearer <CRON_SECRET>
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
// Optional auth
|
|
||||||
const cronSecret = process.env.CRON_SECRET
|
const cronSecret = process.env.CRON_SECRET
|
||||||
if (cronSecret) {
|
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')
|
const authHeader = request.headers.get('authorization')
|
||||||
if (authHeader !== `Bearer ${cronSecret}`) {
|
if (authHeader !== `Bearer ${cronSecret}`) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|||||||
@@ -4,15 +4,15 @@ import prisma from '@/lib/prisma';
|
|||||||
export const dynamic = 'force-dynamic'; // No caching
|
export const dynamic = 'force-dynamic'; // No caching
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
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
|
const cronSecret = process.env.CRON_SECRET
|
||||||
if (cronSecret) {
|
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')
|
const authHeader = request.headers.get('authorization')
|
||||||
if (authHeader !== `Bearer ${cronSecret}`) {
|
if (authHeader !== `Bearer ${cronSecret}`) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/auth'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
// Visible directement dans `docker logs memento-web`
|
|
||||||
console.error('[CLIENT-ERROR-REPORT]', JSON.stringify(body, null, 2))
|
console.error('[CLIENT-ERROR-REPORT]', JSON.stringify(body, null, 2))
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore malformed payload
|
||||||
}
|
}
|
||||||
return NextResponse.json({ ok: true })
|
return NextResponse.json({ ok: true })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
52
memento-note/app/api/notes/cleanup/route.ts
Normal file
52
memento-note/app/api/notes/cleanup/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -91,9 +91,15 @@ export async function GET(req: NextRequest) {
|
|||||||
id: note.id,
|
id: note.id,
|
||||||
title: note.title,
|
title: note.title,
|
||||||
content: note.content,
|
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,
|
createdAt: note.createdAt,
|
||||||
updatedAt: note.updatedAt,
|
updatedAt: note.updatedAt,
|
||||||
isPinned: note.isPinned,
|
|
||||||
notebookId: note.notebookId,
|
notebookId: note.notebookId,
|
||||||
labelRelations: note.labelRelations.map(label => ({
|
labelRelations: note.labelRelations.map(label => ({
|
||||||
id: label.id,
|
id: label.id,
|
||||||
|
|||||||
@@ -111,11 +111,12 @@ export async function POST(req: NextRequest) {
|
|||||||
const mappedNotebookId = notebookIdMap.get(note.notebookId) || null
|
const mappedNotebookId = notebookIdMap.get(note.notebookId) || null
|
||||||
|
|
||||||
// Get label IDs
|
// Get label IDs
|
||||||
|
const labelNames = (note.labels || note.labelRelations || []).map((l: any) => l.name).filter(Boolean)
|
||||||
const labels = await prisma.label.findMany({
|
const labels = await prisma.label.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
name: {
|
name: {
|
||||||
in: note.labels.map((l: any) => l.name)
|
in: labelNames
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -125,8 +126,14 @@ export async function POST(req: NextRequest) {
|
|||||||
data: {
|
data: {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
title: note.title || 'Untitled',
|
title: note.title || 'Untitled',
|
||||||
content: note.content,
|
content: note.content || '',
|
||||||
|
color: note.color || 'default',
|
||||||
isPinned: note.isPinned || false,
|
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,
|
notebookId: mappedNotebookId,
|
||||||
labelRelations: {
|
labelRelations: {
|
||||||
connect: labels.map(label => ({ id: label.id }))
|
connect: labels.map(label => ({ id: label.id }))
|
||||||
|
|||||||
59
memento-note/app/api/notes/reindex/route.ts
Normal file
59
memento-note/app/api/notes/reindex/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import Credentials from 'next-auth/providers/credentials';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import prisma from '@/lib/prisma';
|
import prisma from '@/lib/prisma';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { rateLimit } from '@/lib/rate-limit';
|
||||||
|
|
||||||
export const { auth, signIn, signOut, handlers } = NextAuth({
|
export const { auth, signIn, signOut, handlers } = NextAuth({
|
||||||
...authConfig,
|
...authConfig,
|
||||||
@@ -16,17 +17,21 @@ export const { auth, signIn, signOut, handlers } = NextAuth({
|
|||||||
.safeParse(credentials);
|
.safeParse(credentials);
|
||||||
|
|
||||||
if (!parsedCredentials.success) {
|
if (!parsedCredentials.success) {
|
||||||
console.error('Invalid credentials format');
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { email, password } = parsedCredentials.data;
|
const { email, password } = parsedCredentials.data;
|
||||||
|
|
||||||
|
const { allowed } = rateLimit(`login:${email.toLowerCase()}`)
|
||||||
|
if (!allowed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { email: email.toLowerCase() }
|
where: { email: email.toLowerCase() }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user || !user.password) {
|
if (!user || !user.password) {
|
||||||
console.error('User not found or no password set');
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,10 +46,8 @@ export const { auth, signIn, signOut, handlers } = NextAuth({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error('Password mismatch');
|
|
||||||
return null;
|
return null;
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('CRITICAL AUTH ERROR:', error);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
71
memento-note/components/admin-nav.tsx
Normal file
71
memento-note/components/admin-nav.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
* Compact card matching the reference design — with a "Next Run / Status" footer.
|
* 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 { formatDistanceToNow } from 'date-fns'
|
||||||
import { fr } from 'date-fns/locale/fr'
|
import { fr } from 'date-fns/locale/fr'
|
||||||
import { enUS } from 'date-fns/locale/en-US'
|
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 dateLocale = language === 'fr' ? fr : enUS
|
||||||
const isNew = Date.now() - new Date(agent.createdAt).getTime() < 5 * 60 * 1000
|
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 () => {
|
const handleRun = async () => {
|
||||||
setIsRunning(true)
|
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 {
|
try {
|
||||||
const { runAgent } = await import('@/app/actions/agent-actions')
|
const { runAgent } = await import('@/app/actions/agent-actions')
|
||||||
const result = await runAgent(agent.id)
|
const result = await runAgent(agent.id)
|
||||||
if (result.success) {
|
if (!result.success) {
|
||||||
toast.success(t('agents.toasts.runSuccess', { name: agent.name }))
|
toast.error(t('agents.toasts.runError', { error: result.error || t('agents.toasts.runFailed') }), { id: toastId })
|
||||||
} else {
|
|
||||||
toast.error(t('agents.toasts.runError', { error: result.error || t('agents.toasts.runFailed') }))
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.error(t('agents.toasts.runGenericError'))
|
|
||||||
} finally {
|
|
||||||
setIsRunning(false)
|
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()
|
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'), { id: toastId })
|
||||||
|
setIsRunning(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,15 +6,15 @@
|
|||||||
* Novice-friendly: hides system prompt and tools behind "Advanced mode".
|
* Novice-friendly: hides system prompt and tools behind "Advanced mode".
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useMemo, useRef } from 'react'
|
import { useState, useMemo, useRef, useCallback, useEffect } from 'react'
|
||||||
import { X, Plus, Trash2, Globe, FileSearch, FilePlus, FileText, ExternalLink, Brain, ChevronDown, ChevronUp, HelpCircle, Mail, ImageIcon } from 'lucide-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 { toast } from 'sonner'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
||||||
|
|
||||||
// --- Types ---
|
// --- Types ---
|
||||||
|
|
||||||
type AgentType = 'scraper' | 'researcher' | 'monitor' | 'custom'
|
type AgentType = 'scraper' | 'researcher' | 'monitor' | 'custom' | 'slide-generator' | 'excalidraw-generator'
|
||||||
|
|
||||||
/** Small "?" tooltip shown next to form labels */
|
/** Small "?" tooltip shown next to form labels */
|
||||||
function FieldHelp({ tooltip }: { tooltip: string }) {
|
function FieldHelp({ tooltip }: { tooltip: string }) {
|
||||||
@@ -41,6 +41,7 @@ interface AgentFormProps {
|
|||||||
role: string
|
role: string
|
||||||
sourceUrls?: string | null
|
sourceUrls?: string | null
|
||||||
sourceNotebookId?: string | null
|
sourceNotebookId?: string | null
|
||||||
|
sourceNoteIds?: string | null
|
||||||
targetNotebookId?: string | null
|
targetNotebookId?: string | null
|
||||||
frequency: string
|
frequency: string
|
||||||
tools?: string | null
|
tools?: string | null
|
||||||
@@ -50,6 +51,8 @@ interface AgentFormProps {
|
|||||||
scheduledTime?: string | null
|
scheduledTime?: string | null
|
||||||
scheduledDay?: number | null
|
scheduledDay?: number | null
|
||||||
timezone?: string | null
|
timezone?: string | null
|
||||||
|
slideTheme?: string | null
|
||||||
|
slideStyle?: string | null
|
||||||
} | null
|
} | null
|
||||||
notebooks: { id: string; name: string; icon?: string | null }[]
|
notebooks: { id: string; name: string; icon?: string | null }[]
|
||||||
onSave: (data: FormData) => Promise<void>
|
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'],
|
researcher: ['web_search', 'web_scrape', 'note_search', 'note_create', 'memory_search'],
|
||||||
monitor: ['note_search', 'note_read', 'note_create', 'memory_search'],
|
monitor: ['note_search', 'note_read', 'note_create', 'memory_search'],
|
||||||
custom: ['memory_search'],
|
custom: ['memory_search'],
|
||||||
|
'slide-generator': ['generate_pptx'],
|
||||||
|
'excalidraw-generator': ['generate_excalidraw'],
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Shared class strings ---
|
// --- Shared class strings ---
|
||||||
@@ -89,6 +94,13 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
|
|||||||
return ['']
|
return ['']
|
||||||
})
|
})
|
||||||
const [sourceNotebookId, setSourceNotebookId] = useState(agent?.sourceNotebookId || '')
|
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 [targetNotebookId, setTargetNotebookId] = useState(agent?.targetNotebookId || '')
|
||||||
const [frequency, setFrequency] = useState(agent?.frequency || 'manual')
|
const [frequency, setFrequency] = useState(agent?.frequency || 'manual')
|
||||||
const [scheduledTime, setScheduledTime] = useState(agent?.scheduledTime || '08:00')
|
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 [maxSteps, setMaxSteps] = useState(agent?.maxSteps || 10)
|
||||||
const [notifyEmail, setNotifyEmail] = useState(agent?.notifyEmail || false)
|
const [notifyEmail, setNotifyEmail] = useState(agent?.notifyEmail || false)
|
||||||
const [includeImages, setIncludeImages] = useState(agent?.includeImages || 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)
|
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(() => {
|
const [showAdvanced, setShowAdvanced] = useState(() => {
|
||||||
// Auto-open advanced if editing an agent with custom tools or custom prompt
|
// Auto-open advanced if editing an agent with custom tools or custom prompt
|
||||||
if (agent?.tools) {
|
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: 'note_create', icon: FilePlus, labelKey: 'agents.tools.noteCreate', external: false },
|
||||||
{ id: 'url_fetch', icon: ExternalLink, labelKey: 'agents.tools.urlFetch', 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: '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
|
// 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('frequency', frequency)
|
||||||
formData.set('targetNotebookId', targetNotebookId)
|
formData.set('targetNotebookId', targetNotebookId)
|
||||||
|
|
||||||
if (type === 'monitor') {
|
if (type === 'monitor' || type === 'slide-generator' || type === 'excalidraw-generator') {
|
||||||
formData.set('sourceNotebookId', sourceNotebookId)
|
formData.set('sourceNotebookId', sourceNotebookId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sourceNoteIds.length > 0) {
|
||||||
|
formData.set('sourceNoteIds', JSON.stringify(sourceNoteIds))
|
||||||
|
}
|
||||||
|
|
||||||
const validUrls = urls.filter(u => u.trim())
|
const validUrls = urls.filter(u => u.trim())
|
||||||
if (validUrls.length > 0) {
|
if (validUrls.length > 0) {
|
||||||
formData.set('sourceUrls', JSON.stringify(validUrls))
|
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('scheduledDay', String(scheduledDay))
|
||||||
formData.set('timezone', timezone)
|
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)
|
await onSave(formData)
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('agents.toasts.saveError'))
|
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 }[] = [
|
const agentTypes: { value: AgentType; labelKey: string; descKey: string }[] = [
|
||||||
{ value: 'researcher', labelKey: 'agents.types.researcher', descKey: 'agents.typeDescriptions.researcher' },
|
{ value: 'researcher', labelKey: 'agents.types.researcher', descKey: 'agents.typeDescriptions.researcher' },
|
||||||
{ value: 'scraper', labelKey: 'agents.types.scraper', descKey: 'agents.typeDescriptions.scraper' },
|
{ value: 'scraper', labelKey: 'agents.types.scraper', descKey: 'agents.typeDescriptions.scraper' },
|
||||||
{ value: 'monitor', labelKey: 'agents.types.monitor', descKey: 'agents.typeDescriptions.monitor' },
|
{ 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' },
|
{ value: 'custom', labelKey: 'agents.types.custom', descKey: 'agents.typeDescriptions.custom' },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -318,13 +377,13 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Source Notebook (monitor only) */}
|
{/* Source Notebook (monitor, slide, excalidraw) */}
|
||||||
{showSourceNotebook && (
|
{showSourceNotebook && (
|
||||||
<div>
|
<div>
|
||||||
<label className={labelCls}>{t('agents.form.sourceNotebook')}<FieldHelp tooltip={t('agents.help.tooltips.sourceNotebook')} /></label>
|
<label className={labelCls}>{t('agents.form.sourceNotebook')}<FieldHelp tooltip={t('agents.help.tooltips.sourceNotebook')} /></label>
|
||||||
<select
|
<select
|
||||||
value={sourceNotebookId}
|
value={sourceNotebookId}
|
||||||
onChange={e => setSourceNotebookId(e.target.value)}
|
onChange={e => { setSourceNotebookId(e.target.value); setSourceNoteIds([]) }}
|
||||||
className={selectCls}
|
className={selectCls}
|
||||||
>
|
>
|
||||||
<option value="">{t('agents.form.selectNotebook')}</option>
|
<option value="">{t('agents.form.selectNotebook')}</option>
|
||||||
@@ -337,7 +396,149 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
|
|||||||
</div>
|
</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>
|
<div>
|
||||||
<label className={labelCls}>{t('agents.form.targetNotebook')}<FieldHelp tooltip={t('agents.help.tooltips.targetNotebook')} /></label>
|
<label className={labelCls}>{t('agents.form.targetNotebook')}<FieldHelp tooltip={t('agents.help.tooltips.targetNotebook')} /></label>
|
||||||
<select
|
<select
|
||||||
@@ -353,6 +554,7 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Frequency */}
|
{/* Frequency */}
|
||||||
<div>
|
<div>
|
||||||
@@ -423,7 +625,8 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Email Notification */}
|
{/* Email Notification — hidden for file generators */}
|
||||||
|
{type !== 'slide-generator' && type !== 'excalidraw-generator' && (
|
||||||
<div
|
<div
|
||||||
onClick={() => setNotifyEmail(!notifyEmail)}
|
onClick={() => setNotifyEmail(!notifyEmail)}
|
||||||
className={`flex items-center gap-3 p-3 rounded-lg border-2 cursor-pointer transition-all ${
|
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 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>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Include Images */}
|
{/* Include Images — hidden for file generators */}
|
||||||
|
{type !== 'slide-generator' && type !== 'excalidraw-generator' && (
|
||||||
<div
|
<div
|
||||||
onClick={() => setIncludeImages(!includeImages)}
|
onClick={() => setIncludeImages(!includeImages)}
|
||||||
className={`flex items-center gap-3 p-3 rounded-lg border-2 cursor-pointer transition-all ${
|
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 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>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Advanced mode toggle */}
|
{/* Advanced mode toggle */}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Card } from '@/components/ui/card'
|
|
||||||
import { Switch } from '@/components/ui/switch'
|
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 { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
import { updateAISettings } from '@/app/actions/ai-settings'
|
import { updateAISettings } from '@/app/actions/ai-settings'
|
||||||
import { DemoModeToggle } from '@/components/demo-mode-toggle'
|
import { DemoModeToggle } from '@/components/demo-mode-toggle'
|
||||||
import { toast } from 'sonner'
|
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'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
interface AISettingsPanelProps {
|
interface AISettingsPanelProps {
|
||||||
@@ -35,17 +33,13 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
|
|||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
|
|
||||||
const handleToggle = async (feature: string, value: boolean) => {
|
const handleToggle = async (feature: string, value: boolean) => {
|
||||||
// Optimistic update
|
|
||||||
setSettings(prev => ({ ...prev, [feature]: value }))
|
setSettings(prev => ({ ...prev, [feature]: value }))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsPending(true)
|
setIsPending(true)
|
||||||
await updateAISettings({ [feature]: value })
|
await updateAISettings({ [feature]: value })
|
||||||
toast.success(t('aiSettings.saved'))
|
toast.success(t('aiSettings.saved'))
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Error updating setting:', error)
|
|
||||||
toast.error(t('aiSettings.error'))
|
toast.error(t('aiSettings.error'))
|
||||||
// Revert on error
|
|
||||||
setSettings(initialSettings)
|
setSettings(initialSettings)
|
||||||
} finally {
|
} finally {
|
||||||
setIsPending(false)
|
setIsPending(false)
|
||||||
@@ -54,31 +48,11 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
|
|||||||
|
|
||||||
const handleFrequencyChange = async (value: 'daily' | 'weekly' | 'custom') => {
|
const handleFrequencyChange = async (value: 'daily' | 'weekly' | 'custom') => {
|
||||||
setSettings(prev => ({ ...prev, memoryEchoFrequency: value }))
|
setSettings(prev => ({ ...prev, memoryEchoFrequency: value }))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsPending(true)
|
setIsPending(true)
|
||||||
await updateAISettings({ memoryEchoFrequency: value })
|
await updateAISettings({ memoryEchoFrequency: value })
|
||||||
toast.success(t('aiSettings.saved'))
|
toast.success(t('aiSettings.saved'))
|
||||||
} catch (error) {
|
} catch {
|
||||||
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)
|
|
||||||
toast.error(t('aiSettings.error'))
|
toast.error(t('aiSettings.error'))
|
||||||
setSettings(initialSettings)
|
setSettings(initialSettings)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -88,179 +62,154 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
|
|||||||
|
|
||||||
const handleDemoModeToggle = async (enabled: boolean) => {
|
const handleDemoModeToggle = async (enabled: boolean) => {
|
||||||
setSettings(prev => ({ ...prev, demoMode: enabled }))
|
setSettings(prev => ({ ...prev, demoMode: enabled }))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsPending(true)
|
setIsPending(true)
|
||||||
await updateAISettings({ demoMode: enabled })
|
await updateAISettings({ demoMode: enabled })
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Error toggling demo mode:', error)
|
|
||||||
toast.error(t('aiSettings.error'))
|
toast.error(t('aiSettings.error'))
|
||||||
setSettings(initialSettings)
|
setSettings(initialSettings)
|
||||||
throw error
|
throw new Error()
|
||||||
} finally {
|
} finally {
|
||||||
setIsPending(false)
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
{isPending && (
|
{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" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
{t('aiSettings.saving')}
|
{t('aiSettings.saving')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Feature Toggles */}
|
{/* Feature toggles as cards */}
|
||||||
<div className="space-y-4">
|
<div>
|
||||||
<h2 className="text-xl font-semibold">{t('aiSettings.features')}</h2>
|
<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">
|
||||||
<FeatureToggle
|
{features.map(({ key, icon: Icon, name, description, value }) => (
|
||||||
name={t('titleSuggestions.available').replace('💡 ', '')}
|
<div
|
||||||
description={t('aiSettings.titleSuggestionsDesc')}
|
key={key}
|
||||||
checked={settings.titleSuggestions}
|
className="bg-card rounded-lg border border-border p-5 shadow-sm flex items-start justify-between gap-4"
|
||||||
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}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-start gap-3">
|
||||||
<RadioGroupItem value="daily" id="daily" />
|
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0 mt-0.5">
|
||||||
<Label htmlFor="daily" className="font-normal">
|
<Icon className="h-4 w-4" />
|
||||||
{t('aiSettings.frequencyDaily')}
|
|
||||||
</Label>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div>
|
||||||
<RadioGroupItem value="weekly" id="weekly" />
|
<p className="text-sm font-medium text-foreground">{name}</p>
|
||||||
<Label htmlFor="weekly" className="font-normal">
|
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
|
||||||
{t('aiSettings.frequencyWeekly')}
|
|
||||||
</Label>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<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>
|
</RadioGroup>
|
||||||
</Card>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Language Detection Toggle */}
|
{/* Note History mode — shown when enabled */}
|
||||||
<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 && (
|
{settings.noteHistory && (
|
||||||
<div className="space-y-2 rounded-lg border border-border/50 bg-muted/30 p-3">
|
<div className="bg-card rounded-lg border border-border p-5 shadow-sm">
|
||||||
<p className="text-sm font-medium">{t('notes.historyMode')}</p>
|
<h3 className="text-sm font-semibold text-foreground mb-1">{t('notes.historyMode')}</h3>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
value={settings.noteHistoryMode ?? 'manual'}
|
value={settings.noteHistoryMode ?? 'manual'}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
const mode = value as 'manual' | 'auto'
|
const mode = value as 'manual' | 'auto'
|
||||||
setSettings((s) => ({ ...s, noteHistoryMode: mode }))
|
setSettings(s => ({ ...s, noteHistoryMode: mode }))
|
||||||
updateAISettings({ noteHistoryMode: mode }).then(() => {
|
updateAISettings({ noteHistoryMode: mode }).then(() => toast.success(t('settings.settingsSaved')))
|
||||||
toast.success(t('settings.settingsSaved'))
|
|
||||||
})
|
|
||||||
}}
|
}}
|
||||||
className="space-y-2"
|
className="space-y-3 mt-3"
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-2">
|
{[
|
||||||
<RadioGroupItem value="manual" id="history-manual" />
|
{ value: 'manual', label: t('notes.historyModeManual'), desc: t('notes.historyModeManualDesc') },
|
||||||
<div className="grid gap-0.5 leading-none">
|
{ value: 'auto', label: t('notes.historyModeAuto'), desc: t('notes.historyModeAutoDesc') },
|
||||||
<Label htmlFor="history-manual" className="text-sm font-medium">
|
].map((opt) => (
|
||||||
{t('notes.historyModeManual')}
|
<div key={opt.value} className="flex items-start gap-2">
|
||||||
</Label>
|
<RadioGroupItem value={opt.value} id={`history-${opt.value}`} className="mt-0.5" />
|
||||||
<p className="text-xs text-muted-foreground">
|
<div>
|
||||||
{t('notes.historyModeManualDesc')}
|
<Label htmlFor={`history-${opt.value}`} className="text-sm font-medium cursor-pointer">{opt.label}</Label>
|
||||||
</p>
|
<p className="text-xs text-muted-foreground">{opt.desc}</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>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Demo Mode Toggle */}
|
{/* Demo Mode */}
|
||||||
<DemoModeToggle
|
<DemoModeToggle demoMode={settings.demoMode} onToggle={handleDemoModeToggle} />
|
||||||
demoMode={settings.demoMode}
|
|
||||||
onToggle={handleDemoModeToggle}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Globe, BookOpen, FileText, RotateCcw, Check,
|
Globe, BookOpen, FileText, RotateCcw, Check,
|
||||||
Maximize2, ImageIcon, Link2, Download, ArrowDownToLine,
|
Maximize2, ImageIcon, Link2, Download, ArrowDownToLine,
|
||||||
GitMerge, PlusCircle, Eye, Code, Languages,
|
GitMerge, PlusCircle, Eye, Code, Languages,
|
||||||
|
Presentation, PenTool, ExternalLink,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { MarkdownContent } from '@/components/markdown-content'
|
import { MarkdownContent } from '@/components/markdown-content'
|
||||||
@@ -71,11 +72,18 @@ const ACTION_IDS = [
|
|||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface GenerateResult {
|
||||||
|
type: 'slides' | 'diagram'
|
||||||
|
canvasId?: string
|
||||||
|
noteId?: string
|
||||||
|
}
|
||||||
|
|
||||||
interface ContextualAIChatProps {
|
interface ContextualAIChatProps {
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
noteTitle?: string
|
noteTitle?: string
|
||||||
noteContent?: string
|
noteContent?: string
|
||||||
noteImages?: string[]
|
noteImages?: string[]
|
||||||
|
noteId?: string
|
||||||
/** Called when an action result should be injected into the note */
|
/** Called when an action result should be injected into the note */
|
||||||
onApplyToNote?: (newContent: string) => void
|
onApplyToNote?: (newContent: string) => void
|
||||||
/** Called when the user wants to undo the last injected action */
|
/** Called when the user wants to undo the last injected action */
|
||||||
@@ -95,6 +103,7 @@ export function ContextualAIChat({
|
|||||||
noteTitle,
|
noteTitle,
|
||||||
noteContent,
|
noteContent,
|
||||||
noteImages,
|
noteImages,
|
||||||
|
noteId,
|
||||||
onApplyToNote,
|
onApplyToNote,
|
||||||
onUndoLastAction,
|
onUndoLastAction,
|
||||||
lastActionApplied = false,
|
lastActionApplied = false,
|
||||||
@@ -116,7 +125,16 @@ export function ContextualAIChat({
|
|||||||
const [actionPreview, setActionPreview] = useState<{ label: string; text: string } | null>(null)
|
const [actionPreview, setActionPreview] = useState<{ label: string; text: string } | null>(null)
|
||||||
const [showLangPicker, setShowLangPicker] = useState(false)
|
const [showLangPicker, setShowLangPicker] = useState(false)
|
||||||
const [translateTarget, setTranslateTarget] = useState('')
|
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('')
|
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
|
// Resource tab state
|
||||||
const [resourceUrl, setResourceUrl] = useState('')
|
const [resourceUrl, setResourceUrl] = useState('')
|
||||||
@@ -249,6 +267,104 @@ export function ContextualAIChat({
|
|||||||
|
|
||||||
const handleDiscardPreview = () => setActionPreview(null)
|
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 ────────────────────────────────────────────────────
|
// ── Resource tab handlers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
const handleScrapeUrl = async () => {
|
const handleScrapeUrl = async () => {
|
||||||
@@ -583,7 +699,7 @@ export function ContextualAIChat({
|
|||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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} />
|
<MarkdownContent content={resourcePreview.text} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 px-3 py-2 border-t border-primary/20">
|
<div className="flex gap-2 px-3 py-2 border-t border-primary/20">
|
||||||
@@ -841,7 +957,7 @@ export function ContextualAIChat({
|
|||||||
<button
|
<button
|
||||||
key={l}
|
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'}`}
|
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>
|
>{l}</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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"
|
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...'}
|
placeholder={t('ai.action.customLang') || 'Autre langue...'}
|
||||||
value={customLangInput}
|
value={customLangInput}
|
||||||
onChange={e => setCustomLangInput(e.target.value)}
|
onChange={e => { const val = e.target.value; setCustomLangInput(val); setTranslateTarget(val); }}
|
||||||
onKeyDown={e => { if (e.key === 'Enter' && customLangInput.trim()) { setTranslateTarget(customLangInput.trim()); setCustomLangInput('') } }}
|
onKeyDown={e => { if (e.key === 'Enter' && translateTarget.trim()) { handleAction(action, translateTarget.trim()); } }}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
disabled={!!actionLoading || !translateTarget}
|
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 */}
|
{/* Undo last action shortcut */}
|
||||||
{lastActionApplied && onUndoLastAction && (
|
{lastActionApplied && onUndoLastAction && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -14,7 +14,9 @@ export function ErrorReporter() {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
keepalive: true,
|
keepalive: true,
|
||||||
}).catch(() => {})
|
}).catch(() => {
|
||||||
|
console.error('[ErrorReporter] Failed to report client error (endpoint removed)')
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function onError(event: ErrorEvent) {
|
function onError(event: ErrorEvent) {
|
||||||
|
|||||||
@@ -237,13 +237,9 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
}
|
}
|
||||||
let allNotes = search
|
let allNotes = search
|
||||||
? await searchNotes(search, semanticMode, notebook || undefined)
|
? await searchNotes(search, semanticMode, notebook || undefined)
|
||||||
: await getAllNotes()
|
: await getAllNotes(false, notebook || undefined)
|
||||||
|
|
||||||
// Filtre notebook côté client
|
if (!notebook && !search) {
|
||||||
// Shared notes appear ONLY in inbox (general notes), not in notebooks
|
|
||||||
if (notebook) {
|
|
||||||
allNotes = allNotes.filter((note: any) => note.notebookId === notebook && !note._isShared)
|
|
||||||
} else {
|
|
||||||
allNotes = allNotes.filter((note: any) => !note.notebookId || note._isShared)
|
allNotes = allNotes.filter((note: any) => !note.notebookId || note._isShared)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { Download, Presentation } from 'lucide-react'
|
||||||
import type { ExcalidrawElement } from '@excalidraw/excalidraw/element/types'
|
import type { ExcalidrawElement } from '@excalidraw/excalidraw/element/types'
|
||||||
import type { AppState, BinaryFiles } from '@excalidraw/excalidraw/types'
|
import type { AppState, BinaryFiles } from '@excalidraw/excalidraw/types'
|
||||||
|
import type { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types'
|
||||||
import '@excalidraw/excalidraw/index.css'
|
import '@excalidraw/excalidraw/index.css'
|
||||||
|
|
||||||
// Dynamic import with SSR disabled is REQUIRED for Excalidraw due to window dependencies
|
|
||||||
const Excalidraw = dynamic(
|
const Excalidraw = dynamic(
|
||||||
async () => (await import('@excalidraw/excalidraw')).Excalidraw,
|
async () => (await import('@excalidraw/excalidraw')).Excalidraw,
|
||||||
{ ssr: false }
|
{ ssr: false }
|
||||||
@@ -19,34 +20,106 @@ interface CanvasBoardProps {
|
|||||||
name: string
|
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) {
|
export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
|
||||||
const [isDarkMode, setIsDarkMode] = useState(false)
|
const [isDarkMode, setIsDarkMode] = useState(false)
|
||||||
const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'error'>('saved')
|
const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'error'>('saved')
|
||||||
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
const excalidrawAPIRef = useRef<ExcalidrawImperativeAPI | null>(null)
|
||||||
const filesRef = useRef<BinaryFiles>({})
|
const filesRef = useRef<BinaryFiles>({})
|
||||||
|
|
||||||
// Parse initial state safely (ONLY ON MOUNT to prevent Next.js revalidation infinite loops)
|
const scene = useMemo(
|
||||||
const [elements] = useState<readonly ExcalidrawElement[]>(() => {
|
() => parseCanvasScene(initialData),
|
||||||
if (initialData) {
|
[canvasId, initialData]
|
||||||
try {
|
)
|
||||||
const parsed = JSON.parse(initialData)
|
|
||||||
if (parsed && Array.isArray(parsed)) {
|
useEffect(() => {
|
||||||
return parsed
|
filesRef.current = scene.files
|
||||||
} else if (parsed && parsed.elements) {
|
}, [scene])
|
||||||
// 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 []
|
|
||||||
})
|
|
||||||
|
|
||||||
// Detect dark mode from html class
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkDarkMode = () => {
|
const checkDarkMode = () => {
|
||||||
const isDark = document.documentElement.classList.contains('dark')
|
const isDark = document.documentElement.classList.contains('dark')
|
||||||
@@ -55,7 +128,6 @@ export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
|
|||||||
|
|
||||||
checkDarkMode()
|
checkDarkMode()
|
||||||
|
|
||||||
// Observer for theme changes
|
|
||||||
const observer = new MutationObserver(checkDarkMode)
|
const observer = new MutationObserver(checkDarkMode)
|
||||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
||||||
|
|
||||||
@@ -64,10 +136,9 @@ export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
|
|||||||
|
|
||||||
const handleChange = (
|
const handleChange = (
|
||||||
excalidrawElements: readonly ExcalidrawElement[],
|
excalidrawElements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
_appState: AppState,
|
||||||
files: BinaryFiles
|
files: BinaryFiles
|
||||||
) => {
|
) => {
|
||||||
// Keep files ref up to date so we can include them in the save payload
|
|
||||||
if (files) filesRef.current = files
|
if (files) filesRef.current = files
|
||||||
|
|
||||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
|
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
|
||||||
@@ -75,7 +146,6 @@ export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
|
|||||||
setSaveStatus('saving')
|
setSaveStatus('saving')
|
||||||
saveTimeoutRef.current = setTimeout(async () => {
|
saveTimeoutRef.current = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
// Save both elements and binary files so images persist across page changes
|
|
||||||
const snapshot = JSON.stringify({ elements: excalidrawElements, files: filesRef.current })
|
const snapshot = JSON.stringify({ elements: excalidrawElements, files: filesRef.current })
|
||||||
await fetch('/api/canvas', {
|
await fetch('/api/canvas', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -84,26 +154,35 @@ export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
|
|||||||
})
|
})
|
||||||
setSaveStatus('saved')
|
setSaveStatus('saved')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[CanvasBoard] Save failure:", e)
|
console.error('[CanvasBoard] Save failure:', e)
|
||||||
setSaveStatus('error')
|
setSaveStatus('error')
|
||||||
}
|
}
|
||||||
}, 2000)
|
}, 2000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (scene.pptx) {
|
||||||
|
return <PptxViewer data={scene.pptx} name={name} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const excalKey = canvasId ? `excal-${canvasId}` : 'excal-new'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 h-full w-full bg-slate-50 dark:bg-[#121212]" dir="ltr">
|
<div className="absolute inset-0 h-full w-full bg-slate-50 dark:bg-[#121212]" dir="ltr">
|
||||||
<Excalidraw
|
<Excalidraw
|
||||||
initialData={{ elements, files: filesRef.current }}
|
key={excalKey}
|
||||||
theme={isDarkMode ? "dark" : "light"}
|
excalidrawAPI={(api) => { excalidrawAPIRef.current = api }}
|
||||||
|
initialData={{ elements: scene.elements, files: scene.files }}
|
||||||
|
theme={isDarkMode ? 'dark' : 'light'}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
libraryReturnUrl={typeof window !== 'undefined' ? window.location.origin + window.location.pathname + window.location.search : undefined}
|
|
||||||
UIOptions={{
|
UIOptions={{
|
||||||
canvasActions: {
|
canvasActions: {
|
||||||
loadScene: false,
|
loadScene: true,
|
||||||
saveToActiveFile: false,
|
saveToActiveFile: false,
|
||||||
clearCanvas: true
|
clearCanvas: true,
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
validateEmbeddable={false}
|
||||||
|
renderTopRightUI={() => null}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -185,7 +185,16 @@ export function MasonryGrid({
|
|||||||
|
|
||||||
if (notes !== prevNotesRef.current) {
|
if (notes !== prevNotesRef.current) {
|
||||||
const localSizeMap = new Map(localNotes.map(n => [n.id, n.size]));
|
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 }));
|
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);
|
setLocalNotes(newLocalNotes);
|
||||||
prevNotesRef.current = notes;
|
prevNotesRef.current = notes;
|
||||||
}
|
}
|
||||||
@@ -239,16 +248,20 @@ export function MasonryGrid({
|
|||||||
|
|
||||||
if (!over || active.id === over.id) return;
|
if (!over || active.id === over.id) return;
|
||||||
|
|
||||||
const reordered = arrayMove(
|
const current = localNotesRef.current
|
||||||
localNotesRef.current,
|
const activeIdx = current.findIndex(n => n.id === active.id)
|
||||||
localNotesRef.current.findIndex(n => n.id === active.id),
|
const overIdx = current.findIndex(n => n.id === over.id)
|
||||||
localNotesRef.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;
|
if (reordered.length === 0) return;
|
||||||
|
|
||||||
setLocalNotes(reordered);
|
setLocalNotes(reordered);
|
||||||
// Persist order outside of setState to avoid "setState in render" warning
|
|
||||||
const ids = reordered.map(n => n.id);
|
const ids = reordered.map(n => n.id);
|
||||||
updateFullOrderWithoutRevalidation(ids).catch(err => {
|
updateFullOrderWithoutRevalidation(ids).catch(err => {
|
||||||
console.error('Failed to persist order:', err);
|
console.error('Failed to persist order:', err);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useTransition } from 'react'
|
import { useState, useTransition } from 'react'
|
||||||
import { Card } from '@/components/ui/card'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import {
|
import {
|
||||||
@@ -117,59 +116,71 @@ export function McpSettingsPanel({ initialKeys, serverStatus }: McpSettingsPanel
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="columns-1 lg:columns-2 gap-6 space-y-6">
|
||||||
{/* Section 1: What is MCP */}
|
{/* Section 1: What is MCP */}
|
||||||
<Card className="p-6">
|
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden break-inside-avoid">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-center gap-3 p-6 border-b border-border">
|
||||||
<Info className="h-5 w-5 text-blue-500 mt-0.5 shrink-0" />
|
<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>
|
<div>
|
||||||
<h2 className="text-lg font-semibold">{t('mcpSettings.whatIsMcp.title')}</h2>
|
<h2 className="font-semibold text-foreground">{t('mcpSettings.whatIsMcp.title')}</h2>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-6">
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
{t('mcpSettings.whatIsMcp.description')}
|
{t('mcpSettings.whatIsMcp.description')}
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href="https://modelcontextprotocol.io"
|
href="https://modelcontextprotocol.io"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline mt-2"
|
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:underline mt-4"
|
||||||
>
|
>
|
||||||
{t('mcpSettings.whatIsMcp.learnMore')}
|
{t('mcpSettings.whatIsMcp.learnMore')}
|
||||||
<ExternalLink className="h-3 w-3" />
|
<ExternalLink className="h-3 w-3" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Section 2: Server Status */}
|
{/* Section 2: Server Status */}
|
||||||
<Card className="p-6">
|
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden break-inside-avoid">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 p-6 border-b border-border">
|
||||||
<Server 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">
|
||||||
<h2 className="text-lg font-semibold">{t('mcpSettings.serverStatus.title')}</h2>
|
<Server className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 text-sm">
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<h2 className="font-semibold text-foreground">{t('mcpSettings.serverStatus.title')}</h2>
|
||||||
<span className="text-gray-500">{t('mcpSettings.serverStatus.mode')}:</span>
|
</div>
|
||||||
|
</div>
|
||||||
|
<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>
|
<Badge variant="secondary">{serverStatus.mode.toUpperCase()}</Badge>
|
||||||
</div>
|
</div>
|
||||||
{serverStatus.mode === 'sse' && serverStatus.url && (
|
{serverStatus.mode === 'sse' && serverStatus.url && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="space-y-1.5">
|
||||||
<span className="text-gray-500">{t('mcpSettings.serverStatus.url')}:</span>
|
<span className="text-muted-foreground block">{t('mcpSettings.serverStatus.url')}</span>
|
||||||
<code className="text-xs bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">
|
<code className="text-xs bg-muted p-2 rounded block break-all font-mono">
|
||||||
{serverStatus.url}
|
{serverStatus.url}
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Section 3: API Keys */}
|
{/* Section 3: API Keys */}
|
||||||
<Card className="p-6">
|
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden break-inside-avoid">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||||
<div className="flex items-center gap-3">
|
<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>
|
<div>
|
||||||
<h2 className="text-lg font-semibold">{t('mcpSettings.apiKeys.title')}</h2>
|
<h2 className="font-semibold text-foreground">{t('mcpSettings.apiKeys.title')}</h2>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t('mcpSettings.apiKeys.description')}
|
{t('mcpSettings.apiKeys.description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,8 +199,9 @@ export function McpSettingsPanel({ initialKeys, serverStatus }: McpSettingsPanel
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
{keys.length === 0 ? (
|
{keys.length === 0 ? (
|
||||||
<div className="text-center py-8 text-gray-500">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
<Key className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
<Key className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||||
<p>{t('mcpSettings.apiKeys.empty')}</p>
|
<p>{t('mcpSettings.apiKeys.empty')}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -206,10 +218,13 @@ export function McpSettingsPanel({ initialKeys, serverStatus }: McpSettingsPanel
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Section 4: Configuration Instructions */}
|
{/* Section 4: Configuration Instructions */}
|
||||||
|
<div className="break-inside-avoid">
|
||||||
<ConfigInstructions serverStatus={serverStatus} />
|
<ConfigInstructions serverStatus={serverStatus} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Raw Key Display Dialog */}
|
{/* Raw Key Display Dialog */}
|
||||||
<Dialog open={!!showRawKey} onOpenChange={(open) => { if (!open) setShowRawKey(null) }}>
|
<Dialog open={!!showRawKey} onOpenChange={(open) => { if (!open) setShowRawKey(null) }}>
|
||||||
@@ -222,9 +237,9 @@ export function McpSettingsPanel({ initialKeys, serverStatus }: McpSettingsPanel
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<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">
|
<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}
|
{showRawKey}
|
||||||
</code>
|
</code>
|
||||||
<Button
|
<Button
|
||||||
@@ -330,7 +345,7 @@ function KeyCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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="space-y-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium text-sm">{keyInfo.name}</span>
|
<span className="font-medium text-sm">{keyInfo.name}</span>
|
||||||
@@ -340,7 +355,7 @@ function KeyCard({
|
|||||||
: t('mcpSettings.apiKeys.revoked')}
|
: t('mcpSettings.apiKeys.revoked')}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4 text-xs text-gray-500">
|
<div className="flex gap-4 text-xs text-muted-foreground">
|
||||||
<span>
|
<span>
|
||||||
{t('mcpSettings.apiKeys.createdAt')}: {formatDate(keyInfo.createdAt)}
|
{t('mcpSettings.apiKeys.createdAt')}: {formatDate(keyInfo.createdAt)}
|
||||||
</span>
|
</span>
|
||||||
@@ -436,23 +451,25 @@ Transport: Streamable HTTP`,
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="p-6">
|
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 p-6 border-b border-border">
|
||||||
<ExternalLink 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">
|
||||||
|
<ExternalLink className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold">
|
<h2 className="font-semibold text-foreground">
|
||||||
{t('mcpSettings.configInstructions.title')}
|
{t('mcpSettings.configInstructions.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t('mcpSettings.configInstructions.description')}
|
{t('mcpSettings.configInstructions.description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="p-6 space-y-2">
|
||||||
{configs.map(cfg => (
|
{configs.map(cfg => (
|
||||||
<div key={cfg.id} className="border rounded-lg overflow-hidden">
|
<div key={cfg.id} className="border rounded-lg overflow-hidden">
|
||||||
<button
|
<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)}
|
onClick={() => setExpanded(expanded === cfg.id ? null : cfg.id)}
|
||||||
>
|
>
|
||||||
<span className="font-medium text-sm">{cfg.title}</span>
|
<span className="font-medium text-sm">{cfg.title}</span>
|
||||||
@@ -464,8 +481,8 @@ Transport: Streamable HTTP`,
|
|||||||
</button>
|
</button>
|
||||||
{expanded === cfg.id && (
|
{expanded === cfg.id && (
|
||||||
<div className="px-4 pb-4">
|
<div className="px-4 pb-4">
|
||||||
<p className="text-sm text-gray-500 mb-2">{cfg.description}</p>
|
<p className="text-sm text-muted-foreground mb-2">{cfg.description}</p>
|
||||||
<pre className="text-xs bg-gray-100 dark:bg-gray-800 p-3 rounded overflow-x-auto">
|
<pre className="text-xs bg-muted p-3 rounded overflow-x-auto">
|
||||||
<code>{cfg.snippet}</code>
|
<code>{cfg.snippet}</code>
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -473,6 +490,6 @@ Transport: Streamable HTTP`,
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog'
|
} 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 { 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 { useSession } from 'next-auth/react'
|
||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
import { deleteNote, toggleArchive, togglePin, updateColor, updateNote, updateSize, getNoteAllUsers, leaveSharedNote, removeFusedBadge, createNote, restoreNote, permanentDeleteNote } from '@/app/actions/notes'
|
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 { hi } from 'date-fns/locale/hi'
|
||||||
import { nl } from 'date-fns/locale/nl'
|
import { nl } from 'date-fns/locale/nl'
|
||||||
import { pl } from 'date-fns/locale/pl'
|
import { pl } from 'date-fns/locale/pl'
|
||||||
import { MarkdownContent } from './markdown-content'
|
|
||||||
import { LabelBadge } from './label-badge'
|
import { LabelBadge } from './label-badge'
|
||||||
import { NoteImages } from './note-images'
|
import { NoteImages } from './note-images'
|
||||||
import { NoteChecklist } from './note-checklist'
|
import { NoteChecklist } from './note-checklist'
|
||||||
import { NoteActions } from './note-actions'
|
import { NoteActions } from './note-actions'
|
||||||
import { CollaboratorDialog } from './collaborator-dialog'
|
|
||||||
import { CollaboratorAvatars } from './collaborator-avatars'
|
import { CollaboratorAvatars } from './collaborator-avatars'
|
||||||
import { ConnectionsBadge } from './connections-badge'
|
import { ConnectionsBadge } from './connections-badge'
|
||||||
import { ConnectionsOverlay } from './connections-overlay'
|
|
||||||
import { ComparisonModal } from './comparison-modal'
|
const MarkdownContent = dynamic(() => import('./markdown-content').then(m => ({ default: m.MarkdownContent })), {
|
||||||
import { FusionModal } from './fusion-modal'
|
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 { useConnectionsCompare } from '@/hooks/use-connections-compare'
|
||||||
import { useLabels } from '@/context/LabelContext'
|
import { useLabels } from '@/context/LabelContext'
|
||||||
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
||||||
@@ -186,6 +190,14 @@ export const NoteCard = memo(function NoteCard({
|
|||||||
const [showNotebookMenu, setShowNotebookMenu] = useState(false)
|
const [showNotebookMenu, setShowNotebookMenu] = useState(false)
|
||||||
const [reminderDate, setReminderDate] = useState<Date | null>(note.reminder ? new Date(note.reminder) : null)
|
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) => {
|
const handleUpdateReminder = async (noteId: string, reminder: Date | null) => {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -257,6 +269,8 @@ export const NoteCard = memo(function NoteCard({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isSharedNote) return
|
||||||
|
|
||||||
let isMounted = true
|
let isMounted = true
|
||||||
|
|
||||||
const loadCollaborators = async () => {
|
const loadCollaborators = async () => {
|
||||||
@@ -270,7 +284,6 @@ export const NoteCard = memo(function NoteCard({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load collaborators:', error)
|
|
||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
setCollaborators([])
|
setCollaborators([])
|
||||||
}
|
}
|
||||||
@@ -633,7 +646,7 @@ export const NoteCard = memo(function NoteCard({
|
|||||||
onToggleItem={handleCheckItem}
|
onToggleItem={handleCheckItem}
|
||||||
/>
|
/>
|
||||||
) : note.type === 'richtext' ? (
|
) : 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">
|
<div className="text-sm text-foreground line-clamp-10">
|
||||||
<MarkdownContent
|
<MarkdownContent
|
||||||
|
|||||||
@@ -646,7 +646,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
|||||||
<Dialog open={true} onOpenChange={onClose}>
|
<Dialog open={true} onOpenChange={onClose}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className={cn(
|
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
|
colorClasses.bg
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -875,18 +875,18 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
|||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<>
|
<>
|
||||||
{/* Reminder */}
|
{/* 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')}>
|
onClick={() => setShowReminderDialog(true)} title={t('notes.setReminder')}>
|
||||||
<Bell className="h-4 w-4" />
|
<Bell className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
{/* Add Image */}
|
{/* 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')}>
|
onClick={() => fileInputRef.current?.click()} title={t('notes.addImage')}>
|
||||||
<ImageIcon className="h-4 w-4" />
|
<ImageIcon className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Add Link */}
|
{/* 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')}>
|
onClick={() => setShowLinkDialog(true)} title={t('notes.addLink')}>
|
||||||
<LinkIcon className="h-4 w-4" />
|
<LinkIcon className="h-4 w-4" />
|
||||||
</Button>
|
</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) }} />
|
<NoteTypeSelector value={noteType} onChange={(newType) => { setNoteType(newType); if (newType !== 'markdown') setShowMarkdownPreview(false) }} />
|
||||||
|
|
||||||
{noteType === 'markdown' && (
|
{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)}
|
onClick={() => setShowMarkdownPreview(!showMarkdownPreview)}
|
||||||
title={showMarkdownPreview ? t('general.edit') : t('notes.preview')}>
|
title={showMarkdownPreview ? t('general.edit') : t('notes.preview')}>
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
@@ -904,7 +904,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
|||||||
{/* AI Copilot */}
|
{/* AI Copilot */}
|
||||||
{noteType !== 'checklist' && aiAssistantEnabled && (
|
{noteType !== 'checklist' && aiAssistantEnabled && (
|
||||||
<Button variant="ghost" size="sm"
|
<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">
|
onClick={() => setAiOpen(!aiOpen)} title="IA Note">
|
||||||
<Sparkles className="h-3.5 w-3.5" />
|
<Sparkles className="h-3.5 w-3.5" />
|
||||||
<span className="hidden sm:inline">IA Note</span>
|
<span className="hidden sm:inline">IA Note</span>
|
||||||
@@ -914,7 +914,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
|||||||
{/* Size Selector */}
|
{/* Size Selector */}
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<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" />
|
<Maximize2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
@@ -934,7 +934,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
|||||||
{/* Color Picker */}
|
{/* Color Picker */}
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<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" />
|
<Palette className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
@@ -1023,6 +1023,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
|||||||
noteTitle={title}
|
noteTitle={title}
|
||||||
noteContent={content}
|
noteContent={content}
|
||||||
noteImages={allImages}
|
noteImages={allImages}
|
||||||
|
noteId={note.id}
|
||||||
onApplyToNote={(newContent) => {
|
onApplyToNote={(newContent) => {
|
||||||
setPreviousContentForCopilot(content)
|
setPreviousContentForCopilot(content)
|
||||||
setContent(newContent)
|
setContent(newContent)
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ function VersionPreview({ entry, language }: { entry: NoteHistoryEntry; language
|
|||||||
<MarkdownContent content={entry.content || ''} />
|
<MarkdownContent content={entry.content || ''} />
|
||||||
</div>
|
</div>
|
||||||
) : isRt ? (
|
) : 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>
|
<pre className="whitespace-pre-wrap font-sans">{entry.content || ''}</pre>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export function NoteImages({ images: rawImages, title }: NoteImagesProps) {
|
|||||||
<img
|
<img
|
||||||
src={images[0]}
|
src={images[0]}
|
||||||
alt=""
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
className="w-full h-auto rounded-lg"
|
className="w-full h-auto rounded-lg"
|
||||||
/>
|
/>
|
||||||
) : images.length === 2 ? (
|
) : images.length === 2 ? (
|
||||||
@@ -27,6 +28,7 @@ export function NoteImages({ images: rawImages, title }: NoteImagesProps) {
|
|||||||
key={idx}
|
key={idx}
|
||||||
src={img}
|
src={img}
|
||||||
alt=""
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
className="w-full h-auto rounded-lg"
|
className="w-full h-auto rounded-lg"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -36,6 +38,7 @@ export function NoteImages({ images: rawImages, title }: NoteImagesProps) {
|
|||||||
<img
|
<img
|
||||||
src={images[0]}
|
src={images[0]}
|
||||||
alt=""
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
className="col-span-2 w-full h-auto rounded-lg"
|
className="col-span-2 w-full h-auto rounded-lg"
|
||||||
/>
|
/>
|
||||||
{images.slice(1).map((img, idx) => (
|
{images.slice(1).map((img, idx) => (
|
||||||
@@ -43,6 +46,7 @@ export function NoteImages({ images: rawImages, title }: NoteImagesProps) {
|
|||||||
key={idx}
|
key={idx}
|
||||||
src={img}
|
src={img}
|
||||||
alt=""
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
className="w-full h-auto rounded-lg"
|
className="w-full h-auto rounded-lg"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -54,6 +58,7 @@ export function NoteImages({ images: rawImages, title }: NoteImagesProps) {
|
|||||||
key={idx}
|
key={idx}
|
||||||
src={img}
|
src={img}
|
||||||
alt=""
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
className="w-full h-auto rounded-lg"
|
className="w-full h-auto rounded-lg"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -919,6 +919,7 @@ export function NoteInlineEditor({
|
|||||||
noteTitle={title}
|
noteTitle={title}
|
||||||
noteContent={content}
|
noteContent={content}
|
||||||
noteImages={allImages}
|
noteImages={allImages}
|
||||||
|
noteId={note.id}
|
||||||
onApplyToNote={(newContent) => {
|
onApplyToNote={(newContent) => {
|
||||||
setPreviousContent(content)
|
setPreviousContent(content)
|
||||||
changeContent(newContent)
|
changeContent(newContent)
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useCallback } from 'react'
|
import { useState, useCallback, useRef } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||||
import { cn } from '@/lib/utils'
|
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 { useNotebooks } from '@/context/notebooks-context'
|
||||||
import { useNotebookDrag } from '@/context/notebook-drag-context'
|
import { useNotebookDrag } from '@/context/notebook-drag-context'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -19,12 +20,22 @@ import { LabelManagementDialog } from '@/components/label-management-dialog'
|
|||||||
import { Notebook } from '@/lib/types'
|
import { Notebook } from '@/lib/types'
|
||||||
import { getNotebookIcon } from '@/lib/notebook-icon'
|
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() {
|
export function NotebooksList() {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { t, language } = useLanguage()
|
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 { draggedNoteId, dragOverNotebookId, dragOver } = useNotebookDrag()
|
||||||
const { labels } = useLabels()
|
const { labels } = useLabels()
|
||||||
|
|
||||||
@@ -35,29 +46,77 @@ export function NotebooksList() {
|
|||||||
const [expandedNotebook, setExpandedNotebook] = useState<string | null>(null)
|
const [expandedNotebook, setExpandedNotebook] = useState<string | null>(null)
|
||||||
const [labelsDialogOpen, setLabelsDialogOpen] = useState(false)
|
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')
|
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) => {
|
const handleDrop = useCallback(async (e: React.DragEvent, notebookId: string | null) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
||||||
|
const sourceNbId = e.dataTransfer.getData('application/x-notebook')
|
||||||
const noteId = e.dataTransfer.getData('text/plain')
|
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)
|
await moveNoteToNotebookOptimistic(noteId, notebookId)
|
||||||
}
|
}
|
||||||
|
|
||||||
dragOver(null)
|
dragOver(null)
|
||||||
}, [moveNoteToNotebookOptimistic, dragOver])
|
draggingNbRef.current = null
|
||||||
|
setDraggingNbId(null)
|
||||||
|
setOverNbId(null)
|
||||||
|
}, [notebooks, moveNoteToNotebookOptimistic, updateNotebookOrderOptimistic, dragOver])
|
||||||
|
|
||||||
// Handle drag over a notebook
|
// Handle drag over a notebook
|
||||||
const handleDragOver = useCallback((e: React.DragEvent, notebookId: string | null) => {
|
const handleDragOver = useCallback((e: React.DragEvent, notebookId: string | null) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
if (draggingNbRef.current) {
|
||||||
|
// Notebook reorder mode — just track the insertion target
|
||||||
|
setOverNbId(notebookId)
|
||||||
|
} else {
|
||||||
|
// Note-to-notebook mode
|
||||||
dragOver(notebookId)
|
dragOver(notebookId)
|
||||||
|
}
|
||||||
}, [dragOver])
|
}, [dragOver])
|
||||||
|
|
||||||
// Handle drag leave
|
// Handle drag leave
|
||||||
const handleDragLeave = useCallback(() => {
|
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(null)
|
||||||
}, [dragOver])
|
}, [dragOver])
|
||||||
|
|
||||||
@@ -131,7 +190,20 @@ export function NotebooksList() {
|
|||||||
const NotebookIcon = getNotebookIcon(notebook.icon || 'folder')
|
const NotebookIcon = getNotebookIcon(notebook.icon || 'folder')
|
||||||
|
|
||||||
return (
|
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 ? (
|
{isActive ? (
|
||||||
// Active notebook with expanded labels
|
// Active notebook with expanded labels
|
||||||
<div
|
<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")}
|
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}
|
style={notebook.color ? { color: notebook.color } : undefined}
|
||||||
/>
|
/>
|
||||||
|
<NotebookName name={notebook.name}>
|
||||||
<span
|
<span
|
||||||
className={cn("text-[15px] font-medium tracking-wide truncate min-w-0", !notebook.color && "text-primary dark:text-primary-foreground")}
|
className={cn("text-[15px] font-medium tracking-wide", !notebook.color && "text-primary dark:text-primary-foreground")}
|
||||||
style={notebook.color ? { color: notebook.color } : undefined}
|
style={notebook.color ? { color: notebook.color } : undefined}
|
||||||
>
|
>
|
||||||
{notebook.name}
|
{notebook.name}
|
||||||
</span>
|
</span>
|
||||||
|
</NotebookName>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
{/* Actions menu for active notebook */}
|
{/* 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"
|
isDragOver && "ring-2 ring-blue-500 ring-dashed rounded-e-full me-2"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<TooltipProvider delayDuration={600}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSelectNotebook(notebook.id)}
|
onClick={() => handleSelectNotebook(notebook.id)}
|
||||||
className={cn(
|
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",
|
"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"
|
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" />
|
<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>
|
<span className="text-[15px] font-medium tracking-wide text-start truncate min-w-0 flex-1">
|
||||||
|
{notebook.name}
|
||||||
|
</span>
|
||||||
{(notebook as any).notesCount > 0 && (
|
{(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>
|
<span className="text-xs text-gray-400 ms-auto flex-shrink-0">({new Intl.NumberFormat(language).format((notebook as any).notesCount)})</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</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 */}
|
{/* 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">
|
<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">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from 'react'
|
|||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
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 {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
@@ -196,10 +196,18 @@ export function NotificationPanel() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="max-h-96 overflow-y-auto">
|
<div className="max-h-96 overflow-y-auto">
|
||||||
{/* App notifications (agents, system) */}
|
{/* 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
|
<div
|
||||||
key={notif.id}
|
key={notif.id}
|
||||||
className="p-3 border-b last:border-0 hover:bg-accent/50 transition-colors duration-150 cursor-pointer"
|
className="p-3 border-b last:border-0 hover:bg-accent/50 transition-colors duration-150"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-start gap-3 cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (notif.actionUrl) {
|
if (notif.actionUrl) {
|
||||||
handleMarkNotifRead(notif.id)
|
handleMarkNotifRead(notif.id)
|
||||||
@@ -208,14 +216,19 @@ export function NotificationPanel() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"mt-0.5 flex-none rounded-full p-1",
|
"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_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 === '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 === '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" />
|
<Bot className="w-3.5 h-3.5" />
|
||||||
) : (
|
) : (
|
||||||
<AlertCircle 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(
|
<span className={cn(
|
||||||
"text-[10px] font-semibold uppercase tracking-wider",
|
"text-[10px] font-semibold uppercase tracking-wider",
|
||||||
notif.type === 'agent_success' && 'text-green-600 dark:text-green-400',
|
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 === 'agent_failure' && 'text-red-600 dark:text-red-400',
|
||||||
notif.type === 'system' && 'text-blue-600 dark:text-blue-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_success' && (t('notification.agentSuccess') || 'Agent completed')}
|
||||||
{notif.type === 'agent_failure' && (t('notification.agentFailed') || 'Agent failed')}
|
{notif.type === 'agent_failure' && (t('notification.agentFailed') || 'Agent failed')}
|
||||||
{notif.type === 'system' && 'System'}
|
{notif.type === 'system' && 'System'}
|
||||||
@@ -251,8 +268,23 @@ export function NotificationPanel() {
|
|||||||
<X className="w-3.5 h-3.5" />
|
<X className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
))}
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
{/* Overdue reminders */}
|
{/* Overdue reminders */}
|
||||||
{overdueReminders.map((note) => (
|
{overdueReminders.map((note) => (
|
||||||
|
|||||||
@@ -201,11 +201,13 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
|||||||
<BubbleMenu
|
<BubbleMenu
|
||||||
editor={editor}
|
editor={editor}
|
||||||
className="notion-bubble-menu"
|
className="notion-bubble-menu"
|
||||||
tippyOptions={{
|
{...({
|
||||||
|
tippyOptions: {
|
||||||
appendTo: () => document.body,
|
appendTo: () => document.body,
|
||||||
zIndex: 99999,
|
zIndex: 99999,
|
||||||
fallbackPlacements: ['bottom', 'top']
|
fallbackPlacements: ['bottom', 'top']
|
||||||
}}
|
}
|
||||||
|
} as any)}
|
||||||
shouldShow={({ editor: e, state }: { editor: Editor; state: EditorState }) => {
|
shouldShow={({ editor: e, state }: { editor: Editor; state: EditorState }) => {
|
||||||
const { from, to } = state.selection
|
const { from, to } = state.selection
|
||||||
const isImage = e.isActive('image')
|
const isImage = e.isActive('image')
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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'
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { usePathname } from 'next/navigation'
|
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 { cn } from '@/lib/utils'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
@@ -22,74 +22,32 @@ export function SettingsNav({ className }: SettingsNavProps) {
|
|||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
|
|
||||||
const sections: SettingsSection[] = [
|
const sections: SettingsSection[] = [
|
||||||
{
|
{ id: 'general', label: t('generalSettings.title'), icon: <Settings className="h-4 w-4" />, href: '/settings/general' },
|
||||||
id: 'general',
|
{ id: 'ai', label: t('aiSettings.title'), icon: <Sparkles className="h-4 w-4" />, href: '/settings/ai' },
|
||||||
label: t('generalSettings.title'),
|
{ id: 'appearance', label: t('appearance.title'), icon: <Palette className="h-4 w-4" />, href: '/settings/appearance' },
|
||||||
icon: <Settings className="h-5 w-5" />,
|
{ id: 'profile', label: t('profile.title'), icon: <User className="h-4 w-4" />, href: '/settings/profile' },
|
||||||
href: '/settings/general'
|
{ 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' },
|
||||||
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 + '/')
|
const isActive = (href: string) => pathname === href || pathname.startsWith(href + '/')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className={cn('space-y-1', className)}>
|
<nav className={cn('flex items-center gap-1', className)}>
|
||||||
{sections.map((section) => (
|
{sections.map((section) => (
|
||||||
<Link
|
<Link
|
||||||
key={section.id}
|
key={section.id}
|
||||||
href={section.href}
|
href={section.href}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-3 px-4 py-3 rounded-lg transition-colors',
|
'flex items-center gap-2 px-3 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap',
|
||||||
'hover:bg-gray-100 dark:hover:bg-gray-800',
|
|
||||||
isActive(section.href)
|
isActive(section.href)
|
||||||
? 'bg-gray-100 dark:bg-gray-800 text-primary'
|
? 'border-primary text-primary'
|
||||||
: 'text-gray-700 dark:text-gray-300'
|
: '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}
|
{section.icon}
|
||||||
<span className="font-medium">{section.label}</span>
|
<span>{section.label}</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export function Sidebar({ className, user }: { className?: string, user?: any })
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (HIDDEN_ROUTES.some(r => pathname.startsWith(r))) return
|
if (HIDDEN_ROUTES.some(r => pathname.startsWith(r))) return
|
||||||
getTrashCount().then(setTrashCount)
|
getTrashCount().then(setTrashCount)
|
||||||
}, [pathname, searchKey, refreshKey])
|
}, [pathname, refreshKey])
|
||||||
|
|
||||||
// Hide sidebar on Agents, Chat IA and Lab routes
|
// Hide sidebar on Agents, Chat IA and Lab routes
|
||||||
if (HIDDEN_ROUTES.some(r => pathname.startsWith(r))) return null
|
if (HIDDEN_ROUTES.some(r => pathname.startsWith(r))) return null
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Slot } from "radix-ui"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
@@ -21,14 +21,14 @@ const buttonVariants = cva(
|
|||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
default: "min-h-9 h-auto 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",
|
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: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
sm: "min-h-8 h-auto gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
lg: "min-h-10 h-auto rounded-md px-6 has-[>svg]:px-4",
|
||||||
icon: "size-9",
|
icon: "size-9 shrink-0",
|
||||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
"icon-xs": "size-6 rounded-md shrink-0 [&_svg:not([class*='size-'])]:size-3",
|
||||||
"icon-sm": "size-8",
|
"icon-sm": "size-8 shrink-0",
|
||||||
"icon-lg": "size-10",
|
"icon-lg": "size-10 shrink-0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export const toast = sonnerToast
|
|||||||
export function Toaster() {
|
export function Toaster() {
|
||||||
return (
|
return (
|
||||||
<SonnerToaster
|
<SonnerToaster
|
||||||
position="top-right"
|
position="bottom-right"
|
||||||
expand={false}
|
expand={false}
|
||||||
richColors
|
richColors
|
||||||
closeButton
|
closeButton
|
||||||
|
|||||||
@@ -5,18 +5,25 @@ import { createContext, useContext, useState, useCallback, useMemo } from 'react
|
|||||||
interface NoteRefreshContextType {
|
interface NoteRefreshContextType {
|
||||||
refreshKey: number
|
refreshKey: number
|
||||||
triggerRefresh: () => void
|
triggerRefresh: () => void
|
||||||
|
notebooksRefreshKey: number
|
||||||
|
triggerNotebooksRefresh: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const NoteRefreshContext = createContext<NoteRefreshContextType | undefined>(undefined)
|
const NoteRefreshContext = createContext<NoteRefreshContextType | undefined>(undefined)
|
||||||
|
|
||||||
export function NoteRefreshProvider({ children }: { children: React.ReactNode }) {
|
export function NoteRefreshProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [refreshKey, setRefreshKey] = useState(0)
|
const [refreshKey, setRefreshKey] = useState(0)
|
||||||
|
const [notebooksRefreshKey, setNotebooksRefreshKey] = useState(0)
|
||||||
|
|
||||||
const triggerRefresh = useCallback(() => {
|
const triggerRefresh = useCallback(() => {
|
||||||
setRefreshKey(prev => prev + 1)
|
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 (
|
return (
|
||||||
<NoteRefreshContext.Provider value={value}>
|
<NoteRefreshContext.Provider value={value}>
|
||||||
@@ -33,11 +40,7 @@ export function useNoteRefresh() {
|
|||||||
return context
|
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 {
|
export function useNoteRefreshOptional(): NoteRefreshContextType {
|
||||||
const context = useContext(NoteRefreshContext)
|
const context = useContext(NoteRefreshContext)
|
||||||
return context ?? { refreshKey: 0, triggerRefresh: () => {} }
|
return context ?? { refreshKey: 0, triggerRefresh: () => {}, notebooksRefreshKey: 0, triggerNotebooksRefresh: () => {} }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
|||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [isMovingNote, setIsMovingNote] = useState(false)
|
const [isMovingNote, setIsMovingNote] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const { triggerRefresh, refreshKey } = useNoteRefresh()
|
const { triggerRefresh, triggerNotebooksRefresh, notebooksRefreshKey } = useNoteRefresh()
|
||||||
|
|
||||||
// ===== DERIVED STATE =====
|
// ===== DERIVED STATE =====
|
||||||
const currentLabels = useMemo(() => {
|
const currentLabels = useMemo(() => {
|
||||||
@@ -115,10 +115,9 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
|||||||
loadNotebooks()
|
loadNotebooks()
|
||||||
}, [loadNotebooks])
|
}, [loadNotebooks])
|
||||||
|
|
||||||
// Recharge les carnets à chaque fois qu'une note est modifiée/supprimée
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (refreshKey > 0) loadNotebooks()
|
if (notebooksRefreshKey > 0) loadNotebooks()
|
||||||
}, [refreshKey, loadNotebooks])
|
}, [notebooksRefreshKey, loadNotebooks])
|
||||||
|
|
||||||
// ===== ACTIONS: NOTEBOOKS =====
|
// ===== ACTIONS: NOTEBOOKS =====
|
||||||
const createNotebookOptimistic = useCallback(async (data: CreateNotebookInput) => {
|
const createNotebookOptimistic = useCallback(async (data: CreateNotebookInput) => {
|
||||||
@@ -134,8 +133,9 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
|||||||
|
|
||||||
// Reload notebooks from server to update sidebar state
|
// Reload notebooks from server to update sidebar state
|
||||||
await loadNotebooks()
|
await loadNotebooks()
|
||||||
|
triggerNotebooksRefresh()
|
||||||
triggerRefresh()
|
triggerRefresh()
|
||||||
}, [loadNotebooks, triggerRefresh])
|
}, [loadNotebooks, triggerNotebooksRefresh, triggerRefresh])
|
||||||
|
|
||||||
const updateNotebook = useCallback(async (notebookId: string, data: UpdateNotebookInput) => {
|
const updateNotebook = useCallback(async (notebookId: string, data: UpdateNotebookInput) => {
|
||||||
const response = await fetch(`/api/notebooks/${notebookId}`, {
|
const response = await fetch(`/api/notebooks/${notebookId}`, {
|
||||||
@@ -149,8 +149,9 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
|||||||
}
|
}
|
||||||
|
|
||||||
await loadNotebooks()
|
await loadNotebooks()
|
||||||
|
triggerNotebooksRefresh()
|
||||||
triggerRefresh()
|
triggerRefresh()
|
||||||
}, [loadNotebooks, triggerRefresh])
|
}, [loadNotebooks, triggerNotebooksRefresh, triggerRefresh])
|
||||||
|
|
||||||
const deleteNotebook = useCallback(async (notebookId: string) => {
|
const deleteNotebook = useCallback(async (notebookId: string) => {
|
||||||
const response = await fetch(`/api/notebooks/${notebookId}`, {
|
const response = await fetch(`/api/notebooks/${notebookId}`, {
|
||||||
@@ -162,8 +163,9 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
|||||||
}
|
}
|
||||||
|
|
||||||
await loadNotebooks()
|
await loadNotebooks()
|
||||||
|
triggerNotebooksRefresh()
|
||||||
triggerRefresh()
|
triggerRefresh()
|
||||||
}, [loadNotebooks, triggerRefresh])
|
}, [loadNotebooks, triggerNotebooksRefresh, triggerRefresh])
|
||||||
|
|
||||||
const updateNotebookOrderOptimistic = useCallback(async (notebookIds: string[]) => {
|
const updateNotebookOrderOptimistic = useCallback(async (notebookIds: string[]) => {
|
||||||
const response = await fetch('/api/notebooks/reorder', {
|
const response = await fetch('/api/notebooks/reorder', {
|
||||||
@@ -177,8 +179,9 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
|||||||
}
|
}
|
||||||
|
|
||||||
await loadNotebooks()
|
await loadNotebooks()
|
||||||
|
triggerNotebooksRefresh()
|
||||||
triggerRefresh()
|
triggerRefresh()
|
||||||
}, [loadNotebooks, triggerRefresh])
|
}, [loadNotebooks, triggerNotebooksRefresh, triggerRefresh])
|
||||||
|
|
||||||
// ===== ACTIONS: LABELS =====
|
// ===== ACTIONS: LABELS =====
|
||||||
const createLabel = useCallback(async (data: CreateLabelInput) => {
|
const createLabel = useCallback(async (data: CreateLabelInput) => {
|
||||||
@@ -233,6 +236,7 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
|||||||
}
|
}
|
||||||
|
|
||||||
await loadNotebooks()
|
await loadNotebooks()
|
||||||
|
triggerNotebooksRefresh()
|
||||||
triggerRefresh()
|
triggerRefresh()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Failed to move note. Please try again.')
|
toast.error('Failed to move note. Please try again.')
|
||||||
|
|||||||
@@ -26,6 +26,19 @@ export class CustomOpenAIProvider implements AIProvider {
|
|||||||
const headers = new Headers(options?.headers);
|
const headers = new Headers(options?.headers);
|
||||||
headers.set('HTTP-Referer', 'https://localhost:3000');
|
headers.set('HTTP-Referer', 'https://localhost:3000');
|
||||||
headers.set('X-Title', 'Memento AI');
|
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 });
|
return fetch(url, { ...options, headers });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -119,25 +132,40 @@ export class CustomOpenAIProvider implements AIProvider {
|
|||||||
|
|
||||||
async generateWithTools(options: ToolUseOptions): Promise<ToolCallResult> {
|
async generateWithTools(options: ToolUseOptions): Promise<ToolCallResult> {
|
||||||
const { tools, maxSteps = 10, systemPrompt, messages, prompt } = options
|
const { tools, maxSteps = 10, systemPrompt, messages, prompt } = options
|
||||||
const opts: Record<string, any> = {
|
|
||||||
model: this.model,
|
const buildOpts = (steps: number): Record<string, any> => {
|
||||||
tools,
|
const opts: Record<string, any> = { model: this.model, tools, stopWhen: stepCountIs(steps) }
|
||||||
stopWhen: stepCountIs(maxSteps),
|
|
||||||
}
|
|
||||||
if (systemPrompt) opts.system = systemPrompt
|
if (systemPrompt) opts.system = systemPrompt
|
||||||
if (messages) opts.messages = messages
|
if (messages) opts.messages = messages
|
||||||
else if (prompt) opts.prompt = prompt
|
else if (prompt) opts.prompt = prompt
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
const result = await aiGenerateText(opts as any)
|
const toResult = (r: any): ToolCallResult => ({
|
||||||
return {
|
toolCalls: r.toolCalls?.map((tc: any) => ({ toolName: tc.toolName, input: tc.input })) || [],
|
||||||
toolCalls: result.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 })) || [],
|
||||||
toolResults: result.toolResults?.map((tr: any) => ({ toolName: tr.toolName, input: tr.input, output: tr.output })) || [],
|
text: r.text,
|
||||||
text: result.text,
|
steps: r.steps?.map((step: any) => ({
|
||||||
steps: result.steps?.map((step: any) => ({
|
|
||||||
text: step.text,
|
text: step.text,
|
||||||
toolCalls: step.toolCalls?.map((tc: any) => ({ toolName: tc.toolName, input: tc.input })) || [],
|
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
Reference in New Issue
Block a user