Compare commits
25 Commits
4fe31ebc99
...
feat/inter
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afbb0dfc2d | ||
|
|
ebf6f16fde | ||
|
|
e0fd5393ca | ||
|
|
80ccc1f6de | ||
|
|
69c99e4f4f | ||
|
|
f385d43d5d | ||
|
|
324bf40658 | ||
|
|
86505659cf | ||
|
|
4946c0d44a | ||
|
|
0ee2afbd62 | ||
|
|
bff060ad20 | ||
|
|
1987b084c3 | ||
|
|
07ac42cbee | ||
|
|
1e9b1b30c9 | ||
|
|
d5c44aafb6 | ||
|
|
9d10815415 | ||
|
|
cf3b5483d2 | ||
|
|
6eb46fd1b7 | ||
|
|
a7c15e9fc3 | ||
|
|
88a7d2ad0a | ||
|
|
e8ee53c815 | ||
|
|
a77f3ffb6e | ||
|
|
556a0b2f3f | ||
|
|
704fed1191 | ||
|
|
45297da333 |
45
.impeccable/config.json
Normal file
45
.impeccable/config.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"buildPath": "comp",
|
||||
"detector": {
|
||||
"ignoreRules": [],
|
||||
"ignoreFiles": [],
|
||||
"ignoreValues": [
|
||||
{
|
||||
"rule": "ai-color-palette",
|
||||
"value": "*",
|
||||
"files": [
|
||||
"memento-note/components/intelligence-hub.tsx"
|
||||
],
|
||||
"createdAt": "2026-08-26T18:03:40.105Z",
|
||||
"reason": "Palette violette déjà en place sur le widget ponts du dashboard (incumbent), pas une nouvelle teinte IA."
|
||||
},
|
||||
{
|
||||
"rule": "side-tab",
|
||||
"value": "*",
|
||||
"files": [
|
||||
"memento-note/components/note-editor/note-editor-toolbar.tsx"
|
||||
],
|
||||
"createdAt": "2026-08-26T18:14:54.764Z",
|
||||
"reason": "Bordure existante de la toolbar éditeur (incumbent), pas une nouvelle carte AI."
|
||||
},
|
||||
{
|
||||
"rule": "border-accent-on-rounded",
|
||||
"value": "*",
|
||||
"files": [
|
||||
"memento-note/app/(main)/insights/page.tsx"
|
||||
],
|
||||
"createdAt": "2026-08-26T18:23:06.241Z",
|
||||
"reason": "Styles existants de /insights (incumbent), pas une nouvelle carte."
|
||||
},
|
||||
{
|
||||
"rule": "gray-on-color",
|
||||
"value": "*",
|
||||
"files": [
|
||||
"memento-note/app/(main)/insights/page.tsx"
|
||||
],
|
||||
"createdAt": "2026-08-26T18:23:06.270Z",
|
||||
"reason": "Styles existants de /insights (incumbent), pas un nouveau texte gris."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
88
PRODUCT.md
Normal file
88
PRODUCT.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Product
|
||||
|
||||
<!-- impeccable:product-schema 1 -->
|
||||
|
||||
## Platform
|
||||
|
||||
web
|
||||
|
||||
## Users
|
||||
|
||||
Personnes qui capturent des idées, notes et savoirs au fil du temps et ont besoin de les retrouver, les relier et les actionner — pas seulement de les stocker.
|
||||
|
||||
Audiences confirmées (sans hiérarchie exclusive) :
|
||||
- étudiants et chercheurs (cours, papers, synthèses, révision) ;
|
||||
- freelances et professionnels indépendants (projets, briefs, clients) ;
|
||||
- équipes (partage de contexte, agents, Second Brain collectif selon le palier) ;
|
||||
- personnes « lambda » qui veulent un second cerveau accessible, pas un outil expert-only.
|
||||
|
||||
Situation type : trop de notes / dossiers, recherche trop littérale, idées perdues ; le job est de transformer la capture quotidienne en système vivant (liens, briefing, révision, actions).
|
||||
|
||||
## Product Purpose
|
||||
|
||||
**Memento** est une application de prise de notes intelligente positionnée comme **Second Brain** : elle relie les idées dans le temps, fait émerger des connexions cachées, et transforme les carnets en système searchable, briefable et actionnable (matin / routines).
|
||||
|
||||
Succès produit : l’utilisateur écrit dans Memento et le système lui rend de la valeur *pendant* et *après* l’écriture (Memory Echo, insights, dashboard, flashcards, agents) — pas une pile morte de fichiers.
|
||||
|
||||
## Positioning
|
||||
|
||||
Pas « une app de notes de plus ». Mécanisme différenciant : un Second Brain qui **se connecte pendant que vous écrivez** — similarité sémantique réelle, ponts entre notes/carnets, aperçu côte à côte, dashboard Second Brain (briefing, pistes, checklist), agents qui **écrivent dans les carnets**, recherche sémantique, révision espacée — plutôt qu’un chat jetable ou un classement manuel de dossiers.
|
||||
|
||||
Un concurrent « Keep/Notion générique » ne peut pas honnêtement reprendre la promesse *Second Brain vivant qui se connecte tout seul* sans ces mécanismes.
|
||||
|
||||
## Operating Context
|
||||
|
||||
- App web principale : `memento-note/` (Next.js App Router), URL prod `https://memento-note.com`.
|
||||
- Capture & édition : notes riche / Markdown, carnets, tags, structured views (table/kanban), peek split note liée, publication web, pages interactives.
|
||||
- Second Brain au quotidien : dashboard `/home`, Memory Echo dans l’éditeur, `/insights` (clusters sémantiques), `/revision` (flashcards SM-2), agents, rappels, scan Gmail (vols/colis/abonnements).
|
||||
- Référence UX métier / prototype : `architectural-grid/` (et base `architectural-grid1/`) — à consulter avant implémentation UI significative ; la vérité produit d’interface suit le prototype quand il y a conflit d’interprétation.
|
||||
- Extension navigateur (Web Clipper / Side Panel), serveur MCP, intégrations (Calendar/Gmail pattern).
|
||||
- Companion mobile Expo (`memento-mobile/`) existe ; **l’autorité design Impeccable pour ce PRODUCT est le web** (PWA / responsive inclus). Le natif partage la vérité produit mais n’impose pas un langage UI `adaptive` tant qu’il n’est pas le surface principal de craft.
|
||||
- Auth : priorité Google OAuth ; email/mdp avec email vérifié obligatoire.
|
||||
- i18n : 15 langues ; calendrier iranien + chiffres persans + RTL pour le persan (et arabe) — app et extension.
|
||||
- Facturation : paliers BASIC/PRO/BUSINESS/ENTERPRISE, quotas IA, essai abonnement **7 jours** ; BASIC sans MCP ; BYOK possible selon config.
|
||||
|
||||
## Capabilities and Constraints
|
||||
|
||||
**Capacités confirmées (non exhaustif)** : notes/carnets, recherche sémantique, Memory Echo, insights réseau, dashboard Second Brain configurable, agents, flashcards SM-2, structured views, Web Clipper, MCP (hors BASIC), publication simple/IA, pages interactives, multilinguisme.
|
||||
|
||||
**Contraintes durables** :
|
||||
- Nom produit : **Memento** uniquement (jamais « Momento ») dans libellés, metadata, pages publiques, docs.
|
||||
- Libellés UI via i18n (`memento-note/locales/*.json`) — pas de texte en dur ; pas de marques tierces dans les libellés utilisateur.
|
||||
- Qualité UX prioritaire ; solutions long terme plutôt que rustines.
|
||||
- Pas de perte de données : jamais de reset/drop/truncate DB sans backup vérifié + confirmation explicite (voir `CLAUDE.md` / règles projet).
|
||||
- Quotas IA : réservation atomique avant appel ; chaque usage IA décompte.
|
||||
- Une user story / feature à la fois, validation utilisateur avant la suivante.
|
||||
- Docs de stories souvent périmées — vérifier le code avant de « (re)développer ».
|
||||
|
||||
**Ouvert / hors scope Impeccable init** : détails de roadmap feature-par-feature ; choix esthétiques (monde visuel → `new-work` / `document`).
|
||||
|
||||
## Brand Commitments
|
||||
|
||||
- Marque : **Memento** ; entité visible « Memento Labs » dans certains pied-de-page.
|
||||
- Promesse marketing : Second Brain — « Il se souvient de ce que vous avez oublié » / se connecte pendant que vous écrivez.
|
||||
- Voix : claire, concrète, orientée bénéfice utilisateur ; français correct en communication produit FR ; éviter le jargon non expliqué dans l’UI.
|
||||
- Référence prototype `architectural-grid` pour les flux métier (liste/carte notes, recherche, insights, sidebar, etc.) — engagement produit confirmé, pas une esthétique figée dans ce fichier.
|
||||
|
||||
## Evidence on Hand
|
||||
|
||||
- Copy landing et produit : `memento-note/locales/*.json` (ex. `landing.*`), `components/landing-page.tsx`.
|
||||
- README produit : `README.fr.md` / `README.md`, guide utilisateur `docs/guide-utilisateur/`.
|
||||
- Prototypes UI : `architectural-grid/`, `architectural-grid1/`.
|
||||
- Extension : `memento-note/extension/`.
|
||||
- Assets promo / démos : `promo-video/` (captures authentifiées, VO EN).
|
||||
- **Ne pas inventer** : témoignages clients, logos presse, benchmarks chiffrés non présents dans le repo.
|
||||
|
||||
## Product Principles
|
||||
|
||||
1. **Second Brain d’abord** — chaque surface renforce connexion, rappel et action ; pas le stockage pour le stockage.
|
||||
2. **Valeur pendant l’écriture** — Memory Echo, peek, agents et briefing doivent être exploitables dans le flux réel, pas décoratifs.
|
||||
3. **Accessible à la personne lambda, assez profond pour le chercheur** — clarté et aide contextuelle avant jargon ; puissance sans exiger d’être power-user dès le jour 1.
|
||||
4. **Monde multilingue réel** — RTL, calendriers et embeddings inclus ; pas d’anglais hardcodé quand l’UI est dans une autre langue.
|
||||
5. **Preuve avant promesse** — s’appuyer sur features et copy existantes ; ne pas fabriquer de preuves marketing.
|
||||
|
||||
## Accessibility & Inclusion
|
||||
|
||||
- Multilinguisme et RTL (fa/ar) sont des exigences produit, pas un bonus.
|
||||
- Aide contextuelle (« ? ») et libellés lisibles attendus là où l’UX le demande.
|
||||
- Standard WCAG formel non fixé dans cet init : à traiter comme **ouvert** ; viser une UI utilisable clavier / contraste correcte par défaut sur le web sans inventer une certification.
|
||||
@@ -31,3 +31,17 @@ All BYOK items implemented on 2026-05-30:
|
||||
|
||||
- **`lib/export/zip-builder.ts` non extrait** — logique inline dans la route ; fonctionnel mais écarte la structure prévue par la story.
|
||||
- **Rate limiting absent sur `GET /api/user/export`** — vecteur d’abus (exports répétés) ; hardening ultérieur.
|
||||
|
||||
## Deferred — revue page interactive (2026-07-24)
|
||||
|
||||
- Régénération section/démo (§7.3 spec origine) + UI auteur associée + tarif regen.
|
||||
- Page publique interactive : `ReadingProgress` + `CopyLinkButton` (régression UX vs templates magazine/brief/essay).
|
||||
- Fallback déterministe : libellés complets dans les 13 autres locales + `dir="rtl"` pour fa/ar (actuellement FR/EN seulement).
|
||||
- `normalize.ts` : caps 2 démos/5 sections plus restrictifs que le validateur (5/8) — aligner si latence LLM acceptable.
|
||||
- `validateImageSrc` : autoriser uniquement `/uploads` + https (bloquer http:// et //domaine externe — pixel de tracking sur page publique).
|
||||
- Quality gate « règle d'or » appliquée aussi au `pageSpec` posté directement à `/api/notes/publish` (pas seulement au flux LLM).
|
||||
- `resolve.ts` : numérotation des badges par démo (pas réinitialisée par acte).
|
||||
- `demo-scene-view.tsx:479` : warning eslint exhaustive-deps (`edges` dans useMemo).
|
||||
- `page-view.tsx` useScrollReveal : scoper les `querySelectorAll` via ref (multi-instance).
|
||||
- Test `rejects hex color` : le faire échouer via le scan sémantique (pas l'enum Zod) pour prouver le scan.
|
||||
- Page publiée sans JS : démos à l'état final au premier paint (spec §9.5) — nécessite SSR static + swap hydratation.
|
||||
|
||||
@@ -145,6 +145,7 @@ Dans **Stripe Dashboard** → **Développeurs** → **Webhooks** :
|
||||
- `customer.subscription.created`
|
||||
- `customer.subscription.updated`
|
||||
- `customer.subscription.deleted`
|
||||
- `customer.subscription.trial_will_end` (rappel email ~3 jours avant fin d’essai)
|
||||
- `invoice.payment_failed`
|
||||
4. Copier la **signature secrète** → `STRIPE_WEBHOOK_SECRET` (commence par `whsec_...`)
|
||||
|
||||
@@ -246,11 +247,12 @@ Utilisateur Frontend Backend
|
||||
|
||||
| Événement Stripe | Action Memento | Statut Prisma |
|
||||
|------------------|---------------|---------------|
|
||||
| `checkout.session.completed` | Upsert subscription avec tier/periode | `ACTIVE` |
|
||||
| `checkout.session.completed` | Upsert subscription avec tier/periode | Selon Stripe (`TRIALING` ou `ACTIVE`) |
|
||||
| `customer.subscription.created` | Upsert (nouvelle souscription) | Selon Stripe |
|
||||
| `customer.subscription.updated` | Upsert (changement de plan, etc.) | Selon Stripe |
|
||||
| `customer.subscription.deleted` | Marquer annulé | `CANCELED` |
|
||||
| `invoice.payment_failed` | Sync subscription (passage en `PAST_DUE`) | `PAST_DUE` |
|
||||
| `customer.subscription.trial_will_end` | Sync + email rappel fin d’essai | `TRIALING` |
|
||||
|
||||
### 3.3 Mapping statuts Stripe → Prisma
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Model Context Protocol (MCP) server for integrating Memento note-taking app with
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ **22 Tools** for notes, notebooks, labels, and reminders
|
||||
- ✅ **29 Tools** for notes, notebooks, labels, reminders, semantic similarity, Memory Echo insights, and statistics
|
||||
- 🔒 **API Key Authentication** with secure storage
|
||||
- 🚀 **Performance Optimized** with connection pooling and caching
|
||||
- 📊 **Observability** with Prometheus metrics export
|
||||
@@ -61,7 +61,7 @@ Generate API keys from the Memento web UI: **Settings > MCP**.
|
||||
curl -H "x-api-key: mcp_sk_xxx" http://localhost:3001/health
|
||||
```
|
||||
|
||||
## Available Tools (22)
|
||||
## Available Tools (29)
|
||||
|
||||
### Notes (13)
|
||||
|
||||
@@ -81,7 +81,7 @@ curl -H "x-api-key: mcp_sk_xxx" http://localhost:3001/health
|
||||
| `batch_move_notes` | Move multiple notes at once |
|
||||
| `batch_delete_notes` | Delete multiple notes at once |
|
||||
|
||||
### Notebooks (6)
|
||||
### Notebooks (7)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
@@ -102,11 +102,28 @@ curl -H "x-api-key: mcp_sk_xxx" http://localhost:3001/health
|
||||
| `update_label` | Update a label |
|
||||
| `delete_label` | Delete a label |
|
||||
|
||||
### Reminders (1)
|
||||
### Reminders (3)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `get_due_reminders` | Get due reminders |
|
||||
| `get_upcoming_reminders` | Get reminders due in the next N hours |
|
||||
| `update_reminder` | Set, update, or clear a note reminder |
|
||||
|
||||
### Semantic & AI (3)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `find_similar_notes` | Find notes semantically similar to a given note |
|
||||
| `get_memory_echo_insights` | List AI-discovered note connections |
|
||||
| `dismiss_memory_echo_insight` | Mark a Memory Echo insight as viewed/dismissed |
|
||||
|
||||
### Filters & Statistics (2)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `get_notes_by_label` | List notes by label/tag |
|
||||
| `get_note_statistics` | Get counts and breakdowns for notes, notebooks, labels |
|
||||
|
||||
### Utilities (2)
|
||||
|
||||
|
||||
@@ -197,8 +197,9 @@ export function validateConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
// Port validation
|
||||
const portValidation = validatePort(config.port);
|
||||
// Port validation (check raw value before clamping)
|
||||
const rawPort = Number.parseInt(env('PORT', '3001'), 10);
|
||||
const portValidation = validatePort(Number.isNaN(rawPort) ? config.port : rawPort);
|
||||
if (!portValidation.valid) {
|
||||
errors.push({ key: 'PORT', message: portValidation.error, critical: true });
|
||||
}
|
||||
|
||||
@@ -394,7 +394,7 @@ app.get('/', (req, res) => {
|
||||
sessions: '/sessions',
|
||||
},
|
||||
auth: { enabled: config.requireAuth },
|
||||
tools: 22,
|
||||
tools: 29,
|
||||
uptime: process.uptime(),
|
||||
});
|
||||
});
|
||||
@@ -445,25 +445,23 @@ app.all(
|
||||
}
|
||||
|
||||
// Validate tool input if present
|
||||
if (req.body?.method) {
|
||||
const toolName = req.body.method;
|
||||
if (req.body?.params) {
|
||||
const validation = validateAndSanitize(toolName, req.body.params);
|
||||
if (!validation.success) {
|
||||
log('warn', `Validation failed for ${toolName}:`, validation.errors);
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
mcpError(McpErrors.INVALID_PARAMS.code, {
|
||||
detail: 'Input validation failed',
|
||||
field: validation.errors[0]?.field,
|
||||
context: { toolName, errors: validation.errors },
|
||||
})
|
||||
);
|
||||
}
|
||||
// Update request with sanitized data
|
||||
req.body.params = validation.data;
|
||||
if (req.body?.method === 'tools/call' && req.body?.params?.name && req.body?.params?.arguments) {
|
||||
const toolName = req.body.params.name;
|
||||
const validation = validateAndSanitize(toolName, req.body.params.arguments);
|
||||
if (!validation.success) {
|
||||
log('warn', `Validation failed for ${toolName}:`, validation.errors);
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
mcpError(McpErrors.INVALID_PARAMS.code, {
|
||||
detail: 'Input validation failed',
|
||||
field: validation.errors[0]?.field,
|
||||
context: { toolName, errors: validation.errors },
|
||||
})
|
||||
);
|
||||
}
|
||||
// Update request with sanitized data
|
||||
req.body.params.arguments = validation.data;
|
||||
}
|
||||
|
||||
const ctx = { userId: req.userSession?.userId || null };
|
||||
@@ -539,7 +537,7 @@ async function main() {
|
||||
Auth: ${config.requireAuth ? 'ENABLED' : 'DISABLED (dev)'}
|
||||
Timeout: ${config.requestTimeout}ms
|
||||
Database: ${isPostgres ? 'PostgreSQL' : 'SQLite'}
|
||||
Tools: 22
|
||||
Tools: 29
|
||||
Features: ${config.enableMetrics ? 'Metrics' : ''}${config.enableAuditLog ? ', Audit Log' : ''}
|
||||
`);
|
||||
});
|
||||
|
||||
@@ -176,7 +176,7 @@ async function main() {
|
||||
Database: ${isPostgres ? 'PostgreSQL' : 'SQLite'}
|
||||
User: ${config.userId || 'all'}
|
||||
Log Level: ${config.logLevel}
|
||||
Tools: 22
|
||||
Tools: 29
|
||||
`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "memento-mcp-server",
|
||||
"version": "3.2.0",
|
||||
"description": "MCP Server for Memento - AI-powered note-taking app. Enhanced with error handling, metrics, rate limiting, and input validation. Provides 22 tools for notes, notebooks, labels, and reminders.",
|
||||
"description": "MCP Server for Memento - AI-powered note-taking app. Enhanced with error handling, metrics, rate limiting, and input validation. Provides 29 tools for notes, notebooks, labels, reminders, semantic similarity, Memory Echo insights, and statistics.",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Run with: npm test
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
@@ -124,9 +124,8 @@ describe('MCP Server - Input Validation', () => {
|
||||
|
||||
it('should allow safe HTML', () => {
|
||||
const xss = checkXSS({ content: 'Hello <em>world</em>' });
|
||||
// This will be true because we check for any HTML tags
|
||||
// In production, you might want more sophisticated checking
|
||||
expect(xss).toBe(true);
|
||||
// Safe formatting tags are not flagged as XSS
|
||||
expect(xss).toBe(false);
|
||||
});
|
||||
|
||||
it('should sanitize input', () => {
|
||||
@@ -206,11 +205,18 @@ describe('MCP Server - Tool Definitions', () => {
|
||||
'update_label',
|
||||
'delete_label',
|
||||
'get_due_reminders',
|
||||
'get_upcoming_reminders',
|
||||
'update_reminder',
|
||||
'find_similar_notes',
|
||||
'get_memory_echo_insights',
|
||||
'dismiss_memory_echo_insight',
|
||||
'get_notes_by_label',
|
||||
'get_note_statistics',
|
||||
'export_notes',
|
||||
'import_notes',
|
||||
];
|
||||
|
||||
it('should have all expected tools with schemas', () => {
|
||||
it('should have all expected tools with schemas', async () => {
|
||||
const { toolSchemas } = await import('../validation.js');
|
||||
|
||||
for (const toolName of toolNames) {
|
||||
|
||||
@@ -19,7 +19,7 @@ const DEFAULT_NOTES_LIMIT = 100;
|
||||
const MAX_NOTES_LIMIT = 500;
|
||||
|
||||
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 LABEL_COLORS = ['red', 'orange', 'yellow', 'green', 'teal', 'blue', 'purple', 'pink', 'gray'];
|
||||
|
||||
export function parseNote(dbNote) {
|
||||
if (!dbNote) return null;
|
||||
@@ -463,6 +463,100 @@ const toolDefinitions = [
|
||||
description: 'Get notes with due reminders. Designed for cron/automation.',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
},
|
||||
{
|
||||
name: 'get_upcoming_reminders',
|
||||
description: 'Get notes with reminders due in the next N hours (default 24).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
hours: { type: 'number', description: 'Number of hours ahead to look', default: 24 },
|
||||
includeDone: { type: 'boolean', description: 'Include already-done reminders', default: false },
|
||||
limit: { type: 'number', description: 'Max results', default: 100 },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'update_reminder',
|
||||
description: 'Set, update, or clear a reminder on a note.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Note ID' },
|
||||
reminder: { type: 'string', description: 'ISO 8601 datetime, or null to clear', nullable: true },
|
||||
isReminderDone: { type: 'boolean' },
|
||||
reminderRecurrence: { type: 'string', description: 'daily, weekly, monthly, yearly', nullable: true },
|
||||
reminderLocation: { type: 'string', nullable: true },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
|
||||
// ═══ SEMANTIC / AI ═══
|
||||
{
|
||||
name: 'find_similar_notes',
|
||||
description: 'Find notes semantically similar to a given note using its vector embedding. Requires the note to have an embedding.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Source note ID' },
|
||||
limit: { type: 'number', description: 'Max results', default: 10 },
|
||||
threshold: { type: 'number', description: 'Minimum cosine similarity (0-1)', default: 0.7 },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_memory_echo_insights',
|
||||
description: 'List Memory Echo insights: AI-discovered connections between notes.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
includeViewed: { type: 'boolean', description: 'Include already viewed insights', default: false },
|
||||
includeDismissed: { type: 'boolean', description: 'Include dismissed insights', default: false },
|
||||
limit: { type: 'number', description: 'Max results', default: 20 },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'dismiss_memory_echo_insight',
|
||||
description: 'Mark a Memory Echo insight as viewed or dismissed.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Insight ID' },
|
||||
viewed: { type: 'boolean', description: 'Mark as viewed', default: true },
|
||||
dismissed: { type: 'boolean', description: 'Mark as dismissed', default: true },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
|
||||
// ═══ FILTERS & STATISTICS ═══
|
||||
{
|
||||
name: 'get_notes_by_label',
|
||||
description: 'List notes that have a specific label/tag.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
label: { type: 'string', description: 'Label text to match' },
|
||||
notebookId: { type: 'string', description: 'Filter by notebook', nullable: true },
|
||||
includeArchived: { type: 'boolean', default: false },
|
||||
limit: { type: 'number', description: 'Max results', default: 100 },
|
||||
},
|
||||
required: ['label'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_note_statistics',
|
||||
description: 'Get statistics about notes, notebooks, labels, reminders, and trash.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
includeArchived: { type: 'boolean', description: 'Include archived notes in counts', default: false },
|
||||
includeTrashed: { type: 'boolean', description: 'Include trashed notes in counts', default: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Tool Handlers ──────────────────────────────────────────────────────────
|
||||
@@ -502,7 +596,7 @@ export function registerTools(server, prisma) {
|
||||
title: args.title || null,
|
||||
content: args.content,
|
||||
color: args.color || 'default',
|
||||
type: args.type || 'text',
|
||||
type: args.type || 'richtext',
|
||||
checkItems: args.checkItems ?? null,
|
||||
labels: args.labels ?? null,
|
||||
isPinned: args.isPinned || false,
|
||||
@@ -1003,6 +1097,201 @@ export function registerTools(server, prisma) {
|
||||
return textResult({ count: reminders.length, reminders });
|
||||
}
|
||||
|
||||
case 'get_upcoming_reminders': {
|
||||
const now = new Date();
|
||||
const horizon = new Date(now.getTime() + (args.hours || 24) * 60 * 60 * 1000);
|
||||
const extra = {
|
||||
reminder: { not: null, gte: now, lte: horizon },
|
||||
isArchived: false,
|
||||
};
|
||||
if (!args.includeDone) extra.isReminderDone = false;
|
||||
|
||||
const reminders = await prisma.note.findMany({
|
||||
where: noteWhere(uid, extra),
|
||||
select: { id: true, title: true, content: true, reminder: true, isReminderDone: true, notebookId: true },
|
||||
orderBy: { reminder: 'asc' },
|
||||
take: args.limit || 100,
|
||||
});
|
||||
|
||||
return textResult({ count: reminders.length, horizon, reminders });
|
||||
}
|
||||
|
||||
case 'update_reminder': {
|
||||
const d = { updatedAt: new Date() };
|
||||
if ('reminder' in args) d.reminder = args.reminder ? new Date(args.reminder) : null;
|
||||
if ('isReminderDone' in args) d.isReminderDone = args.isReminderDone;
|
||||
if ('reminderRecurrence' in args) d.reminderRecurrence = args.reminderRecurrence || null;
|
||||
if ('reminderLocation' in args) d.reminderLocation = args.reminderLocation || null;
|
||||
|
||||
const note = await prisma.note.update({
|
||||
where: { id: args.id, userId: uid, trashedAt: null },
|
||||
data: d,
|
||||
});
|
||||
return textResult(parseNote(note));
|
||||
}
|
||||
|
||||
// ═══ SEMANTIC / AI ═══
|
||||
case 'find_similar_notes': {
|
||||
const source = await prisma.note.findUnique({
|
||||
where: { id: args.id, userId: uid, trashedAt: null },
|
||||
select: { id: true, title: true },
|
||||
});
|
||||
if (!source) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
|
||||
|
||||
const sourceEmbedding = await prisma.noteEmbedding.findUnique({
|
||||
where: { noteId: args.id },
|
||||
select: { embedding: true },
|
||||
});
|
||||
if (!sourceEmbedding) {
|
||||
return textResult({
|
||||
noteId: args.id,
|
||||
error: 'Source note has no embedding. Index it first via the Memento app.',
|
||||
results: [],
|
||||
});
|
||||
}
|
||||
|
||||
const embeddingArray = Array.isArray(sourceEmbedding.embedding)
|
||||
? sourceEmbedding.embedding
|
||||
: String(sourceEmbedding.embedding).replace(/^\[|\]$/g, '').split(',').map(Number);
|
||||
const vecStr = `[${embeddingArray.join(',')}]`;
|
||||
const limit = Math.min(args.limit || 10, 50);
|
||||
const threshold = args.threshold ?? 0.7;
|
||||
|
||||
const rows = await prisma.$queryRawUnsafe(
|
||||
`SELECT n.id, n.title, n.content, n.color, n.type, n."isPinned", n."isArchived",
|
||||
n."notebookId", n."createdAt", n."updatedAt",
|
||||
1 - (e."embedding"::vector <=> $1::vector) AS similarity
|
||||
FROM "Note" n
|
||||
INNER JOIN "NoteEmbedding" e ON e."noteId" = n.id
|
||||
WHERE n."trashedAt" IS NULL
|
||||
AND n."isArchived" = false
|
||||
AND n."userId" = $2
|
||||
AND n.id != $3
|
||||
AND 1 - (e."embedding"::vector <=> $1::vector) >= $4
|
||||
ORDER BY e."embedding"::vector <=> $1::vector ASC
|
||||
LIMIT $5`,
|
||||
vecStr,
|
||||
uid,
|
||||
args.id,
|
||||
threshold,
|
||||
limit,
|
||||
);
|
||||
|
||||
return textResult({
|
||||
noteId: args.id,
|
||||
sourceTitle: source.title || 'Untitled',
|
||||
count: rows.length,
|
||||
results: rows.map(r => ({ ...parseNoteLightweight(r), similarity: Number(r.similarity) })),
|
||||
});
|
||||
}
|
||||
|
||||
case 'get_memory_echo_insights': {
|
||||
const where = { userId: uid };
|
||||
if (!args.includeViewed) where.viewed = false;
|
||||
if (!args.includeDismissed) where.dismissed = false;
|
||||
|
||||
const insights = await prisma.memoryEchoInsight.findMany({
|
||||
where,
|
||||
orderBy: { similarityScore: 'desc' },
|
||||
take: Math.min(args.limit || 20, 100),
|
||||
include: {
|
||||
note1: { select: { id: true, title: true } },
|
||||
note2: { select: { id: true, title: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return textResult({
|
||||
count: insights.length,
|
||||
insights: insights.map(i => ({
|
||||
id: i.id,
|
||||
insight: i.insight,
|
||||
similarityScore: i.similarityScore,
|
||||
insightDate: i.insightDate,
|
||||
viewed: i.viewed,
|
||||
dismissed: i.dismissed,
|
||||
note1: i.note1,
|
||||
note2: i.note2,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
case 'dismiss_memory_echo_insight': {
|
||||
const d = {};
|
||||
if ('viewed' in args) d.viewed = args.viewed;
|
||||
if ('dismissed' in args) d.dismissed = args.dismissed;
|
||||
|
||||
const insight = await prisma.memoryEchoInsight.update({
|
||||
where: { id: args.id, userId: uid },
|
||||
data: d,
|
||||
});
|
||||
return textResult({ success: true, id: insight.id, viewed: insight.viewed, dismissed: insight.dismissed });
|
||||
}
|
||||
|
||||
// ═══ FILTERS & STATISTICS ═══
|
||||
case 'get_notes_by_label': {
|
||||
const label = args.label.trim();
|
||||
const extra = {};
|
||||
if (!args.includeArchived) extra.isArchived = false;
|
||||
if (args.notebookId) {
|
||||
extra.notebookId = args.notebookId === 'inbox' ? null : args.notebookId;
|
||||
}
|
||||
|
||||
// Labels are stored as a JSON string like ["tag1","tag2"].
|
||||
const notes = await prisma.note.findMany({
|
||||
where: {
|
||||
...noteWhere(uid, extra),
|
||||
labels: { contains: label },
|
||||
},
|
||||
orderBy: [{ isPinned: 'desc' }, { updatedAt: 'desc' }],
|
||||
take: Math.min(args.limit || 100, MAX_NOTES_LIMIT),
|
||||
});
|
||||
|
||||
return textResult({ label, count: notes.length, notes: notes.map(parseNoteLightweight) });
|
||||
}
|
||||
|
||||
case 'get_note_statistics': {
|
||||
const baseWhere = { userId: uid };
|
||||
const activeWhere = { ...baseWhere, trashedAt: null };
|
||||
|
||||
const [
|
||||
totalNotes,
|
||||
archivedNotes,
|
||||
trashedNotes,
|
||||
notebookCount,
|
||||
labelCount,
|
||||
notesWithReminders,
|
||||
notesWithImages,
|
||||
notesWithCheckItems,
|
||||
notesByType,
|
||||
notesByColor,
|
||||
] = await Promise.all([
|
||||
prisma.note.count({ where: activeWhere }),
|
||||
prisma.note.count({ where: { ...activeWhere, isArchived: true } }),
|
||||
prisma.note.count({ where: { ...baseWhere, trashedAt: { not: null } } }),
|
||||
prisma.notebook.count({ where: { userId: uid } }),
|
||||
prisma.label.count({ where: { notebook: { userId: uid } } }),
|
||||
prisma.note.count({ where: { ...activeWhere, reminder: { not: null } } }),
|
||||
prisma.note.count({ where: { ...activeWhere, images: { not: null } } }),
|
||||
prisma.note.count({ where: { ...activeWhere, checkItems: { not: null } } }),
|
||||
prisma.note.groupBy({ by: ['type'], where: activeWhere, _count: { type: true } }),
|
||||
prisma.note.groupBy({ by: ['color'], where: activeWhere, _count: { color: true } }),
|
||||
]);
|
||||
|
||||
return textResult({
|
||||
totalNotes,
|
||||
archivedNotes,
|
||||
trashedNotes,
|
||||
activeNotes: totalNotes - archivedNotes,
|
||||
notebookCount,
|
||||
labelCount,
|
||||
notesWithReminders,
|
||||
notesWithImages,
|
||||
notesWithCheckItems,
|
||||
byType: notesByType.map(t => ({ type: t.type, count: t._count.type })),
|
||||
byColor: notesByColor.map(c => ({ color: c.color, count: c._count.color })),
|
||||
});
|
||||
}
|
||||
|
||||
// ═══ TRASH ═══
|
||||
case 'trash_note': {
|
||||
const note = await prisma.note.update({
|
||||
|
||||
@@ -179,7 +179,6 @@ export const getNotesSchema = z.object({
|
||||
notebookId: idSchema.optional().nullable(),
|
||||
fullDetails: boolSchema(false),
|
||||
limit: z.number().int().min(1).max(500).default(100),
|
||||
offset: z.number().int().min(0).default(0),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -225,7 +224,6 @@ export const deleteNoteSchema = z.object({
|
||||
*/
|
||||
export const searchNotesSchema = z.object({
|
||||
query: z.string().min(1).max(500),
|
||||
limit: z.number().int().min(1).max(100).default(50),
|
||||
notebookId: idSchema.optional().nullable(),
|
||||
includeArchived: boolSchema(false),
|
||||
});
|
||||
@@ -243,7 +241,6 @@ export const moveNoteSchema = z.object({
|
||||
*/
|
||||
export const togglePinSchema = z.object({
|
||||
id: idSchema,
|
||||
pinned: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -251,14 +248,13 @@ export const togglePinSchema = z.object({
|
||||
*/
|
||||
export const toggleArchiveSchema = z.object({
|
||||
id: idSchema,
|
||||
archived: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* batch_move_notes input schema
|
||||
*/
|
||||
export const batchMoveNotesSchema = z.object({
|
||||
noteIds: z.array(idSchema).min(1).max(100),
|
||||
ids: z.array(idSchema).min(1).max(100),
|
||||
notebookId: idSchema.optional().nullable(),
|
||||
});
|
||||
|
||||
@@ -266,7 +262,7 @@ export const batchMoveNotesSchema = z.object({
|
||||
* batch_delete_notes input schema
|
||||
*/
|
||||
export const batchDeleteNotesSchema = z.object({
|
||||
noteIds: z.array(idSchema).min(1).max(100),
|
||||
ids: z.array(idSchema).min(1).max(100),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -274,18 +270,16 @@ export const batchDeleteNotesSchema = z.object({
|
||||
*/
|
||||
export const createNotebookSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
color: colorSchema.default('default'),
|
||||
color: z.string().max(50).default('#3B82F6'),
|
||||
icon: z.string().max(50).optional().nullable(),
|
||||
parentId: idSchema.optional().nullable(),
|
||||
order: z.number().int().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* get_notebooks input schema
|
||||
*/
|
||||
export const getNotebooksSchema = z.object({
|
||||
includeHierarchy: boolSchema(false),
|
||||
includeTrashed: boolSchema(false),
|
||||
});
|
||||
export const getNotebooksSchema = z.object({});
|
||||
|
||||
/**
|
||||
* get_notebook input schema
|
||||
@@ -300,8 +294,9 @@ export const getNotebookSchema = z.object({
|
||||
export const updateNotebookSchema = z.object({
|
||||
id: idSchema,
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
color: colorSchema.optional(),
|
||||
color: z.string().max(50).optional().nullable(),
|
||||
icon: z.string().max(50).optional().nullable(),
|
||||
order: z.number().int().optional(),
|
||||
parentId: idSchema.optional().nullable(),
|
||||
});
|
||||
|
||||
@@ -332,14 +327,15 @@ export const getNotebookHierarchySchema = z.object({
|
||||
*/
|
||||
export const createLabelSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
color: colorSchema.default('default'),
|
||||
color: z.string().max(50).optional().nullable(),
|
||||
notebookId: idSchema,
|
||||
});
|
||||
|
||||
/**
|
||||
* get_labels input schema
|
||||
*/
|
||||
export const getLabelsSchema = z.object({
|
||||
limit: z.number().int().min(1).max(500).default(100),
|
||||
notebookId: idSchema.optional().nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -361,37 +357,90 @@ export const deleteLabelSchema = z.object({
|
||||
/**
|
||||
* get_due_reminders input schema
|
||||
*/
|
||||
export const getDueRemindersSchema = z.object({
|
||||
before: isoDateSchema.optional().nullable(),
|
||||
after: isoDateSchema.optional().nullable(),
|
||||
includeDone: boolSchema(false),
|
||||
limit: z.number().int().min(1).max(500).default(100),
|
||||
});
|
||||
export const getDueRemindersSchema = z.object({});
|
||||
|
||||
/**
|
||||
* export_notes input schema
|
||||
*/
|
||||
export const exportNotesSchema = z.object({
|
||||
notebookId: idSchema.optional().nullable(),
|
||||
includeArchived: boolSchema(false),
|
||||
format: z.enum(['json', 'markdown']).default('json'),
|
||||
});
|
||||
export const exportNotesSchema = z.object({});
|
||||
|
||||
/**
|
||||
* import_notes input schema
|
||||
*/
|
||||
export const importNotesSchema = z.object({
|
||||
notes: z.array(
|
||||
z.object({
|
||||
title: z.string().optional(),
|
||||
content: z.string(),
|
||||
color: colorSchema.optional(),
|
||||
labels: labelsSchema,
|
||||
notebookId: idSchema.optional().nullable(),
|
||||
})
|
||||
).min(1).max(100),
|
||||
data: z.object({
|
||||
version: z.string().optional(),
|
||||
data: z.object({
|
||||
notes: z.array(z.any()).optional(),
|
||||
labels: z.array(z.any()).optional(),
|
||||
notebooks: z.array(z.any()).optional(),
|
||||
}).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* find_similar_notes input schema
|
||||
*/
|
||||
export const findSimilarNotesSchema = z.object({
|
||||
id: idSchema,
|
||||
limit: z.number().int().min(1).max(50).default(10),
|
||||
threshold: z.number().min(0).max(1).default(0.7),
|
||||
});
|
||||
|
||||
/**
|
||||
* get_memory_echo_insights input schema
|
||||
*/
|
||||
export const getMemoryEchoInsightsSchema = z.object({
|
||||
includeViewed: boolSchema(false),
|
||||
includeDismissed: boolSchema(false),
|
||||
limit: z.number().int().min(1).max(100).default(20),
|
||||
});
|
||||
|
||||
/**
|
||||
* dismiss_memory_echo_insight input schema
|
||||
*/
|
||||
export const dismissMemoryEchoInsightSchema = z.object({
|
||||
id: idSchema,
|
||||
viewed: boolSchema(true),
|
||||
dismissed: boolSchema(true),
|
||||
});
|
||||
|
||||
/**
|
||||
* get_notes_by_label input schema
|
||||
*/
|
||||
export const getNotesByLabelSchema = z.object({
|
||||
label: z.string().min(1).max(100),
|
||||
notebookId: idSchema.optional().nullable(),
|
||||
overwrite: z.boolean().optional().default(false),
|
||||
includeArchived: boolSchema(false),
|
||||
limit: z.number().int().min(1).max(500).default(100),
|
||||
});
|
||||
|
||||
/**
|
||||
* get_note_statistics input schema
|
||||
*/
|
||||
export const getNoteStatisticsSchema = z.object({
|
||||
includeArchived: boolSchema(false),
|
||||
includeTrashed: boolSchema(false),
|
||||
});
|
||||
|
||||
/**
|
||||
* get_upcoming_reminders input schema
|
||||
*/
|
||||
export const getUpcomingRemindersSchema = z.object({
|
||||
hours: z.number().int().min(1).max(168).default(24),
|
||||
includeDone: boolSchema(false),
|
||||
limit: z.number().int().min(1).max(500).default(100),
|
||||
});
|
||||
|
||||
/**
|
||||
* update_reminder input schema
|
||||
*/
|
||||
export const updateReminderSchema = z.object({
|
||||
id: idSchema,
|
||||
reminder: isoDateSchema,
|
||||
isReminderDone: z.boolean().optional(),
|
||||
reminderRecurrence: recurrenceSchema,
|
||||
reminderLocation: z.string().max(500).optional().nullable(),
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -427,6 +476,13 @@ export const toolSchemas = {
|
||||
get_due_reminders: getDueRemindersSchema,
|
||||
export_notes: exportNotesSchema,
|
||||
import_notes: importNotesSchema,
|
||||
find_similar_notes: findSimilarNotesSchema,
|
||||
get_memory_echo_insights: getMemoryEchoInsightsSchema,
|
||||
dismiss_memory_echo_insight: dismissMemoryEchoInsightSchema,
|
||||
get_notes_by_label: getNotesByLabelSchema,
|
||||
get_note_statistics: getNoteStatisticsSchema,
|
||||
get_upcoming_reminders: getUpcomingRemindersSchema,
|
||||
update_reminder: updateReminderSchema,
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -457,7 +513,7 @@ export function validateToolInput(toolName, input) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return {
|
||||
success: false,
|
||||
errors: error.errors.map((e) => ({
|
||||
errors: error.issues.map((e) => ({
|
||||
field: e.path.join('.'),
|
||||
message: e.message,
|
||||
code: e.code,
|
||||
|
||||
7
mcp-server/vitest.config.js
Normal file
7
mcp-server/vitest.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['test/test.js', 'test/**/*.test.js'],
|
||||
},
|
||||
});
|
||||
@@ -1,24 +1,41 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { updateBillingConfig, updatePlanEntitlement } from '@/app/actions/admin-billing'
|
||||
import { updateBillingConfig } from '@/app/actions/admin-billing'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { CreditCard, Gauge, Settings2 } from 'lucide-react'
|
||||
import {
|
||||
CreditCard,
|
||||
Gauge,
|
||||
Coins,
|
||||
Package,
|
||||
Activity,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
Users,
|
||||
BookOpen,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type EntitlementRow = {
|
||||
tier: string
|
||||
feature: string
|
||||
limitValue: number | null
|
||||
mode: 'limited' | 'unlimited' | 'unavailable'
|
||||
type PriceHealth = {
|
||||
key: string
|
||||
priceId: string
|
||||
configured: boolean
|
||||
source: string
|
||||
stripeAmount: number | null
|
||||
stripeCurrency: string | null
|
||||
stripeActive: boolean | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
type BillingAdminData = {
|
||||
entitlements: EntitlementRow[]
|
||||
billingConfig: Record<string, string>
|
||||
usageOverview: {
|
||||
period: string
|
||||
@@ -26,34 +43,93 @@ type BillingAdminData = {
|
||||
byFeature: Array<{ feature: string; requests: number; tokens: number; users: number }>
|
||||
topUsers: Array<{ userId: string; email: string; name: string | null; requests: number }>
|
||||
}
|
||||
features: string[]
|
||||
tiers: string[]
|
||||
creditAllocations?: Array<{
|
||||
tier: string
|
||||
monthlyCredits: number | null
|
||||
unlimited: boolean
|
||||
}>
|
||||
creditCosts?: Record<string, number>
|
||||
creditPacks?: Array<{
|
||||
id: string
|
||||
credits: number
|
||||
defaultDisplay: string
|
||||
}>
|
||||
stripeHealth?: {
|
||||
secretConfigured: boolean
|
||||
secretMode: 'test' | 'live' | 'missing' | 'placeholder'
|
||||
publishableConfigured: boolean
|
||||
webhookSecretConfigured: boolean
|
||||
billingEnabled: boolean
|
||||
trialDays: number
|
||||
prices: PriceHealth[]
|
||||
}
|
||||
subscriptionStats?: {
|
||||
byTier: Record<string, number>
|
||||
byStatus: Record<string, number>
|
||||
cancelAtPeriodEnd: number
|
||||
usersWithoutSub: number
|
||||
trialing: number
|
||||
pastDue: number
|
||||
paidActive: number
|
||||
recent: Array<{
|
||||
email: string
|
||||
name: string | null
|
||||
tier: string
|
||||
status: string
|
||||
trialEndsAt: string | null
|
||||
currentPeriodEnd: string | null
|
||||
cancelAtPeriodEnd: boolean
|
||||
hasStripeSub: boolean
|
||||
updatedAt: string
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
function getEntitlement(
|
||||
entitlements: EntitlementRow[],
|
||||
tier: string,
|
||||
feature: string,
|
||||
): EntitlementRow | undefined {
|
||||
return entitlements.find((e) => e.tier === tier && e.feature === feature)
|
||||
const SUBSCRIPTION_PRICE_KEYS = [
|
||||
'STRIPE_PRICE_PRO_MONTHLY',
|
||||
'STRIPE_PRICE_PRO_ANNUAL',
|
||||
'STRIPE_PRICE_BUSINESS_MONTHLY',
|
||||
'STRIPE_PRICE_BUSINESS_ANNUAL',
|
||||
] as const
|
||||
|
||||
const PACK_PRICE_KEYS = [
|
||||
'STRIPE_PRICE_CREDITS_S',
|
||||
'STRIPE_PRICE_CREDITS_M',
|
||||
'STRIPE_PRICE_CREDITS_L',
|
||||
] as const
|
||||
|
||||
function formatStableUtc(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} UTC`
|
||||
}
|
||||
|
||||
function StatusDot({ ok, warn }: { ok: boolean; warn?: boolean }) {
|
||||
if (ok) return <CheckCircle2 className="h-4 w-4 text-emerald-600 shrink-0" />
|
||||
if (warn) return <AlertTriangle className="h-4 w-4 text-amber-500 shrink-0" />
|
||||
return <XCircle className="h-4 w-4 text-rose-500 shrink-0" />
|
||||
}
|
||||
|
||||
export function BillingAdminClient({ initialData }: { initialData: BillingAdminData }) {
|
||||
const { t } = useLanguage()
|
||||
const [activeTier, setActiveTier] = useState(initialData.tiers[0] ?? 'BASIC')
|
||||
const [billingEnabled, setBillingEnabled] = useState(initialData.billingConfig.BILLING_ENABLED === 'true')
|
||||
const [isSavingBilling, setIsSavingBilling] = useState(false)
|
||||
const [savingCell, setSavingCell] = useState<string | null>(null)
|
||||
const [showTestGuide, setShowTestGuide] = useState(true)
|
||||
|
||||
const health = initialData.stripeHealth
|
||||
const stats = initialData.subscriptionStats
|
||||
const priceMap = Object.fromEntries((health?.prices ?? []).map((p) => [p.key, p]))
|
||||
|
||||
const handleSaveBilling = async (formData: FormData) => {
|
||||
setIsSavingBilling(true)
|
||||
try {
|
||||
const data: Record<string, string> = {
|
||||
BILLING_ENABLED: billingEnabled ? 'true' : 'false',
|
||||
STRIPE_PRICE_PRO_MONTHLY: String(formData.get('STRIPE_PRICE_PRO_MONTHLY') ?? ''),
|
||||
STRIPE_PRICE_PRO_ANNUAL: String(formData.get('STRIPE_PRICE_PRO_ANNUAL') ?? ''),
|
||||
STRIPE_PRICE_BUSINESS_MONTHLY: String(formData.get('STRIPE_PRICE_BUSINESS_MONTHLY') ?? ''),
|
||||
STRIPE_PRICE_BUSINESS_ANNUAL: String(formData.get('STRIPE_PRICE_BUSINESS_ANNUAL') ?? ''),
|
||||
}
|
||||
for (const key of [...SUBSCRIPTION_PRICE_KEYS, ...PACK_PRICE_KEYS]) {
|
||||
data[key] = String(formData.get(key) ?? '')
|
||||
}
|
||||
await updateBillingConfig(data)
|
||||
toast.success(t('admin.billing.configSaved'))
|
||||
@@ -64,22 +140,14 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
|
||||
}
|
||||
}
|
||||
|
||||
const handleEntitlementChange = async (
|
||||
feature: string,
|
||||
mode: 'unavailable' | 'unlimited' | 'limited',
|
||||
limitValue?: number,
|
||||
) => {
|
||||
const key = `${activeTier}:${feature}`
|
||||
setSavingCell(key)
|
||||
try {
|
||||
await updatePlanEntitlement(activeTier, feature, mode, limitValue)
|
||||
toast.success(t('admin.billing.limitSaved'))
|
||||
} catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : t('admin.billing.limitFailed'))
|
||||
} finally {
|
||||
setSavingCell(null)
|
||||
}
|
||||
}
|
||||
const modeLabel =
|
||||
health?.secretMode === 'test'
|
||||
? t('admin.billing.modeTest')
|
||||
: health?.secretMode === 'live'
|
||||
? t('admin.billing.modeLive')
|
||||
: health?.secretMode === 'placeholder'
|
||||
? t('admin.billing.modePlaceholder')
|
||||
: t('admin.billing.modeMissing')
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
@@ -88,6 +156,246 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
|
||||
<p className="text-sm text-muted-foreground mt-1">{t('admin.billing.description')}</p>
|
||||
</div>
|
||||
|
||||
{/* Stripe health */}
|
||||
{health && (
|
||||
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-3 p-6 border-b border-border">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
||||
<Activity className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold">{t('admin.billing.healthTitle')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('admin.billing.healthDescription')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="flex items-start gap-2 rounded-xl border border-border/60 bg-muted/20 p-3">
|
||||
<StatusDot ok={health.secretConfigured} warn={health.secretMode === 'placeholder'} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold">{t('admin.billing.healthSecret')}</p>
|
||||
<p className="text-[11px] text-muted-foreground">{modeLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 rounded-xl border border-border/60 bg-muted/20 p-3">
|
||||
<StatusDot ok={health.publishableConfigured} />
|
||||
<div>
|
||||
<p className="text-xs font-semibold">{t('admin.billing.healthPublishable')}</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{health.publishableConfigured ? t('admin.billing.configured') : t('admin.billing.missing')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 rounded-xl border border-border/60 bg-muted/20 p-3">
|
||||
<StatusDot ok={health.webhookSecretConfigured} />
|
||||
<div>
|
||||
<p className="text-xs font-semibold">{t('admin.billing.healthWebhook')}</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{health.webhookSecretConfigured ? t('admin.billing.configured') : t('admin.billing.missing')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 rounded-xl border border-border/60 bg-muted/20 p-3">
|
||||
<StatusDot ok={health.billingEnabled} warn={!health.billingEnabled} />
|
||||
<div>
|
||||
<p className="text-xs font-semibold">{t('admin.billing.healthBillingFlag')}</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{health.billingEnabled ? t('admin.billing.enabled') : t('admin.billing.disabled')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 rounded-xl border border-border/60 bg-muted/20 p-3">
|
||||
<StatusDot ok />
|
||||
<div>
|
||||
<p className="text-xs font-semibold">{t('admin.billing.healthTrial')}</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t('admin.billing.trialDaysValue', { days: health.trialDays })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 pb-6">
|
||||
<h3 className="text-sm font-medium mb-3">{t('admin.billing.priceStatusTitle')}</h3>
|
||||
<div className="overflow-x-auto rounded-xl border border-border/60">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-muted/40 text-muted-foreground">
|
||||
<tr>
|
||||
<th className="text-start p-2.5 font-medium">{t('admin.billing.colKey')}</th>
|
||||
<th className="text-start p-2.5 font-medium">{t('admin.billing.colPriceId')}</th>
|
||||
<th className="text-start p-2.5 font-medium">{t('admin.billing.colSource')}</th>
|
||||
<th className="text-start p-2.5 font-medium">{t('admin.billing.colStripe')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(health.prices ?? []).map((row) => (
|
||||
<tr key={row.key} className="border-t border-border/50">
|
||||
<td className="p-2.5 font-mono">{t(`admin.billing.${row.key}`)}</td>
|
||||
<td className="p-2.5 font-mono truncate max-w-[180px]">
|
||||
{row.configured ? row.priceId : '—'}
|
||||
</td>
|
||||
<td className="p-2.5">{row.source}</td>
|
||||
<td className="p-2.5">
|
||||
{!row.configured ? (
|
||||
<span className="text-rose-600">{t('admin.billing.missing')}</span>
|
||||
) : row.error ? (
|
||||
<span className="text-rose-600" title={row.error}>{t('admin.billing.priceError')}</span>
|
||||
) : row.stripeAmount != null ? (
|
||||
<span className={cn(row.stripeActive === false && 'text-amber-600')}>
|
||||
{row.stripeAmount.toLocaleString('fr-FR', { minimumFractionDigits: 2 })} {row.stripeCurrency}
|
||||
{row.stripeActive === false ? ` (${t('admin.billing.inactive')})` : ''}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{t('admin.billing.notChecked')}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subscription stats */}
|
||||
{stats && (
|
||||
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-3 p-6 border-b border-border">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
||||
<Users className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold">{t('admin.billing.subsTitle')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('admin.billing.subsDescription')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div className="rounded-xl border border-border/60 bg-muted/20 p-4">
|
||||
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">{t('admin.billing.statPaid')}</p>
|
||||
<p className="text-2xl font-semibold tabular-nums mt-1">{stats.paidActive}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/60 bg-muted/20 p-4">
|
||||
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">{t('admin.billing.statTrialing')}</p>
|
||||
<p className="text-2xl font-semibold tabular-nums mt-1">{stats.trialing}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/60 bg-muted/20 p-4">
|
||||
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">{t('admin.billing.statPastDue')}</p>
|
||||
<p className="text-2xl font-semibold tabular-nums mt-1">{stats.pastDue}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/60 bg-muted/20 p-4">
|
||||
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">{t('admin.billing.statCanceling')}</p>
|
||||
<p className="text-2xl font-semibold tabular-nums mt-1">{stats.cancelAtPeriodEnd}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 pb-4 grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">{t('admin.billing.byTier')}</h3>
|
||||
<ul className="space-y-1.5 text-sm">
|
||||
{Object.entries(stats.byTier).map(([tier, count]) => (
|
||||
<li key={tier} className="flex justify-between border-b border-border/40 pb-1">
|
||||
<span>{tier}</span>
|
||||
<span className="tabular-nums text-muted-foreground">{count}</span>
|
||||
</li>
|
||||
))}
|
||||
<li className="flex justify-between text-xs text-muted-foreground pt-1">
|
||||
<span>{t('admin.billing.usersWithoutSub')}</span>
|
||||
<span className="tabular-nums">{stats.usersWithoutSub}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">{t('admin.billing.byStatus')}</h3>
|
||||
<ul className="space-y-1.5 text-sm">
|
||||
{Object.keys(stats.byStatus).length === 0 ? (
|
||||
<li className="text-muted-foreground text-xs">{t('admin.billing.noSubs')}</li>
|
||||
) : (
|
||||
Object.entries(stats.byStatus).map(([status, count]) => (
|
||||
<li key={status} className="flex justify-between border-b border-border/40 pb-1">
|
||||
<span>{status}</span>
|
||||
<span className="tabular-nums text-muted-foreground">{count}</span>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 pb-6">
|
||||
<h3 className="text-sm font-medium mb-2">{t('admin.billing.recentSubs')}</h3>
|
||||
{stats.recent.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('admin.billing.noSubs')}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border border-border/60">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-muted/40 text-muted-foreground">
|
||||
<tr>
|
||||
<th className="text-start p-2.5 font-medium">{t('admin.billing.colUser')}</th>
|
||||
<th className="text-start p-2.5 font-medium">{t('admin.billing.colTier')}</th>
|
||||
<th className="text-start p-2.5 font-medium">{t('admin.billing.colStatus')}</th>
|
||||
<th className="text-start p-2.5 font-medium">{t('admin.billing.colPeriod')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stats.recent.map((row) => (
|
||||
<tr key={`${row.email}-${row.updatedAt}`} className="border-t border-border/50">
|
||||
<td className="p-2.5 truncate max-w-[200px]">{row.email}</td>
|
||||
<td className="p-2.5">{row.tier}</td>
|
||||
<td className="p-2.5">
|
||||
{row.status}
|
||||
{row.cancelAtPeriodEnd ? ` · ${t('admin.billing.canceling')}` : ''}
|
||||
{!row.hasStripeSub ? ` · ${t('admin.billing.manualTier')}` : ''}
|
||||
</td>
|
||||
<td className="p-2.5 text-muted-foreground">
|
||||
{row.trialEndsAt
|
||||
? t('admin.billing.trialUntil', { date: formatStableUtc(row.trialEndsAt) })
|
||||
: row.currentPeriodEnd
|
||||
? formatStableUtc(row.currentPeriodEnd)
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* How to test Stripe */}
|
||||
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowTestGuide((v) => !v)}
|
||||
className="w-full flex items-center gap-3 p-6 border-b border-border text-start hover:bg-muted/20 transition-colors"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
||||
<BookOpen className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="font-semibold">{t('admin.billing.testGuideTitle')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('admin.billing.testGuideDescription')}</p>
|
||||
</div>
|
||||
{showTestGuide ? <ChevronUp className="h-4 w-4 text-muted-foreground" /> : <ChevronDown className="h-4 w-4 text-muted-foreground" />}
|
||||
</button>
|
||||
{showTestGuide && (
|
||||
<div className="p-6 space-y-3 text-sm text-muted-foreground leading-relaxed">
|
||||
<ol className="list-decimal ps-5 space-y-2">
|
||||
<li>{t('admin.billing.testStep1')}</li>
|
||||
<li>{t('admin.billing.testStep2')}</li>
|
||||
<li>
|
||||
<code className="text-[11px] bg-muted px-1.5 py-0.5 rounded">stripe listen --forward-to localhost:3000/api/billing/webhook</code>
|
||||
{' — '}{t('admin.billing.testStep3')}
|
||||
</li>
|
||||
<li>{t('admin.billing.testStep4')}</li>
|
||||
<li>{t('admin.billing.testStep5')}</li>
|
||||
<li>{t('admin.billing.testStep6')}</li>
|
||||
</ol>
|
||||
<p className="text-xs border-t border-border/50 pt-3">
|
||||
{t('admin.billing.testCardHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-3 p-6 border-b border-border">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
||||
@@ -98,7 +406,7 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
|
||||
<p className="text-sm text-muted-foreground">{t('admin.billing.stripeConfigDescription')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleSaveBilling(new FormData(e.currentTarget)) }} className="p-6 space-y-4">
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleSaveBilling(new FormData(e.currentTarget)) }} className="p-6 space-y-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="BILLING_ENABLED"
|
||||
@@ -107,18 +415,52 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
|
||||
/>
|
||||
<Label htmlFor="BILLING_ENABLED">{t('admin.billing.enableBilling')}</Label>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{(['STRIPE_PRICE_PRO_MONTHLY', 'STRIPE_PRICE_PRO_ANNUAL', 'STRIPE_PRICE_BUSINESS_MONTHLY', 'STRIPE_PRICE_BUSINESS_ANNUAL'] as const).map((key) => (
|
||||
<div key={key} className="space-y-2">
|
||||
<Label htmlFor={key}>{t(`admin.billing.${key}`)}</Label>
|
||||
<Input
|
||||
id={key}
|
||||
name={key}
|
||||
defaultValue={initialData.billingConfig[key] ?? ''}
|
||||
placeholder="price_..."
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-3">{t('admin.billing.subscriptionPricesTitle')}</h3>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{SUBSCRIPTION_PRICE_KEYS.map((key) => {
|
||||
const meta = priceMap[key]
|
||||
return (
|
||||
<div key={key} className="space-y-2">
|
||||
<Label htmlFor={key}>{t(`admin.billing.${key}`)}</Label>
|
||||
<Input
|
||||
id={key}
|
||||
name={key}
|
||||
defaultValue={initialData.billingConfig[key] ?? ''}
|
||||
placeholder="price_..."
|
||||
/>
|
||||
{meta?.stripeAmount != null && (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Stripe: {meta.stripeAmount.toLocaleString('fr-FR', { minimumFractionDigits: 2 })} {meta.stripeCurrency}
|
||||
</p>
|
||||
)}
|
||||
{meta?.error && (
|
||||
<p className="text-[11px] text-rose-600">{meta.error}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-border/50 pt-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium">{t('admin.billing.packPricesTitle')}</h3>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-3">{t('admin.billing.packPricesDescription')}</p>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{PACK_PRICE_KEYS.map((key) => (
|
||||
<div key={key} className="space-y-2">
|
||||
<Label htmlFor={key}>{t(`admin.billing.${key}`)}</Label>
|
||||
<Input
|
||||
id={key}
|
||||
name={key}
|
||||
defaultValue={initialData.billingConfig[key] ?? ''}
|
||||
placeholder="price_..."
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.billing.secretsNote')}</p>
|
||||
<Button type="submit" disabled={isSavingBilling}>{t('admin.billing.saveConfig')}</Button>
|
||||
@@ -128,60 +470,60 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
|
||||
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-3 p-6 border-b border-border">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
||||
<Settings2 className="h-5 w-5" />
|
||||
<Coins className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold">{t('admin.billing.limitsTitle')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('admin.billing.limitsDescription')}</p>
|
||||
<h2 className="font-semibold">{t('admin.billing.creditsTitle')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('admin.billing.creditsDescription')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{initialData.tiers.map((tier) => (
|
||||
<button
|
||||
key={tier}
|
||||
type="button"
|
||||
onClick={() => setActiveTier(tier)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
|
||||
activeTier === tier
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-border text-muted-foreground hover:bg-muted'
|
||||
}`}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{(initialData.creditAllocations ?? []).map((row) => (
|
||||
<div
|
||||
key={row.tier}
|
||||
className="rounded-xl border border-border/60 bg-muted/30 p-4 space-y-1"
|
||||
>
|
||||
{tier}
|
||||
</button>
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{row.tier}
|
||||
</p>
|
||||
<p className="text-2xl font-semibold tabular-nums">
|
||||
{row.unlimited
|
||||
? t('admin.billing.modeUnlimited')
|
||||
: row.monthlyCredits?.toLocaleString('fr-FR')}
|
||||
</p>
|
||||
{!row.unlimited && (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t('admin.billing.creditsPerMonth')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-2 pr-4">{t('admin.billing.feature')}</th>
|
||||
<th className="py-2 pr-4">{t('admin.billing.mode')}</th>
|
||||
<th className="py-2 pr-4">{t('admin.billing.monthlyLimit')}</th>
|
||||
<th className="py-2">{t('admin.billing.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{initialData.features.map((feature) => {
|
||||
const row = getEntitlement(initialData.entitlements, activeTier, feature)
|
||||
const mode = row?.mode ?? 'unavailable'
|
||||
const cellKey = `${activeTier}:${feature}`
|
||||
return (
|
||||
<EntitlementRowEditor
|
||||
key={`${activeTier}:${feature}`}
|
||||
feature={feature}
|
||||
mode={mode}
|
||||
limitValue={row?.limitValue ?? null}
|
||||
isSaving={savingCell === cellKey}
|
||||
onSave={handleEntitlementChange}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{(initialData.creditPacks?.length ?? 0) > 0 && (
|
||||
<div className="border-t border-border/40 pt-3">
|
||||
<p className="text-xs font-medium mb-2">{t('admin.billing.packsCatalogTitle')}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(initialData.creditPacks ?? []).map((pack) => (
|
||||
<span
|
||||
key={pack.id}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border/60 bg-muted/20 px-3 py-1.5 text-xs"
|
||||
>
|
||||
<span className="font-semibold uppercase">{pack.id}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{pack.credits.toLocaleString('fr-FR')} cr. · {pack.defaultDisplay}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground leading-relaxed border-t border-border/40 pt-3">
|
||||
{t('admin.billing.creditsCostsNote')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('admin.billing.creditsResetHint')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -195,7 +537,9 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('admin.billing.usagePeriod', { period: initialData.usageOverview.period })}
|
||||
{initialData.usageOverview.lastSyncedAt
|
||||
? ` · ${t('admin.billing.lastSync', { date: new Date(initialData.usageOverview.lastSyncedAt).toLocaleString() })}`
|
||||
? ` · ${t('admin.billing.lastSync', {
|
||||
date: formatStableUtc(initialData.usageOverview.lastSyncedAt),
|
||||
})}`
|
||||
: ` · ${t('admin.billing.notSynced')}`}
|
||||
</p>
|
||||
</div>
|
||||
@@ -236,68 +580,3 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EntitlementRowEditor({
|
||||
feature,
|
||||
mode: initialMode,
|
||||
limitValue: initialLimit,
|
||||
isSaving,
|
||||
onSave,
|
||||
t,
|
||||
}: {
|
||||
feature: string
|
||||
mode: 'unavailable' | 'unlimited' | 'limited'
|
||||
limitValue: number | null
|
||||
isSaving: boolean
|
||||
onSave: (feature: string, mode: 'unavailable' | 'unlimited' | 'limited', limit?: number) => Promise<void>
|
||||
t: (key: string, params?: Record<string, string | number>) => string
|
||||
}) {
|
||||
const [mode, setMode] = useState(initialMode)
|
||||
const [limit, setLimit] = useState(initialLimit != null ? String(initialLimit) : '50')
|
||||
|
||||
useEffect(() => {
|
||||
setMode(initialMode)
|
||||
setLimit(initialLimit != null ? String(initialLimit) : '50')
|
||||
}, [initialMode, initialLimit])
|
||||
|
||||
return (
|
||||
<tr className="border-b border-border/30">
|
||||
<td className="py-3 pr-4 font-mono text-xs">{feature}</td>
|
||||
<td className="py-3 pr-4">
|
||||
<select
|
||||
value={mode}
|
||||
onChange={(e) => setMode(e.target.value as typeof mode)}
|
||||
className="h-9 rounded-md border border-input bg-background px-2 text-xs"
|
||||
>
|
||||
<option value="unavailable">{t('admin.billing.modeUnavailable')}</option>
|
||||
<option value="limited">{t('admin.billing.modeLimited')}</option>
|
||||
<option value="unlimited">{t('admin.billing.modeUnlimited')}</option>
|
||||
</select>
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
{mode === 'limited' ? (
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={limit}
|
||||
onChange={(e) => setLimit(e.target.value)}
|
||||
className="h-9 w-24 text-xs"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isSaving}
|
||||
onClick={() => onSave(feature, mode, mode === 'limited' ? parseInt(limit, 10) : undefined)}
|
||||
>
|
||||
{isSaving ? '…' : t('admin.billing.saveLimit')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,58 +1,184 @@
|
||||
import { getUsers } from '@/app/actions/admin'
|
||||
import { AdminMetrics } from '@/components/admin-metrics'
|
||||
import { Users, Activity, Database, Zap } from 'lucide-react'
|
||||
import { Users, Activity, Database, Zap, CreditCard, Bot } from 'lucide-react'
|
||||
import prisma from '@/lib/prisma'
|
||||
import Link from 'next/link'
|
||||
|
||||
async function getAdminDashboardStats() {
|
||||
const now = new Date()
|
||||
const dayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
|
||||
const [
|
||||
userCount,
|
||||
noteCount,
|
||||
notebookCount,
|
||||
agentsEnabled,
|
||||
agentRunsToday,
|
||||
paidSubs,
|
||||
recentUsers,
|
||||
] = await Promise.all([
|
||||
prisma.user.count().catch(() => 0),
|
||||
prisma.note.count({ where: { trashedAt: null } }).catch(() => 0),
|
||||
prisma.notebook.count().catch(() => 0),
|
||||
prisma.agent.count({ where: { isEnabled: true } }).catch(() => 0),
|
||||
prisma.agentAction.count({ where: { createdAt: { gte: dayAgo } } }).catch(() => 0),
|
||||
prisma.subscription.count({
|
||||
where: { tier: { in: ['PRO', 'BUSINESS', 'ENTERPRISE'] }, status: 'ACTIVE' },
|
||||
}).catch(() => 0),
|
||||
prisma.user.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 8,
|
||||
select: { id: true, name: true, email: true, createdAt: true, role: true },
|
||||
}).catch(() => []),
|
||||
])
|
||||
|
||||
// AI usage this month
|
||||
let aiRequestsMonth = 0
|
||||
try {
|
||||
aiRequestsMonth = await prisma.usageLog.aggregate({
|
||||
where: { periodStart: { gte: monthStart } },
|
||||
_sum: { requestsCount: true },
|
||||
}).then((r) => r._sum.requestsCount ?? 0)
|
||||
} catch {
|
||||
try {
|
||||
aiRequestsMonth = await prisma.agentAction.count({
|
||||
where: { createdAt: { gte: monthStart } },
|
||||
})
|
||||
} catch {
|
||||
aiRequestsMonth = 0
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
userCount,
|
||||
noteCount,
|
||||
notebookCount,
|
||||
agentsEnabled,
|
||||
agentRunsToday,
|
||||
paidSubs,
|
||||
aiRequestsMonth,
|
||||
recentUsers,
|
||||
}
|
||||
}
|
||||
|
||||
export default async function AdminPage() {
|
||||
const users = await getUsers()
|
||||
const [users, stats] = await Promise.all([getUsers(), getAdminDashboardStats()])
|
||||
|
||||
// Mock metrics data - in a real app, these would come from analytics
|
||||
const metrics = [
|
||||
{
|
||||
title: 'Total Users',
|
||||
value: users.length,
|
||||
trend: { value: 12, isPositive: true },
|
||||
title: 'Utilisateurs',
|
||||
value: stats.userCount,
|
||||
trend: undefined as { value: number; isPositive: boolean } | undefined,
|
||||
icon: <Users className="h-5 w-5 text-primary dark:text-primary-foreground" />,
|
||||
},
|
||||
{
|
||||
title: 'Active Sessions',
|
||||
value: '24',
|
||||
trend: { value: 8, isPositive: true },
|
||||
icon: <Activity className="h-5 w-5 text-green-600 dark:text-green-400" />,
|
||||
},
|
||||
{
|
||||
title: 'Total Notes',
|
||||
value: '1,234',
|
||||
trend: { value: 24, isPositive: true },
|
||||
title: 'Notes',
|
||||
value: stats.noteCount.toLocaleString('fr-FR'),
|
||||
icon: <Database className="h-5 w-5 text-purple-600 dark:text-purple-400" />,
|
||||
},
|
||||
{
|
||||
title: 'AI Requests',
|
||||
value: '856',
|
||||
trend: { value: 5, isPositive: false },
|
||||
title: 'Abonnements payants',
|
||||
value: stats.paidSubs,
|
||||
icon: <CreditCard className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />,
|
||||
},
|
||||
{
|
||||
title: 'Agents actifs',
|
||||
value: stats.agentsEnabled,
|
||||
icon: <Bot className="h-5 w-5 text-blue-600 dark:text-blue-400" />,
|
||||
},
|
||||
{
|
||||
title: 'Exécutions agents (24h)',
|
||||
value: stats.agentRunsToday,
|
||||
icon: <Activity className="h-5 w-5 text-green-600 dark:text-green-400" />,
|
||||
},
|
||||
{
|
||||
title: 'Usage IA (mois)',
|
||||
value: stats.aiRequestsMonth.toLocaleString('fr-FR'),
|
||||
icon: <Zap className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
Dashboard
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Overview of your application metrics
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">
|
||||
Administration
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Vue d'ensemble en temps réel — {stats.notebookCount} carnets · {users.length} comptes listés
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
href="/admin/billing"
|
||||
className="text-xs font-medium px-3 py-2 rounded-lg border border-border bg-card hover:bg-muted transition-colors"
|
||||
>
|
||||
Facturation & quotas
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/ai"
|
||||
className="text-xs font-medium px-3 py-2 rounded-lg border border-border bg-card hover:bg-muted transition-colors"
|
||||
>
|
||||
Config IA
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/users"
|
||||
className="text-xs font-medium px-3 py-2 rounded-lg border border-border bg-card hover:bg-muted transition-colors"
|
||||
>
|
||||
Utilisateurs
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/settings"
|
||||
className="text-xs font-medium px-3 py-2 rounded-lg border border-border bg-card hover:bg-muted transition-colors"
|
||||
>
|
||||
Paramètres
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AdminMetrics metrics={metrics} />
|
||||
|
||||
<div className="bg-card rounded-lg shadow-sm overflow-hidden border border-border p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Recent Activity
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Recent activity will be displayed here.
|
||||
</p>
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="bg-card rounded-lg shadow-sm overflow-hidden border border-border p-6">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4">
|
||||
Derniers utilisateurs
|
||||
</h2>
|
||||
{stats.recentUsers.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">Aucun utilisateur.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{stats.recentUsers.map((u) => (
|
||||
<li key={u.id} className="py-2.5 flex items-center justify-between gap-3 text-sm">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-foreground truncate">{u.name || u.email}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{u.email}</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">{u.role}</span>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{u.createdAt ? new Date(u.createdAt).toLocaleDateString('fr-FR') : '—'}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-card rounded-lg shadow-sm overflow-hidden border border-border p-6">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4">
|
||||
Raccourcis ops
|
||||
</h2>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li>· Configurer les Price IDs Stripe dans <Link href="/admin/billing" className="text-primary underline">Facturation</Link></li>
|
||||
<li>· Vérifier les quotas IA par feature (BASIC / PRO / BUSINESS)</li>
|
||||
<li>· Tester les providers dans <Link href="/admin/ai" className="text-primary underline">IA</Link></li>
|
||||
<li>· CRON_SECRET requis pour agents planifiés (entrypoint Docker)</li>
|
||||
<li>· Notes indexées via embeddings + fragments (y compris notes courtes)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -139,11 +139,11 @@ const SUGGESTED_EMBEDDINGS: Record<string, string[]> = {
|
||||
zai: ['text-embedding-3-small', 'text-embedding-3-large'],
|
||||
}
|
||||
|
||||
type ModelPurpose = 'tags' | 'embeddings' | 'chat'
|
||||
type ModelPurpose = 'tags' | 'embeddings' | 'chat' | 'slides'
|
||||
|
||||
export function AdminSettingsForm({ config }: { config: Record<string, string> }) {
|
||||
const { t } = useLanguage()
|
||||
const [activeAiTab, setActiveAiTab] = useState<'tags' | 'embeddings' | 'chat'>('tags')
|
||||
const [activeAiTab, setActiveAiTab] = useState<'tags' | 'embeddings' | 'chat' | 'slides'>('tags')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [isTesting, setIsTesting] = useState(false)
|
||||
|
||||
@@ -168,11 +168,15 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
return PROVIDERS_WITHOUT_EMBEDDINGS.includes(v) ? 'ollama' : v
|
||||
})
|
||||
const [chatProvider, setChatProvider] = useState<AIProvider>((config.AI_PROVIDER_CHAT as AIProvider) || 'ollama')
|
||||
const [slidesProvider, setSlidesProvider] = useState<AIProvider>(
|
||||
(config.AI_PROVIDER_SLIDES as AIProvider) || (config.AI_PROVIDER_CHAT as AIProvider) || 'ollama',
|
||||
)
|
||||
|
||||
// Selected Models State
|
||||
const [selectedTagsModel, setSelectedTagsModel] = useState<string>(config.AI_MODEL_TAGS || '')
|
||||
const [selectedEmbeddingModel, setSelectedEmbeddingModel] = useState<string>(config.AI_MODEL_EMBEDDING || '')
|
||||
const [selectedChatModel, setSelectedChatModel] = useState<string>(config.AI_MODEL_CHAT || '')
|
||||
const [selectedSlidesModel, setSelectedSlidesModel] = useState<string>(config.AI_MODEL_SLIDES || '')
|
||||
|
||||
const [tagsFallbackProvider, setTagsFallbackProvider] = useState<string>(config.AI_PROVIDER_TAGS_FALLBACK || '')
|
||||
const [embedFallbackProvider, setEmbedFallbackProvider] = useState<string>(config.AI_PROVIDER_EMBEDDING_FALLBACK || '')
|
||||
@@ -186,11 +190,13 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
tags: [],
|
||||
embeddings: [],
|
||||
chat: [],
|
||||
slides: [],
|
||||
})
|
||||
const [isLoadingModels, setIsLoadingModels] = useState<Record<ModelPurpose, boolean>>({
|
||||
tags: false,
|
||||
embeddings: false,
|
||||
chat: false,
|
||||
slides: false,
|
||||
})
|
||||
|
||||
// Fetch models from local providers (Ollama, LM Studio) or cloud providers with /v1/models
|
||||
@@ -268,6 +274,16 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
const key = config[API_KEY_CONFIG[chatProvider]] || ''
|
||||
if (url && key) await fetchModels('chat', chatProvider, url, key)
|
||||
}
|
||||
|
||||
if (slidesProvider === 'ollama') {
|
||||
await fetchModels('slides', 'ollama', config.OLLAMA_BASE_URL_CHAT || config.OLLAMA_BASE_URL || 'http://localhost:11434')
|
||||
} else if (slidesProvider === 'lmstudio') {
|
||||
await fetchModels('slides', 'lmstudio', config.LMSTUDIO_BASE_URL || 'http://localhost:1234/v1')
|
||||
} else if (PROVIDER_META[slidesProvider]?.hasApiKey && slidesProvider !== 'anthropic_custom') {
|
||||
const url = DEFAULT_BASE_URLS[slidesProvider]
|
||||
const key = config[API_KEY_CONFIG[slidesProvider]] || ''
|
||||
if (url && key) await fetchModels('slides', slidesProvider, url, key)
|
||||
}
|
||||
}
|
||||
fetchInitial()
|
||||
|
||||
@@ -408,6 +424,32 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
}
|
||||
}
|
||||
|
||||
// Slides provider (dedicated model for deck generation — never auto-renamed)
|
||||
const slidesProv = formData.get('AI_PROVIDER_SLIDES') as string
|
||||
data.AI_PROVIDER_SLIDES = slidesProv?.trim() ?? ''
|
||||
const slidesModel = formData.get('AI_MODEL_SLIDES') as string
|
||||
data.AI_MODEL_SLIDES = slidesModel?.trim() ?? ''
|
||||
if (slidesProv) {
|
||||
if (slidesProv === 'ollama') {
|
||||
const ollamaUrl = formData.get('BASE_URL_ollama_slides') as string
|
||||
if (ollamaUrl) data.OLLAMA_BASE_URL_CHAT = ollamaUrl
|
||||
} else if (slidesProv === 'lmstudio') {
|
||||
const url = formData.get('BASE_URL_lmstudio_slides') as string
|
||||
if (url) data.LMSTUDIO_BASE_URL = url
|
||||
} else {
|
||||
const apiKeyConfigKey = API_KEY_CONFIG[slidesProv as AIProvider]
|
||||
if (apiKeyConfigKey) {
|
||||
const apiKey = formData.get(`API_KEY_${slidesProv}_slides`) as string
|
||||
if (apiKey) data[apiKeyConfigKey] = apiKey
|
||||
}
|
||||
const baseUrlConfigKey = BASE_URL_CONFIG[slidesProv as AIProvider]
|
||||
if (baseUrlConfigKey) {
|
||||
const baseUrl = formData.get(`BASE_URL_${slidesProv}_slides`) as string
|
||||
if (baseUrl) data[baseUrlConfigKey] = baseUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = await updateSystemConfig(data)
|
||||
setIsSaving(false)
|
||||
|
||||
@@ -740,6 +782,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
<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>
|
||||
<button type="button" onClick={() => setActiveAiTab('slides')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'slides' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>📑 {t('admin.ai.slidesTab')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
@@ -945,6 +988,44 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Slides Provider — dedicated model for deck generation */}
|
||||
<div className={`space-y-4 p-4 border border-border/50 rounded-lg bg-muted/50 ${activeAiTab === 'slides' ? 'block' : 'hidden'}`}>
|
||||
<h3 className="text-base font-semibold flex items-center gap-2">
|
||||
<span>📑</span> {t('admin.ai.slidesProvider')}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('admin.ai.slidesDescription')}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="AI_PROVIDER_SLIDES">{t('admin.ai.provider')}</Label>
|
||||
<select
|
||||
id="AI_PROVIDER_SLIDES"
|
||||
name="AI_PROVIDER_SLIDES"
|
||||
value={slidesProvider}
|
||||
onChange={(e) => {
|
||||
setSlidesProvider(e.target.value as AIProvider)
|
||||
setSelectedSlidesModel('')
|
||||
setDynamicModels((prev) => ({ ...prev, slides: [] }))
|
||||
}}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{providerOptions.map((opt) => (
|
||||
<option key={`slides-${opt.value}`} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="AI_MODEL_SLIDES" value={selectedSlidesModel} />
|
||||
{renderProviderConfig(slidesProvider, 'slides', selectedSlidesModel, setSelectedSlidesModel)}
|
||||
|
||||
<p className="text-xs text-muted-foreground border-t border-border/40 pt-3">
|
||||
{t('admin.ai.slidesQuotaNote')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 pb-6 flex flex-col sm:flex-row gap-3 sm:justify-between pt-6">
|
||||
<Button type="submit" disabled={isSaving}>{isSaving ? t('admin.ai.saving') : t('admin.ai.saveSettings')}</Button>
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { deleteUser, updateUserRole, updateUserSubscription } from '@/app/actions/admin'
|
||||
import { deleteUser, updateUserRole, updateUserSubscription, resetUserQuotas } from '@/app/actions/admin'
|
||||
import { toast } from 'sonner'
|
||||
import { Trash2, Shield, ShieldOff, Crown, ChevronDown } from 'lucide-react'
|
||||
import { Trash2, Shield, ShieldOff, Crown, ChevronDown, RotateCcw } from 'lucide-react'
|
||||
import { format } from 'date-fns'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
@@ -91,6 +91,7 @@ export function UserList({ initialUsers }: { initialUsers: any[] }) {
|
||||
const { t } = useLanguage()
|
||||
const [users, setUsers] = useState(initialUsers)
|
||||
const [openDropdown, setOpenDropdown] = useState<string | null>(null)
|
||||
const [resettingId, setResettingId] = useState<string | null>(null)
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm(t('admin.users.confirmDelete'))) return
|
||||
@@ -103,6 +104,27 @@ export function UserList({ initialUsers }: { initialUsers: any[] }) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleResetQuotas = async (user: { id: string; email?: string | null; name?: string | null }) => {
|
||||
const label = user.email || user.name || user.id
|
||||
if (!confirm(t('admin.users.confirmResetQuotas', { user: label }))) return
|
||||
setResettingId(user.id)
|
||||
try {
|
||||
const result = await resetUserQuotas(user.id)
|
||||
toast.success(
|
||||
t('admin.users.resetQuotasSuccess', {
|
||||
period: result.period,
|
||||
count: result.featuresReset.length,
|
||||
}),
|
||||
)
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t('admin.users.resetQuotasFailed'),
|
||||
)
|
||||
} finally {
|
||||
setResettingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRoleToggle = async (user: any) => {
|
||||
const newRole = user.role === 'ADMIN' ? 'USER' : 'ADMIN'
|
||||
try {
|
||||
@@ -163,6 +185,16 @@ export function UserList({ initialUsers }: { initialUsers: any[] }) {
|
||||
<td className="p-4 align-middle">{format(new Date(user.createdAt), 'PP')}</td>
|
||||
<td className="p-4 align-middle text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleResetQuotas(user)}
|
||||
disabled={resettingId === user.id}
|
||||
title={t('admin.users.resetQuotas')}
|
||||
className="text-amber-700 hover:text-amber-900 dark:text-amber-400 dark:hover:text-amber-200"
|
||||
>
|
||||
<RotateCcw className={`h-4 w-4 ${resettingId === user.id ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
92
memento-note/app/(auth)/check-email/page.tsx
Normal file
92
memento-note/app/(auth)/check-email/page.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { Mail, ArrowLeft, Sparkles } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { resendSignupVerification } from '@/app/actions/auth-verify'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
function CheckEmailContent() {
|
||||
const { t, language } = useLanguage()
|
||||
const searchParams = useSearchParams()
|
||||
const initialEmail = searchParams.get('email') ?? ''
|
||||
const [email, setEmail] = useState(initialEmail)
|
||||
const [sending, setSending] = useState(false)
|
||||
|
||||
const handleResend = async () => {
|
||||
const target = email.trim()
|
||||
if (!target) {
|
||||
toast.error(t('auth.verifyMissingEmail'))
|
||||
return
|
||||
}
|
||||
setSending(true)
|
||||
const result = await resendSignupVerification(target, language)
|
||||
setSending(false)
|
||||
if (result.success) {
|
||||
toast.success(t('auth.verifyResent'))
|
||||
} else {
|
||||
toast.error(t('auth.verifyResendFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-[var(--background)]/50 border border-[var(--border)] p-8 md:p-10 rounded-[48px] shadow-2xl">
|
||||
<div className="space-y-8 text-center">
|
||||
<div className="w-14 h-14 mx-auto rounded-2xl bg-[var(--color-brand-accent)]/10 flex items-center justify-center">
|
||||
<Mail size={28} className="text-[var(--color-brand-accent)]" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl md:text-3xl font-serif font-bold">
|
||||
{t('auth.checkEmailTitle')}
|
||||
</h1>
|
||||
<p className="text-[var(--muted-foreground)] text-sm font-light leading-relaxed">
|
||||
{initialEmail
|
||||
? t('auth.checkEmailDescription', { email: initialEmail })
|
||||
: t('auth.checkEmailDescriptionGeneric')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 text-start">
|
||||
<label htmlFor="email" className="text-[10px] uppercase tracking-widest font-bold text-[var(--muted-foreground)] px-4">
|
||||
{t('auth.email')}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder={t('auth.emailPlaceholder')}
|
||||
className="w-full bg-slate-50 dark:bg-white/5 border border-[var(--border)] rounded-2xl py-4 px-4 text-sm outline-none focus:border-[var(--color-brand-accent)] focus:ring-4 ring-[var(--color-brand-accent)]/5 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResend}
|
||||
disabled={sending}
|
||||
className="w-full bg-[var(--foreground)] text-[var(--background)] py-4 rounded-2xl font-bold uppercase tracking-[0.2em] text-[10px] flex items-center justify-center gap-3 transition-all hover:shadow-xl hover:shadow-black/10 active:scale-[0.98] disabled:opacity-50"
|
||||
>
|
||||
{sending ? <Sparkles size={16} className="animate-spin" /> : t('auth.resendVerification')}
|
||||
</button>
|
||||
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex items-center gap-2 text-xs text-[var(--muted-foreground)] hover:text-[var(--color-brand-accent)] transition-colors"
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
{t('auth.backToLogin')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CheckEmailPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="p-10 text-center text-sm text-muted-foreground">…</div>}>
|
||||
<CheckEmailContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -1,49 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { headers } from 'next/headers';
|
||||
import { detectUserLanguage, parseAcceptLanguage } from '@/lib/i18n/detect-user-language';
|
||||
import { loadTranslations } from '@/lib/i18n/load-translations';
|
||||
import { LanguageProvider } from '@/lib/i18n/LanguageProvider';
|
||||
import Link from 'next/link';
|
||||
import { Globe } from 'lucide-react';
|
||||
import { AuthShell } from '@/components/auth-shell';
|
||||
|
||||
export default function AuthLayout({
|
||||
export default async function AuthLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const headersList = await headers();
|
||||
const browserLang = parseAcceptLanguage(headersList.get('accept-language'));
|
||||
const initialLanguage = await detectUserLanguage(browserLang);
|
||||
const initialTranslations = await loadTranslations(initialLanguage);
|
||||
|
||||
return (
|
||||
<LanguageProvider>
|
||||
<div className="min-h-screen bg-[#FDFCFB] dark:bg-[#0D0D0D] flex flex-col relative overflow-hidden">
|
||||
<div className="absolute top-[-10%] right-[-10%] w-[50%] h-[50%] bg-[var(--color-brand-accent)]/5 blur-[120px] rounded-full pointer-events-none" />
|
||||
<div className="absolute bottom-[-10%] left-[-10%] w-[50%] h-[50%] bg-[#D4A373]/5 blur-[120px] rounded-full pointer-events-none" />
|
||||
|
||||
<header className="p-6 md:p-8 flex justify-between items-center relative z-10">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors group"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full border border-[var(--border)] flex items-center justify-center group-hover:border-[var(--color-brand-accent)] transition-colors">
|
||||
<Globe size={14} className="group-hover:rotate-12 transition-transform" />
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-[var(--foreground)] text-[var(--background)] rounded-xl flex items-center justify-center shadow-lg">
|
||||
<span className="font-serif font-bold text-xl">M</span>
|
||||
</div>
|
||||
<span className="font-serif text-xl font-medium tracking-tight">Memento</span>
|
||||
</Link>
|
||||
|
||||
<div className="w-8" />
|
||||
</header>
|
||||
|
||||
<main className="flex-1 flex items-center justify-center p-4 md:p-6 relative z-10">
|
||||
<div className="w-full max-w-md">
|
||||
{children}
|
||||
<p className="text-center mt-8 text-[9px] text-[var(--muted-foreground)] font-bold uppercase tracking-[0.3em] opacity-40 select-none">
|
||||
© 2025 Memento Labs
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<LanguageProvider initialLanguage={initialLanguage} initialTranslations={initialTranslations}>
|
||||
<AuthShell>{children}</AuthShell>
|
||||
</LanguageProvider>
|
||||
);
|
||||
}
|
||||
|
||||
97
memento-note/app/(auth)/verify-email/page.tsx
Normal file
97
memento-note/app/(auth)/verify-email/page.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { CheckCircle2, AlertCircle, Sparkles } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { confirmEmail } from '@/app/actions/auth-verify'
|
||||
|
||||
function VerifyEmailContent() {
|
||||
const { t } = useLanguage()
|
||||
const searchParams = useSearchParams()
|
||||
const token = searchParams.get('token')
|
||||
const [status, setStatus] = useState<'loading' | 'ok' | 'invalid' | 'expired'>('loading')
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setStatus('invalid')
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
const result = await confirmEmail(token)
|
||||
if (cancelled) return
|
||||
if (result.success) setStatus('ok')
|
||||
else setStatus(result.error === 'expired' ? 'expired' : 'invalid')
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [token])
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="bg-white dark:bg-[var(--background)]/50 border border-[var(--border)] p-8 md:p-10 rounded-[48px] shadow-2xl text-center space-y-4">
|
||||
<Sparkles size={28} className="mx-auto animate-spin text-[var(--color-brand-accent)]" />
|
||||
<p className="text-sm text-[var(--muted-foreground)]">{t('auth.verifyLoading')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'ok') {
|
||||
return (
|
||||
<div className="bg-white dark:bg-[var(--background)]/50 border border-[var(--border)] p-8 md:p-10 rounded-[48px] shadow-2xl text-center space-y-6">
|
||||
<div className="w-14 h-14 mx-auto rounded-2xl bg-emerald-500/10 flex items-center justify-center">
|
||||
<CheckCircle2 size={28} className="text-emerald-600" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-serif font-bold">{t('auth.verifySuccessTitle')}</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">{t('auth.verifySuccessDescription')}</p>
|
||||
</div>
|
||||
<Link href="/login?verified=1" className="block">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full bg-[var(--foreground)] text-[var(--background)] py-4 rounded-2xl font-bold uppercase tracking-[0.2em] text-[10px] transition-all hover:shadow-xl active:scale-[0.98]"
|
||||
>
|
||||
{t('auth.signIn')}
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-[var(--background)]/50 border border-[var(--border)] p-8 md:p-10 rounded-[48px] shadow-2xl text-center space-y-6">
|
||||
<div className="w-14 h-14 mx-auto rounded-2xl bg-red-500/10 flex items-center justify-center">
|
||||
<AlertCircle size={28} className="text-red-500" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-serif font-bold">
|
||||
{status === 'expired' ? t('auth.verifyExpiredTitle') : t('auth.verifyInvalidTitle')}
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{status === 'expired'
|
||||
? t('auth.verifyExpiredDescription')
|
||||
: t('auth.verifyInvalidDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/check-email" className="block">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full bg-[var(--foreground)] text-[var(--background)] py-4 rounded-2xl font-bold uppercase tracking-[0.2em] text-[10px] transition-all hover:shadow-xl active:scale-[0.98]"
|
||||
>
|
||||
{t('auth.resendVerification')}
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function VerifyEmailPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="p-10 text-center text-sm text-muted-foreground">…</div>}>
|
||||
<VerifyEmailContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Metadata } from 'next'
|
||||
import { auth } from '@/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { ChatContainer } from '@/components/chat/chat-container'
|
||||
import { getConversations } from '@/app/actions/chat-actions'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Chat IA | Memento',
|
||||
description: 'Discutez avec vos notes et vos agents IA',
|
||||
}
|
||||
|
||||
export default async function ChatPage() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) redirect('/login')
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
// Fetch initial data
|
||||
const [conversations, notebooks, config] = await Promise.all([
|
||||
getConversations(),
|
||||
prisma.notebook.findMany({
|
||||
where: { userId },
|
||||
orderBy: { order: 'asc' }
|
||||
}),
|
||||
getSystemConfig(),
|
||||
])
|
||||
|
||||
// Check if web search tools are configured
|
||||
const webSearchAvailable = !!(
|
||||
config.WEB_SEARCH_PROVIDER || config.BRAVE_SEARCH_API_KEY || config.SEARXNG_URL || config.JINA_API_KEY
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full bg-white dark:bg-[#1a1c22]">
|
||||
<ChatContainer
|
||||
initialConversations={conversations}
|
||||
notebooks={notebooks}
|
||||
webSearchAvailable={webSearchAvailable}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
memento-note/app/(main)/dev/interactive-page/page.tsx
Normal file
23
memento-note/app/(main)/dev/interactive-page/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
import { PageView } from '@/components/interactive-page/page-view'
|
||||
import { validateInteractivePage } from '@/lib/interactive-page'
|
||||
import thermoFixture from '@/lib/interactive-page/fixtures/thermo-page.json'
|
||||
|
||||
/**
|
||||
* Dev preview of PageView + thermo fixture (spec §8.3).
|
||||
* Only available outside production.
|
||||
*/
|
||||
export default function InteractivePagePreviewPage() {
|
||||
if (process.env.NODE_ENV === 'production') notFound()
|
||||
|
||||
const parsed = validateInteractivePage(thermoFixture)
|
||||
if (!parsed.ok) {
|
||||
return (
|
||||
<div className="p-8 text-sm text-destructive">
|
||||
Fixture invalide : {parsed.issues[0]?.message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <PageView page={parsed.page} demoMode="interactive" />
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
// D3 uses browser APIs — must be loaded client-side only
|
||||
const NoteGraphView = dynamic(
|
||||
() => import('@/components/note-graph-view').then(m => m.NoteGraphView),
|
||||
{ ssr: false }
|
||||
)
|
||||
|
||||
export default function GraphPage() {
|
||||
return (
|
||||
<div className="h-screen overflow-hidden">
|
||||
<NoteGraphView />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { motion, AnimatePresence, useReducedMotion } from 'motion/react'
|
||||
import {
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Database,
|
||||
ArrowRight,
|
||||
Menu,
|
||||
Network,
|
||||
List,
|
||||
@@ -28,7 +27,6 @@ import {
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import Link from 'next/link'
|
||||
import { useNotePeek, NotePeekPanel } from '@/components/note-peek'
|
||||
import { createNote } from '@/app/actions/notes'
|
||||
import { emitNoteChange } from '@/lib/note-change-sync'
|
||||
@@ -86,6 +84,7 @@ const COLOR_PALETTE = ['#F87171', '#60A5FA', '#34D399', '#FBBF24', '#A78BFA', '#
|
||||
|
||||
export default function InsightsPage() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { t, language: locale } = useLanguage()
|
||||
|
||||
const formatSyncTime = useCallback(
|
||||
@@ -104,7 +103,7 @@ export default function InsightsPage() {
|
||||
const [isStale, setIsStale] = useState(false)
|
||||
const [selectedClusterId, setSelectedClusterId] = useState<string | null>(null)
|
||||
const [viewMode, setViewMode] = useState<'graph' | 'dashboard'>('dashboard')
|
||||
const [graphMode, setGraphMode] = useState<'visual' | 'list'>('visual')
|
||||
const [graphMode, setGraphMode] = useState<'visual' | 'list'>('list')
|
||||
const [listFilter, setListFilter] = useState('')
|
||||
const [listSort, setListSort] = useState<'size' | 'alpha' | 'bridges'>('size')
|
||||
/** Un seul cluster déplié à la fois (null = tous repliés) */
|
||||
@@ -131,6 +130,15 @@ export default function InsightsPage() {
|
||||
loadInitialData()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const clusterParam = searchParams.get('cluster')
|
||||
if (!clusterParam || clusters.length === 0) return
|
||||
const match = clusters.find(
|
||||
c => c.id === clusterParam || String(c.clusterId) === clusterParam,
|
||||
)
|
||||
if (match) setSelectedClusterId(match.id)
|
||||
}, [searchParams, clusters])
|
||||
|
||||
// ─── Données calculées ───────────────────────────────────────────────────────
|
||||
|
||||
const selectedCluster = useMemo(
|
||||
@@ -437,7 +445,7 @@ export default function InsightsPage() {
|
||||
<button
|
||||
className="p-2 -ms-1 text-foreground hover:bg-foreground/5 rounded-lg transition-colors shrink-0 cursor-pointer focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none"
|
||||
onClick={() => window.dispatchEvent(new CustomEvent('toggle-insights-sidebar'))}
|
||||
aria-label="Toggle sidebar"
|
||||
aria-label={t('insightsView.toggleMenu')}
|
||||
>
|
||||
<Menu size={22} />
|
||||
</button>
|
||||
@@ -455,9 +463,6 @@ export default function InsightsPage() {
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5 mt-1.5 text-[10px] text-concrete">
|
||||
<span>{t('insightsView.semanticGraphLegend')}</span>
|
||||
<Link href="/graph" className="inline-flex items-center gap-0.5 text-ochre hover:underline font-medium">
|
||||
{t('insightsView.openGraphMap')} <ArrowRight size={9} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -712,7 +717,7 @@ export default function InsightsPage() {
|
||||
style={{ backgroundColor: cluster.color }}
|
||||
aria-hidden
|
||||
/>
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-ink dark:text-dark-ink truncate flex-1 min-w-0">
|
||||
<h3 className="min-w-0 flex-1 text-[13px] font-semibold leading-snug text-ink dark:text-dark-ink">
|
||||
{name}
|
||||
</h3>
|
||||
<span className="text-[9px] text-concrete shrink-0 tabular-nums">
|
||||
|
||||
@@ -35,13 +35,13 @@ export default async function MainLayout({
|
||||
initialTranslations={initialTranslations}
|
||||
initialAiProcessingConsent={aiSettings?.aiProcessingConsent === true}
|
||||
>
|
||||
{/* No top-bar header — sidebar-only navigation (architectural-grid design) */}
|
||||
<div className="flex h-screen overflow-hidden bg-memento-desk dark:bg-background">
|
||||
<Suspense fallback={<div className="hidden w-80 md:w-[26rem] 2xl:w-[30rem] shrink-0 md:block h-full self-stretch" />}>
|
||||
{/* Fond via --background (suit .dark) — évite le flash beige/clair au changement de route */}
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
<Suspense fallback={<div className="hidden w-80 md:w-[26rem] 2xl:w-[30rem] shrink-0 md:block h-full self-stretch bg-sidebar" />}>
|
||||
<Sidebar user={session?.user} />
|
||||
</Suspense>
|
||||
|
||||
<main className="flex min-h-0 flex-1 flex-col overflow-y-auto scroll-smooth bg-memento-paper dark:bg-background" id="main-content">
|
||||
<main className="flex min-h-0 flex-1 flex-col overflow-y-auto scroll-smooth bg-background" id="main-content">
|
||||
<a href="#main-content" className="sr-only focus:not-sr-only focus:absolute focus:z-[9999] focus:top-2 focus:left-2 focus:bg-brand-accent focus:text-white focus:px-4 focus:py-2 focus:rounded-lg focus:text-sm focus:font-bold">
|
||||
Skip to content
|
||||
</a>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { updateUserSettings } from '@/app/actions/user-settings'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { toast } from 'sonner'
|
||||
import { Palette, Type, LayoutGrid, Maximize } from 'lucide-react'
|
||||
import { applyDocumentTheme, normalizeThemeId, type ThemeId } from '@/lib/apply-document-theme'
|
||||
import { persistThemePreference, normalizeThemeId, type ThemeId } from '@/lib/apply-document-theme'
|
||||
import {
|
||||
NOTES_LAYOUT_STORAGE_KEY,
|
||||
parseNotesLayoutMode,
|
||||
@@ -65,8 +65,7 @@ export function AppearanceSettingsClient({
|
||||
const handleThemeChange = async (value: string) => {
|
||||
const next = normalizeThemeId(value)
|
||||
setTheme(next)
|
||||
localStorage.setItem('theme-preference', next)
|
||||
applyDocumentTheme(next)
|
||||
persistThemePreference(next)
|
||||
await updateUserSettings({ theme: next })
|
||||
toast.success(t('settings.settingsSaved'))
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { BillingPlans } from '@/components/settings/billing-plans';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Billing',
|
||||
title: 'Facturation — Memento',
|
||||
};
|
||||
|
||||
function Fallback() {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { updateAISettings } from '@/app/actions/ai-settings'
|
||||
import { toast } from 'sonner'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { Globe, Bell, Shield, Brain, HelpCircle } from 'lucide-react'
|
||||
import { motion } from 'motion/react'
|
||||
import { openCookiePreferences } from '@/lib/consent/cookie-consent'
|
||||
@@ -27,6 +27,20 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
|
||||
const { t, setLanguage: setContextLanguage } = useLanguage()
|
||||
const { hasAiConsent, revokeConsent, requestAiConsent } = useAiConsent()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const aiConsentSectionRef = useRef<HTMLDivElement>(null)
|
||||
const highlightAiConsent = searchParams?.get('highlight') === 'aiConsent'
|
||||
const [showConsentHighlight, setShowConsentHighlight] = useState(highlightAiConsent)
|
||||
|
||||
useEffect(() => {
|
||||
if (highlightAiConsent && aiConsentSectionRef.current) {
|
||||
setShowConsentHighlight(true)
|
||||
aiConsentSectionRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
const timer = setTimeout(() => setShowConsentHighlight(false), 4000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [highlightAiConsent])
|
||||
|
||||
const [language, setLanguage] = useState(initialSettings.preferredLanguage || 'auto')
|
||||
const [emailNotifications, setEmailNotifications] = useState(initialSettings.emailNotifications ?? false)
|
||||
const [desktopNotifications, setDesktopNotifications] = useState(initialSettings.desktopNotifications ?? false)
|
||||
@@ -76,6 +90,12 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
|
||||
{t('generalSettings.description')}
|
||||
</h3>
|
||||
|
||||
{highlightAiConsent && (
|
||||
<div className="rounded-xl border border-brand-accent/30 bg-brand-accent/10 p-4 text-sm font-medium text-ink">
|
||||
{t('consent.ai.settingsBanner')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-xl p-8 space-y-6">
|
||||
<div className="flex items-center gap-5">
|
||||
@@ -202,7 +222,15 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-xl p-8 space-y-6 md:col-span-2">
|
||||
<div
|
||||
ref={aiConsentSectionRef}
|
||||
className={cn(
|
||||
'bg-white/40 dark:bg-white/5 border rounded-xl p-8 space-y-6 md:col-span-2 transition-all duration-500',
|
||||
showConsentHighlight
|
||||
? 'border-brand-accent ring-2 ring-brand-accent/40 shadow-lg shadow-brand-accent/10'
|
||||
: 'border-border'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="p-3 bg-paper dark:bg-white/10 rounded-2xl text-concrete border border-border">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Menu } from 'lucide-react'
|
||||
import { SettingsNav } from '@/components/settings'
|
||||
import { SettingsDocumentTitle } from '@/components/settings/settings-document-title'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export default function SettingsLayout({
|
||||
@@ -11,21 +12,22 @@ export default function SettingsLayout({
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-[#F2F0E9] dark:bg-dark-paper">
|
||||
<header className="px-4 sm:px-8 md:px-12 pt-8 sm:pt-14 md:pt-20 pb-6 sm:pb-10 md:pb-16 space-y-6 sm:space-y-10 md:space-y-12 shrink-0">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex flex-col h-full bg-background">
|
||||
<SettingsDocumentTitle />
|
||||
<header className="px-4 sm:px-8 md:px-12 pt-6 sm:pt-8 md:pt-10 pb-4 sm:pb-6 shrink-0">
|
||||
<div className="flex items-start gap-3 mb-6">
|
||||
<button
|
||||
className="md:hidden p-2 -ms-1 text-ink/70 hover:bg-ink/5 rounded-lg transition-colors shrink-0 mt-1"
|
||||
className="md:hidden p-2 -ms-1 text-ink dark:text-zinc-200 hover:bg-ink/5 dark:hover:bg-white/10 rounded-lg transition-colors shrink-0 mt-1"
|
||||
onClick={() => window.dispatchEvent(new CustomEvent('open-mobile-sidebar'))}
|
||||
aria-label={t('settings.title')}
|
||||
>
|
||||
<Menu size={22} />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-3xl sm:text-5xl md:text-[64px] font-serif text-ink tracking-tight leading-none italic font-medium">
|
||||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-serif text-ink dark:text-zinc-50 tracking-tight leading-tight font-medium">
|
||||
{t('settings.title')}
|
||||
</h1>
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.4em] text-concrete opacity-60 mt-4">
|
||||
<p className="text-sm text-concrete dark:text-zinc-400 mt-1.5">
|
||||
{t('settings.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@ export function McpSettingsHeader() {
|
||||
{ text: t('mcpSettings.helpBox.step2') },
|
||||
{ text: t('mcpSettings.helpBox.step3') },
|
||||
{ text: t('mcpSettings.helpBox.step4'), link: { label: t('mcpSettings.helpBox.step4Link'), href: 'https://modelcontextprotocol.io/docs' } },
|
||||
{ icon: '⚡', text: t('mcpSettings.helpBox.step5') },
|
||||
{ text: t('mcpSettings.helpBox.step5') },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useLanguage } from '@/lib/i18n';
|
||||
|
||||
export default function SupportPage() {
|
||||
const { t } = useLanguage();
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-10 max-w-4xl">
|
||||
<div className="text-center mb-10">
|
||||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold mb-4">
|
||||
{t('support.title')}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
{t('support.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 mb-10">
|
||||
<Card className="border-2 border-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="text-2xl">☕</span>
|
||||
{t('support.buyMeACoffee')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="mb-4">
|
||||
{t('support.donationDescription')}
|
||||
</p>
|
||||
<Button asChild className="w-full">
|
||||
<a href="https://ko-fi.com/yourusername" target="_blank" rel="noopener noreferrer">
|
||||
{t('support.donateOnKofi')}
|
||||
</a>
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{t('support.kofiDescription')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-2 border-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="text-2xl">💚</span>
|
||||
{t('support.sponsorOnGithub')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="mb-4">
|
||||
{t('support.sponsorDescription')}
|
||||
</p>
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<a href="https://github.com/sponsors/yourusername" target="_blank" rel="noopener noreferrer">
|
||||
{t('support.sponsorOnGithub')}
|
||||
</a>
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{t('support.githubDescription')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="mb-10">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('support.howSupportHelps')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">💰 {t('support.directImpact')}</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li>☕ Keeps me fueled with coffee</li>
|
||||
<li>🐛 Covers hosting and server costs</li>
|
||||
<li>✨ Funds development of new features</li>
|
||||
<li>📚 Improves documentation</li>
|
||||
<li>🌍 Keeps Memento 100% open-source</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">🎁 {t('support.sponsorPerks')}</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li>🥉 $5/month - Bronze: Name in supporters list</li>
|
||||
<li>🥈 $15/month - Silver: Priority feature requests</li>
|
||||
<li>🥇 $50/month - Gold: Logo in footer, priority support</li>
|
||||
<li>💎 $100/month - Platinum: Custom features, consulting</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>💡 {t('support.transparency')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm mb-4">
|
||||
{t('support.transparencyDescription')}
|
||||
</p>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>{t('support.hostingServers')}</span>
|
||||
<span className="font-mono">~$20/month</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t('support.domainSSL')}</span>
|
||||
<span className="font-mono">~$15/year</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t('support.aiApiCosts')}</span>
|
||||
<span className="font-mono">~$30/month</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-t pt-2">
|
||||
<span className="font-semibold">{t('support.totalExpenses')}</span>
|
||||
<span className="font-mono font-semibold">~$50/month</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-4">
|
||||
Any amount beyond these costs goes directly into improving Memento
|
||||
and funding new features. Thank you for your support! 💚
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="mt-10 text-center">
|
||||
<h2 className="text-2xl font-bold mb-4">{t('support.otherWaysTitle')}</h2>
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
<Button variant="outline" asChild>
|
||||
<a href="https://github.com/yourusername/memento" target="_blank" rel="noopener noreferrer">
|
||||
⭐ {t('support.starGithub')}
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<a href="https://github.com/yourusername/memento/issues" target="_blank" rel="noopener noreferrer">
|
||||
🐛 {t('support.reportBug')}
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<a href="https://github.com/yourusername/memento" target="_blank" rel="noopener noreferrer">
|
||||
📝 {t('support.contributeCode')}
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<a href="https://twitter.com/intent/tweet?text=Check%20out%20Memento%20-%20a%20great%20open-source%20note-taking%20app!%20https://github.com/yourusername/memento" target="_blank" rel="noopener noreferrer">
|
||||
🐦 {t('support.shareTwitter')}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
|
||||
/**
|
||||
* Plus d’animation d’entrée (opacity / translate) :
|
||||
* ça laissait voir le fond clair une fraction de seconde en mode sombre.
|
||||
* Conteneur neutre uniquement.
|
||||
*/
|
||||
export default function MainTemplate({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="flex-1 flex flex-col min-h-0"
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-background text-foreground">
|
||||
{children}
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,12 @@ import { detectUserLanguage, parseAcceptLanguage } from '@/lib/i18n/detect-user-
|
||||
import { loadTranslations } from '@/lib/i18n/load-translations'
|
||||
import { PublicProviders } from '@/components/public-providers'
|
||||
|
||||
/**
|
||||
* Le body racine est en hauteur d’écran avec débordement masqué
|
||||
* (pour l’application connectée). Les pages publiques (accueil, tarifs,
|
||||
* notes publiées) doivent pouvoir défiler : on crée un conteneur de
|
||||
* défilement qui couvre tout l’écran, indépendant du body.
|
||||
*/
|
||||
export default async function PublicLayout({ children }: { children: React.ReactNode }) {
|
||||
const headersList = await headers()
|
||||
const browserLang = parseAcceptLanguage(headersList.get('accept-language'))
|
||||
@@ -11,7 +17,13 @@ export default async function PublicLayout({ children }: { children: React.React
|
||||
|
||||
return (
|
||||
<PublicProviders initialLanguage={initialLanguage} initialTranslations={initialTranslations}>
|
||||
{children}
|
||||
<div
|
||||
data-public-scroll-root
|
||||
className="fixed inset-0 z-[1] h-[100dvh] w-full overflow-y-auto overflow-x-hidden overscroll-y-contain"
|
||||
style={{ WebkitOverflowScrolling: 'touch', touchAction: 'pan-y' }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</PublicProviders>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,89 +1,160 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
import { Shield } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { Check } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { SUBSCRIPTION_TRIAL_DAYS } from '@/lib/billing/trial-constants'
|
||||
|
||||
export default function PricingPage() {
|
||||
const { t } = useLanguage()
|
||||
const router = useRouter()
|
||||
const [billingInterval, setBillingInterval] = useState<'monthly' | 'annual'>('monthly')
|
||||
const trialDays = SUBSCRIPTION_TRIAL_DAYS
|
||||
const { data: byokCatalog } = useQuery({
|
||||
queryKey: ['public', 'byok-catalog'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/public/byok-catalog')
|
||||
if (!res.ok) throw new Error('catalog')
|
||||
return res.json() as Promise<{ providers: { id: string }[] }>
|
||||
},
|
||||
staleTime: 60_000,
|
||||
})
|
||||
const providerCount = byokCatalog?.providers.length || '…'
|
||||
|
||||
const PLANS = [
|
||||
{ key: 'basic', popular: false, price: t('landing.pricing.basicPrice'), period: '' },
|
||||
{ key: 'pro', popular: true, price: billingInterval === 'monthly' ? '9,90€' : '7,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual') },
|
||||
{ key: 'business', popular: false, price: billingInterval === 'monthly' ? '29,90€' : '23,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual') },
|
||||
{ key: 'enterprise', popular: false, price: billingInterval === 'monthly' ? '49,90€' : '39,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perUser') : t('landing.pricing.perUserAnnual') },
|
||||
const plans = [
|
||||
{ key: 'basic', popular: false, hasTrial: false, price: t('landing.pricing.basicPrice'), period: '' },
|
||||
{
|
||||
key: 'pro',
|
||||
popular: true,
|
||||
hasTrial: true,
|
||||
price: billingInterval === 'monthly' ? t('landing.pricing.proMonthly') : t('landing.pricing.proAnnualMonthly'),
|
||||
period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual'),
|
||||
},
|
||||
{
|
||||
key: 'business',
|
||||
popular: false,
|
||||
hasTrial: true,
|
||||
price: billingInterval === 'monthly' ? t('landing.pricing.businessMonthly') : t('landing.pricing.businessAnnualMonthly'),
|
||||
period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual'),
|
||||
},
|
||||
{
|
||||
key: 'enterprise',
|
||||
popular: false,
|
||||
hasTrial: false,
|
||||
price: t('landing.pricing.enterprisePrice'),
|
||||
period: '',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-paper">
|
||||
<section className="py-32 px-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.3em] text-ochre mb-4 block">{t('landing.pricing.label')}</span>
|
||||
<h2 className="text-4xl md:text-5xl font-serif tracking-tight text-ink mb-6">{t('landing.pricing.title')}</h2>
|
||||
<p className="text-concrete font-light max-w-xl mx-auto mb-12">{t('landing.pricing.desc')}</p>
|
||||
<main className="min-h-screen bg-[#0B0A09] text-[#F4F1EA] font-[family-name:var(--font-manrope)] selection:bg-[#D4A373]/40 selection:text-white">
|
||||
<nav className="sticky top-0 z-[100] px-5 sm:px-8 py-4 flex items-center justify-between bg-[#0B0A09]/70 backdrop-blur-xl border-b border-white/[0.06]">
|
||||
<Link href="/" className="flex items-center gap-2.5 group">
|
||||
<div className="w-9 h-9 bg-[#F4F1EA] text-[#0B0A09] flex items-center justify-center rounded-lg">
|
||||
<span className="font-serif text-xl font-bold leading-none">M</span>
|
||||
</div>
|
||||
<span className="font-serif text-xl font-medium tracking-tight">Memento</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<Link href="/login" className="text-[13px] text-white/75 hover:text-white transition-colors px-2">
|
||||
{t('landing.nav.login')}
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full bg-[#F4F1EA] text-[#0B0A09] text-[13px] font-semibold hover:bg-white transition-colors"
|
||||
>
|
||||
{t('landing.nav.cta')}
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center justify-center gap-10 mb-8">
|
||||
<button onClick={() => setBillingInterval('monthly')} className={`group relative py-2 px-1 transition-all ${billingInterval === 'monthly' ? 'text-ink' : 'text-concrete/40 hover:text-concrete'}`}>
|
||||
<span className="text-xs font-black uppercase tracking-[0.2em]">{t('landing.pricing.monthly')}</span>
|
||||
{billingInterval === 'monthly' && (
|
||||
<motion.div layoutId="interval-active-pricing" className="absolute -inset-x-1 -inset-y-0.5 border border-ochre/60" transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }} />
|
||||
)}
|
||||
<section className="px-5 sm:px-8 py-28">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.3em] text-[#D4A373] mb-4 block">
|
||||
{t('landing.pricing.label')}
|
||||
</span>
|
||||
<h1 className="font-serif text-3xl sm:text-5xl tracking-tight mb-4">{t('landing.pricing.title')}</h1>
|
||||
<p className="text-white/70 mb-8">{t('landing.pricing.desc')}</p>
|
||||
<div className="inline-flex p-1 rounded-full border border-white/10 bg-white/[0.03]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBillingInterval('monthly')}
|
||||
className={`px-5 py-2 rounded-full text-[12px] font-semibold transition-all ${billingInterval === 'monthly' ? 'bg-[#F4F1EA] text-[#0B0A09]' : 'text-white/70'}`}
|
||||
>
|
||||
{t('landing.pricing.monthly')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBillingInterval('annual')}
|
||||
className={`px-5 py-2 rounded-full text-[12px] font-semibold transition-all relative ${billingInterval === 'annual' ? 'bg-[#F4F1EA] text-[#0B0A09]' : 'text-white/70'}`}
|
||||
>
|
||||
{t('landing.pricing.annual')}
|
||||
<span className="absolute -top-3 -right-1 text-[10px] text-[#D4A373] whitespace-nowrap">
|
||||
{t('landing.pricing.savePercent')}
|
||||
</span>
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button onClick={() => setBillingInterval('annual')} className={`group relative py-2 px-1 transition-all ${billingInterval === 'annual' ? 'text-ink' : 'text-concrete/40 hover:text-concrete'}`}>
|
||||
<span className="text-xs font-black uppercase tracking-[0.2em]">{t('landing.pricing.annual')}</span>
|
||||
{billingInterval === 'annual' && (
|
||||
<motion.div layoutId="interval-active-pricing" className="absolute -inset-x-1 -inset-y-0.5 border border-ochre/60" transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }} />
|
||||
)}
|
||||
</button>
|
||||
<div className="absolute -top-6 left-1/2 -translate-x-1/2 whitespace-nowrap">
|
||||
<span className="text-[9px] font-bold text-ochre uppercase tracking-widest italic animate-pulse">(-20%)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 items-stretch">
|
||||
{PLANS.map((plan) => (
|
||||
<div key={plan.key} className={`relative p-8 rounded-[32px] border flex flex-col transition-all duration-300 hover:shadow-2xl hover:shadow-ink/5 ${plan.popular ? 'bg-ink text-paper border-ink ring-4 ring-ochre/20' : 'bg-white border-border text-ink'}`}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{plans.map((plan) => (
|
||||
<div
|
||||
key={plan.key}
|
||||
className={`rounded-2xl border p-6 flex flex-col ${
|
||||
plan.popular
|
||||
? 'border-[#D4A373]/50 bg-[#D4A373]/10'
|
||||
: 'border-white/[0.08] bg-white/[0.02]'
|
||||
}`}
|
||||
>
|
||||
{plan.popular && (
|
||||
<div className="absolute -top-4 left-1/2 -translate-x-1/2 px-4 py-1 bg-ochre text-ink text-[10px] font-bold uppercase tracking-widest rounded-full">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-[#D4A373] mb-3">
|
||||
{t('landing.pricing.popular')}
|
||||
</div>
|
||||
</span>
|
||||
)}
|
||||
<div className="mb-8">
|
||||
<h4 className="text-[11px] font-bold uppercase tracking-widest mb-2 opacity-60">{t(`landing.pricing.${plan.key}.name`)}</h4>
|
||||
<div className="flex items-baseline gap-1 mb-4">
|
||||
<span className="text-4xl font-serif font-medium">{plan.price}</span>
|
||||
{plan.period && <span className="text-xs opacity-60">{plan.period}</span>}
|
||||
</div>
|
||||
<p className="text-sm font-light leading-relaxed opacity-80">{t(`landing.pricing.${plan.key}.desc`)}</p>
|
||||
<h2 className="text-[13px] font-medium tracking-wide text-white/80 mb-2">
|
||||
{t(`landing.pricing.${plan.key}.name`)}
|
||||
</h2>
|
||||
<div className="flex items-baseline gap-1 mb-2">
|
||||
<span className="text-3xl font-serif">{plan.price}</span>
|
||||
{plan.period && <span className="text-sm text-white/70">{plan.period}</span>}
|
||||
</div>
|
||||
<div className="flex-1 space-y-4 mb-10">
|
||||
{[0, 1, 2, 3, 4, 5].map(j => {
|
||||
const feat = t(`landing.pricing.${plan.key}.feature${j}`)
|
||||
if (!feat || feat === `landing.pricing.${plan.key}.feature${j}`) return null
|
||||
{plan.hasTrial && (
|
||||
<p className="text-[11px] font-semibold text-[#D4A373] mb-3">
|
||||
{t('landing.pricing.trialBadge', { days: trialDays })}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-white/70 mb-6">{t(`landing.pricing.${plan.key}.desc`)}</p>
|
||||
<ul className="space-y-2.5 mb-8 flex-1">
|
||||
{plan.hasTrial && (
|
||||
<li className="flex gap-2 text-xs text-[#D4A373]/90">
|
||||
<Check size={12} className="text-[#D4A373] mt-0.5 shrink-0" />
|
||||
{t('landing.pricing.trialFeature', { days: trialDays })}
|
||||
</li>
|
||||
)}
|
||||
{[0, 1, 2, 3, 4, 5].map((j) => {
|
||||
const feat = t(`landing.pricing.${plan.key}.feature${j}`, { count: providerCount })
|
||||
if (!feat || feat.startsWith('landing.')) return null
|
||||
return (
|
||||
<div key={j} className="flex items-start gap-3">
|
||||
<div className={`mt-1 rounded-full p-0.5 ${plan.popular ? 'bg-ochre text-ink' : 'bg-brand-accent/10 text-brand-accent'}`}>
|
||||
<Shield size={10} fill="currentColor" />
|
||||
</div>
|
||||
<span className="text-xs font-light">{feat}</span>
|
||||
</div>
|
||||
<li key={j} className="flex gap-2 text-sm text-white/80">
|
||||
<Check size={12} className="text-[#D4A373] mt-0.5 shrink-0" />
|
||||
{feat}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push('/register')}
|
||||
className={`w-full py-4 rounded-2xl text-xs font-bold uppercase tracking-widest transition-all ${plan.popular ? 'bg-ochre text-ink hover:opacity-90' : 'bg-ink text-paper hover:bg-ink/90'}`}
|
||||
</ul>
|
||||
<Link
|
||||
href="/register"
|
||||
className={`py-3 rounded-xl text-center text-[13px] font-semibold transition-colors ${
|
||||
plan.popular
|
||||
? 'bg-[#F4F1EA] text-[#0B0A09] hover:bg-white'
|
||||
: 'bg-white/10 text-white hover:bg-white/15'
|
||||
}`}
|
||||
>
|
||||
{t(`landing.pricing.${plan.key}.cta`)}
|
||||
</button>
|
||||
{plan.hasTrial
|
||||
? t('landing.pricing.trialCta', { days: trialDays })
|
||||
: t(`landing.pricing.${plan.key}.cta`)}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
411
memento-note/app/(public)/privacy/page.tsx
Normal file
411
memento-note/app/(public)/privacy/page.tsx
Normal file
@@ -0,0 +1,411 @@
|
||||
import { headers } from 'next/headers'
|
||||
import Link from 'next/link'
|
||||
import { parseAcceptLanguage } from '@/lib/i18n/detect-user-language'
|
||||
|
||||
export async function generateMetadata({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams?: Promise<{ lang?: string | string[] }>
|
||||
}) {
|
||||
const sp = searchParams ? (await searchParams) : undefined
|
||||
const locale = await pickLocale(sp?.lang)
|
||||
return {
|
||||
title: locale === 'fr' ? 'Politique de confidentialité — Memento' : 'Privacy Policy — Memento',
|
||||
description: locale === 'fr'
|
||||
? "Comment Memento gère vos données, l'extension de capture web Chrome et Firefox, et les fonctionnalités d'IA."
|
||||
: 'How Memento handles your data, the Chrome and Firefox web clipper extension, and AI features.',
|
||||
alternates: { canonical: 'https://memento-note.com/privacy' },
|
||||
robots: { index: true, follow: true },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
type Locale = 'en' | 'fr'
|
||||
const SUPPORTED: Locale[] = ['en', 'fr']
|
||||
const RTL: Locale[] = []
|
||||
|
||||
async function pickLocale(searchLang?: string | string[]): Promise<Locale> {
|
||||
// 1. Explicit ?lang= override
|
||||
const raw = Array.isArray(searchLang) ? searchLang[0] : searchLang
|
||||
if (raw && SUPPORTED.includes(raw.toLowerCase() as Locale)) return raw.toLowerCase() as Locale
|
||||
// 2. Accept-Language header
|
||||
try {
|
||||
const h = await headers()
|
||||
const accept = parseAcceptLanguage(h.get('accept-language'))
|
||||
const first = accept?.[0]?.toLowerCase()
|
||||
if (first && SUPPORTED.includes(first as Locale)) return first as Locale
|
||||
if (first && first.startsWith('fr')) return 'fr'
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
return 'en'
|
||||
}
|
||||
|
||||
type Section = { id: string; title: string; body: string[] }
|
||||
type Doc = {
|
||||
title: string
|
||||
intro: string
|
||||
lastUpdated: string
|
||||
sections: Section[]
|
||||
}
|
||||
|
||||
const DOCS: Record<Locale, Doc> = {
|
||||
fr: {
|
||||
title: 'Politique de confidentialité',
|
||||
intro:
|
||||
'Cette politique décrit comment Memento (Memento Labs) collecte, utilise et protège vos données, y compris via l\'application web, l\'extension navigateur Web Clipper et les fonctionnalités d\'intelligence artificielle. Nous concevons Memento comme un Second Brain personnel : par défaut, vos données vous appartiennent et ne sont pas partagées.',
|
||||
lastUpdated: 'Dernière mise à jour : juillet 2026',
|
||||
sections: [
|
||||
{
|
||||
id: 'data-collected',
|
||||
title: '1. Données collectées',
|
||||
body: [
|
||||
'Compte et authentification : adresse e-mail, nom facultatif, mot de passe haché (bcrypt), ou identifiant Google via OAuth (Google Auth Platform).',
|
||||
'Carnets et notes : contenu, titre, étiquettes, images jointes, pièces jointes (PDF, images, documents), métadonnées (dates, ordre, couleur, épinglé, archivé), et historique des versions (snapshots).',
|
||||
'Rappels et notifications : date/heure, récurrence, lieu facultatif, état (fait/non fait).',
|
||||
'Données IA : titre et corps des notes envoyés à votre fournisseur d\'IA configuré pour générer résumés, tags, embeddings, Memory Echo, reformulations, traduction, suggestions. Avec BYOK (votre propre clé), l\'envoi va directement vers votre fournisseur ; sinon, il transite par notre routeur IA mutualisé.',
|
||||
'Extension Web Clipper (Chrome / Firefox) : lorsque vous cliquez explicitement « Clipper », l\'extension lit l\'URL active, le titre de la page, la sélection de texte et (selon votre choix) le HTML complet de la page. Aucune lecture du contenu de page n\'a lieu tant que vous n\'initiez pas l\'action.',
|
||||
'Cookies techniques et de session : strictement nécessaires à l\'authentification (cookies NextAuth) et aux préférences (langue, thème, panneau de configuration de l\'extension). Aucun cookie publicitaire, aucun tracker tiers par défaut.',
|
||||
'Facturation et quotas : pour les tiers payants, référence de commande et jetons d\'utilisation IA stockés pour appliquer les quotas (réservation atomique). Aucune donnée bancaire (traitée par Stripe).',
|
||||
'Métadonnées techniques : adresse IP (journalisée ~30 jours à des fins anti-abus), user-agent, version de l\'extension, codes d\'erreur anonymisés.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'usage',
|
||||
title: '2. Usage des données',
|
||||
body: [
|
||||
'Fournir le service : synchronisation de vos carnets, recherche, Memory Echo, Memory Echo insights, agents, publication web de notes, clips depuis l\'extension.',
|
||||
'Amélioration : journaux d\'erreur anonymisés et métriques agrégées (Prometheus + dashboards Grafana internes) pour diagnostiquer les pannes et améliorer les performances.',
|
||||
'Sécurité : détection d\'abus, protection contre le credential stuffing et les dépassements de quotas.',
|
||||
'Nous ne vendons jamais vos données. Nous ne les partageons pas avec des annonceurs. Aucun profilage publicitaire.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'third-parties',
|
||||
title: '3. Services tiers et sous-traitants',
|
||||
body: [
|
||||
'Google OAuth : authentification optionnelle (Branding → Audience « In production »).',
|
||||
'Google Cloud Storage (GCP) : hébergement des fichiers joints et des pièces jointes, région Europe (eu-west-3 / europe-west9).',
|
||||
'PostgreSQL (Docker self-hosted) : base principale, sauvegardes chiffrées au repos.',
|
||||
'Redis (Docker self-hosted) : cache de session, rate limiting, files de tâches.',
|
||||
'Fournisseurs d\'IA (OpenAI, Anthropic, Google Gemini, Mistral, OpenRouter, ou votre propre clé BYOK) : envoi du titre et du contenu de la note sélectionnée pour les fonctionnalités IA. Aucun entraînement n\'est effectué sur vos données par notre intermédiaire ; voir la politique du fournisseur retenu.',
|
||||
'Stripe : facturation des abonnements. Les données de paiement sont gérées exclusivement par Stripe (PCI-DSS).',
|
||||
'Resend : e-mails transactionnels (vérification de compte, réinitialisation de mot de passe, alertes quota).',
|
||||
'Google Fonts (CDN fonts.googleapis.com) : polices web sur la landing page et le side panel de l\'extension — adresse IP communiquée à Google.',
|
||||
'Google Favicons (google.com/s2/favicons) : récupération d\'icônes de sites dans l\'extension — l\'URL du domaine est communiquée à Google.',
|
||||
'Grafana Cloud (monitoring optionnel) : métriques agrégées anonymisées.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'extension',
|
||||
title: '4. Permissions de l\'extension navigateur',
|
||||
body: [
|
||||
'activeTab : lecture du contenu de l\'onglet actif uniquement lorsque vous cliquez le bouton « Clipper ». Aucune lecture passive.',
|
||||
'scripting : injection d\'un petit script de capture de sélection après votre clic explicite.',
|
||||
'storage : mémorisation de l\'URL de votre serveur Memento et du dernier carnet utilisé.',
|
||||
'sidePanel (Chrome) / sidebar_action (Firefox) : ouverture du panneau latéral pour afficher l\'interface du clipper.',
|
||||
'tabs : récupération de l\'URL et du titre de l\'onglet actif pour le contexte du clip.',
|
||||
'host_permissions : limitées à memento-note.com en production. Les hôtes étendus (localhost / LAN) sont disponibles uniquement en build développeur.',
|
||||
'L\'extension ne lit jamais l\'historique de navigation. Elle ne communique avec aucun serveur autre que l\'instance Memento que vous avez explicitement configurée.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ai',
|
||||
title: '5. Intelligence artificielle et BYOK',
|
||||
body: [
|
||||
'Les fonctionnalités IA (résumé, tags, Memory Echo, embeddings sémantiques, agents) consomment un quota mesuré en tokens. Le quota est réservé atomiquement avant chaque appel.',
|
||||
'Vous pouvez fournir votre propre clé d\'API (BYOK) pour contourner les quotas mutualisés et faire transiter les appels directement par votre fournisseur. La clé est chiffrée au repos (AES-256-GCM) et n\'est jamais affichée en clair après saisie.',
|
||||
'Aucun entraînement de modèle n\'est effectué sur vos données par notre intermédiaire.',
|
||||
'Vous pouvez désactiver individuellement chaque fonctionnalité IA dans Réglages → IA.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'retention',
|
||||
title: '6. Conservation des données',
|
||||
body: [
|
||||
'Carnets et notes : conservés tant que votre compte est actif. Suppression définitive dans les 30 jours suivant la suppression du compte (sauf obligation légale contraire).',
|
||||
'Corbeille : notes supprimées conservées 30 jours avant purge définitive.',
|
||||
'Journaux d\'erreur : 90 jours maximum, agrégés ensuite.',
|
||||
'Sauvegardes base de données : conservées 30 jours en rotation chiffrée.',
|
||||
'Métadonnées de facturation : conservées 10 ans (obligation comptable).',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rights',
|
||||
title: '7. Vos droits (RGPD / CCPA)',
|
||||
body: [
|
||||
'Accès : export complet de vos notes, carnets, étiquettes et paramètres via Réglages → Données.',
|
||||
'Rectification : édition directe dans l\'interface.',
|
||||
'Effacement : suppression de compte dans Réglages → Données. Effacement définitif sous 30 jours.',
|
||||
'Portabilité : export JSON des notes, carnets et étiquettes (équivalent de l\'outil `export_notes` du serveur MCP).',
|
||||
'Opposition au traitement IA : désactivation des fonctionnalités IA dans Réglages → IA.',
|
||||
'Réclamation : vous pouvez saisir la CNIL (France) ou l\'autorité de contrôle de votre lieu de résidence.',
|
||||
'Pour exercer vos droits : privacy@memento-note.com.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'security',
|
||||
title: '8. Sécurité',
|
||||
body: [
|
||||
'Transport : HTTPS obligatoire, HSTS activé, en-têtes de sécurité (CSP, X-Frame-Options, X-Content-Type-Options).',
|
||||
'Stockage : mots de passe hachés bcrypt, clés API chiffrées AES-256-GCM, secrets en variables d\'environnement chiffrées au repos.',
|
||||
'Authentification : cookies de session NextAuth, JWT signés, expiration glissante.',
|
||||
'Audit : journaux d\'audit log pour les actions sensibles (changement de mot de passe, export de données, suppression de compte).',
|
||||
'Bug bounty : security@memento-note.com pour signaler une vulnérabilité. Programme responsable de divulgation.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'minors',
|
||||
title: '9. Mineurs',
|
||||
body: [
|
||||
'Memento n\'est pas destiné aux enfants de moins de 16 ans (RGPD) ou 13 ans (COPPA). Nous ne collectons pas sciemment de données auprès de mineurs. Si vous pensez qu\'un mineur a fourni des données, contactez privacy@memento-note.com pour suppression.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'changes',
|
||||
title: '10. Modifications de cette politique',
|
||||
body: [
|
||||
'Toute modification substantielle est annoncée par e-mail au moins 30 jours à l\'avance et par bannière dans l\'application. La date de « dernière mise à jour » en haut de cette page est mise à jour à chaque révision.',
|
||||
'Les modifications non substantielles (corrections de formulation, liens mis à jour) sont publiées sans préavis. La version précédente reste accessible sur demande.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'contact',
|
||||
title: '11. Contact et responsable de traitement',
|
||||
body: [
|
||||
'Memento Labs — Responsable de traitement : privacy@memento-note.com',
|
||||
'Délégué à la protection des données (DPO) : dpo@memento-note.com',
|
||||
'Adresse postale : Memento Labs, [adresse], France',
|
||||
'Hébergeur des données : serveur self-hosted sur infrastructure Memento Labs (PostgreSQL + Redis Docker, région Europe).',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
en: {
|
||||
title: 'Privacy Policy',
|
||||
intro:
|
||||
'This policy describes how Memento (Memento Labs) collects, uses and protects your data, including through the web app, the Web Clipper browser extension, and AI features. We design Memento as a personal Second Brain: by default your data belongs to you and is not shared.',
|
||||
lastUpdated: 'Last updated: July 2026',
|
||||
sections: [
|
||||
{
|
||||
id: 'data-collected',
|
||||
title: '1. Data we collect',
|
||||
body: [
|
||||
'Account and authentication: email address, optional name, hashed password (bcrypt), or Google identifier via OAuth (Google Auth Platform).',
|
||||
'Notebooks and notes: content, title, labels, attached images and files (PDF, images, documents), metadata (dates, order, color, pinned, archived), and version history (snapshots).',
|
||||
'Reminders and notifications: date/time, recurrence, optional location, status (done/not done).',
|
||||
'AI data: note title and body sent to your configured AI provider to generate summaries, tags, embeddings, Memory Echo, reformulations, translations, suggestions. With BYOK (your own key), requests go directly to your provider; otherwise they transit through our shared AI router.',
|
||||
'Web Clipper extension (Chrome / Firefox): when you explicitly click "Clip", the extension reads the active URL, page title, text selection and (depending on your choice) the full page HTML. No page content is read unless you initiate the action.',
|
||||
'Technical and session cookies: strictly necessary for authentication (NextAuth cookies) and preferences (language, theme, extension settings panel). No advertising cookies, no third-party trackers by default.',
|
||||
'Billing and quotas: for paid tiers, order reference and AI usage tokens stored to enforce quotas (atomic reservation). No bank card data (handled by Stripe).',
|
||||
'Technical metadata: IP address (logged ~30 days for anti-abuse), user agent, extension version, anonymized error codes.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'usage',
|
||||
title: '2. How we use data',
|
||||
body: [
|
||||
'Provide the service: notebook sync, search, Memory Echo, Memory Echo insights, agents, web publishing of notes, clips from the extension.',
|
||||
'Improvement: anonymized error logs and aggregated metrics (internal Prometheus + Grafana dashboards) to diagnose failures and improve performance.',
|
||||
'Security: abuse detection, protection against credential stuffing and quota overruns.',
|
||||
'We never sell your data. We never share it with advertisers. No advertising profiling.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'third-parties',
|
||||
title: '3. Third parties and sub-processors',
|
||||
body: [
|
||||
'Google OAuth: optional authentication (Branding → Audience "In production").',
|
||||
'Google Cloud Storage (GCP): hosting of attached files and attachments, Europe region (eu-west-3 / europe-west9).',
|
||||
'PostgreSQL (Docker self-hosted): main database, encrypted backups at rest.',
|
||||
'Redis (Docker self-hosted): session cache, rate limiting, job queues.',
|
||||
'AI providers (OpenAI, Anthropic, Google Gemini, Mistral, OpenRouter, or your own BYOK key): the title and content of the selected note are sent for AI features. No training is performed on your data through us; see the chosen provider\'s policy.',
|
||||
'Stripe: subscription billing. Payment data is handled exclusively by Stripe (PCI-DSS).',
|
||||
'Resend: transactional emails (account verification, password reset, quota alerts).',
|
||||
'Google Fonts (CDN fonts.googleapis.com): web fonts on the landing page and the extension side panel — IP address is shared with Google.',
|
||||
'Google Favicons (google.com/s2/favicons): site icon retrieval in the extension — the domain URL is shared with Google.',
|
||||
'Grafana Cloud (optional monitoring): anonymized aggregated metrics.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'extension',
|
||||
title: '4. Browser extension permissions',
|
||||
body: [
|
||||
'activeTab: read the active tab content only when you click the "Clip" button. No passive reading.',
|
||||
'scripting: inject a small selection-capture script after your explicit click.',
|
||||
'storage: remember your Memento server URL and the last-used notebook.',
|
||||
'sidePanel (Chrome) / sidebar_action (Firefox): open the side panel to display the clipper UI.',
|
||||
'tabs: retrieve the active tab URL and title for the clip context.',
|
||||
'host_permissions: limited to memento-note.com in production. Extended hosts (localhost / LAN) are only available in the developer build.',
|
||||
'The extension never reads your browsing history. It never communicates with any server other than the Memento instance you explicitly configured.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ai',
|
||||
title: '5. Artificial intelligence and BYOK',
|
||||
body: [
|
||||
'AI features (summary, tags, Memory Echo, semantic embeddings, agents) consume a token-based quota. The quota is atomically reserved before each call.',
|
||||
'You can provide your own API key (BYOK) to bypass shared quotas and route calls directly through your provider. The key is encrypted at rest (AES-256-GCM) and is never displayed in clear text after entry.',
|
||||
'No model training is performed on your data through us.',
|
||||
'You can disable each AI feature individually in Settings → AI.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'retention',
|
||||
title: '6. Data retention',
|
||||
body: [
|
||||
'Notebooks and notes: kept as long as your account is active. Permanently deleted within 30 days of account deletion (unless a legal obligation requires otherwise).',
|
||||
'Trash: deleted notes kept for 30 days before permanent purge.',
|
||||
'Error logs: maximum 90 days, then aggregated.',
|
||||
'Database backups: kept 30 days in encrypted rotation.',
|
||||
'Billing metadata: kept 10 years (accounting obligation).',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rights',
|
||||
title: '7. Your rights (GDPR / CCPA)',
|
||||
body: [
|
||||
'Access: full export of your notes, notebooks, labels and settings via Settings → Data.',
|
||||
'Rectification: edit directly in the interface.',
|
||||
'Erasure: account deletion in Settings → Data. Definitive erasure within 30 days.',
|
||||
'Portability: JSON export of notes, notebooks and labels (equivalent of the `export_notes` MCP server tool).',
|
||||
'Opt-out of AI processing: disable AI features in Settings → AI.',
|
||||
'Complaint: you may contact the CNIL (France) or the supervisory authority of your place of residence.',
|
||||
'To exercise your rights: privacy@memento-note.com.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'security',
|
||||
title: '8. Security',
|
||||
body: [
|
||||
'Transport: HTTPS enforced, HSTS enabled, security headers (CSP, X-Frame-Options, X-Content-Type-Options).',
|
||||
'Storage: bcrypt-hashed passwords, AES-256-GCM-encrypted API keys, secrets in encrypted environment variables at rest.',
|
||||
'Authentication: NextAuth session cookies, signed JWT, sliding expiration.',
|
||||
'Audit: audit logs for sensitive actions (password change, data export, account deletion).',
|
||||
'Bug bounty: security@memento-note.com to report a vulnerability. Responsible disclosure program.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'minors',
|
||||
title: '9. Minors',
|
||||
body: [
|
||||
'Memento is not intended for children under 16 (GDPR) or 13 (COPPA). We do not knowingly collect data from minors. If you believe a minor has provided data, contact privacy@memento-note.com for deletion.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'changes',
|
||||
title: '10. Changes to this policy',
|
||||
body: [
|
||||
'Any substantial change is announced by email at least 30 days in advance and via an in-app banner. The "Last updated" date at the top of this page is updated with each revision.',
|
||||
'Non-substantial changes (wording fixes, updated links) are published without notice. The previous version remains accessible on request.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'contact',
|
||||
title: '11. Contact and data controller',
|
||||
body: [
|
||||
'Memento Labs — Data controller: privacy@memento-note.com',
|
||||
'Data Protection Officer (DPO): dpo@memento-note.com',
|
||||
'Postal address: Memento Labs, [address], France',
|
||||
'Data hosting: self-hosted on Memento Labs infrastructure (PostgreSQL + Redis Docker, Europe region).',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export default async function PrivacyPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams?: Promise<{ lang?: string | string[] }>
|
||||
}) {
|
||||
const sp = searchParams ? (await searchParams) : undefined
|
||||
const locale = await pickLocale(sp?.lang)
|
||||
const doc = DOCS[locale]
|
||||
const isRtl = RTL.includes(locale)
|
||||
|
||||
return (
|
||||
<main
|
||||
lang={locale}
|
||||
dir={isRtl ? 'rtl' : 'ltr'}
|
||||
className="min-h-screen bg-[#0B0A09] text-[#F4F1EA] font-[family-name:var(--font-manrope)] selection:bg-[#D4A373]/40 selection:text-white"
|
||||
>
|
||||
<header className="px-5 sm:px-8 py-6 border-b border-white/[0.06] flex items-center justify-between">
|
||||
<Link href="/" className="flex items-center gap-2.5">
|
||||
<div className="w-9 h-9 bg-[#F4F1EA] text-[#0B0A09] flex items-center justify-center rounded-lg">
|
||||
<span className="font-serif text-xl font-bold leading-none">M</span>
|
||||
</div>
|
||||
<span className="font-serif text-xl font-medium tracking-tight">Memento</span>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-3">
|
||||
<a
|
||||
href="?lang=fr"
|
||||
aria-current={locale === 'fr' ? 'true' : undefined}
|
||||
className={`text-xs uppercase tracking-widest px-3 py-1 rounded-full border transition-colors ${
|
||||
locale === 'fr'
|
||||
? 'bg-[#F4F1EA] text-[#0B0A09] border-[#F4F1EA]'
|
||||
: 'border-white/15 text-white/55 hover:border-white/40 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
FR
|
||||
</a>
|
||||
<a
|
||||
href="?lang=en"
|
||||
aria-current={locale === 'en' ? 'true' : undefined}
|
||||
className={`text-xs uppercase tracking-widest px-3 py-1 rounded-full border transition-colors ${
|
||||
locale === 'en'
|
||||
? 'bg-[#F4F1EA] text-[#0B0A09] border-[#F4F1EA]'
|
||||
: 'border-white/15 text-white/55 hover:border-white/40 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
EN
|
||||
</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<article className="max-w-3xl mx-auto px-5 sm:px-8 py-16 sm:py-24">
|
||||
<p className="text-[11px] uppercase tracking-[0.3em] text-[#D4A373] mb-4">
|
||||
{locale === 'fr' ? 'Confidentialité' : 'Privacy'}
|
||||
</p>
|
||||
<h1 className="font-serif text-4xl sm:text-5xl tracking-tight mb-4">{doc.title}</h1>
|
||||
<p className="text-sm text-white/70 mb-3">{doc.lastUpdated}</p>
|
||||
<p className="text-white/65 leading-relaxed text-lg mb-12">{doc.intro}</p>
|
||||
|
||||
<nav aria-label="Sommaire" className="mb-12 p-5 rounded-xl bg-white/[0.03] border border-white/[0.06]">
|
||||
<p className="text-[13px] font-medium tracking-wide text-white/70 mb-3">
|
||||
{locale === 'fr' ? 'Sommaire' : 'Contents'}
|
||||
</p>
|
||||
<ul className="space-y-1.5 text-sm">
|
||||
{doc.sections.map((s) => (
|
||||
<li key={s.id}>
|
||||
<a href={`#${s.id}`} className="text-white/65 hover:text-[#D4A373] transition-colors">
|
||||
{s.title}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div className="space-y-12">
|
||||
{doc.sections.map((s) => (
|
||||
<section key={s.id} id={s.id} className="scroll-mt-24">
|
||||
<h2 className="font-serif text-2xl tracking-tight mb-4 text-[#F4F1EA]">{s.title}</h2>
|
||||
<div className="space-y-3 text-white/70 leading-relaxed">
|
||||
{s.body.map((p, i) => (
|
||||
<p key={i}>{p}</p>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<footer className="mt-20 pt-8 border-t border-white/[0.06] text-xs text-white/35 flex flex-wrap items-center justify-between gap-4">
|
||||
<span>© 2026 Memento Labs</span>
|
||||
<Link href="/" className="hover:text-white transition-colors">
|
||||
{locale === 'fr' ? 'Retour à l\'accueil' : 'Back to home'}
|
||||
</Link>
|
||||
</footer>
|
||||
</article>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,9 @@ const BILLING_CONFIG_KEYS = [
|
||||
'STRIPE_PRICE_PRO_ANNUAL',
|
||||
'STRIPE_PRICE_BUSINESS_MONTHLY',
|
||||
'STRIPE_PRICE_BUSINESS_ANNUAL',
|
||||
'STRIPE_PRICE_CREDITS_S',
|
||||
'STRIPE_PRICE_CREDITS_M',
|
||||
'STRIPE_PRICE_CREDITS_L',
|
||||
] as const
|
||||
|
||||
const TIERS: TierType[] = ['BASIC', 'PRO', 'BUSINESS', 'ENTERPRISE']
|
||||
@@ -43,6 +46,152 @@ function assertValidTier(tier: string): asserts tier is TierType {
|
||||
}
|
||||
}
|
||||
|
||||
async function getStripeHealth(billingConfig: Record<string, string>) {
|
||||
const secret = process.env.STRIPE_SECRET_KEY ?? ''
|
||||
const publishable = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? ''
|
||||
const webhook = process.env.STRIPE_WEBHOOK_SECRET ?? ''
|
||||
|
||||
let secretMode: 'test' | 'live' | 'missing' | 'placeholder' = 'missing'
|
||||
if (!secret) secretMode = 'missing'
|
||||
else if (secret === 'sk_test_placeholder' || secret.includes('placeholder')) secretMode = 'placeholder'
|
||||
else if (secret.startsWith('sk_live_')) secretMode = 'live'
|
||||
else if (secret.startsWith('sk_test_')) secretMode = 'test'
|
||||
else secretMode = 'placeholder'
|
||||
|
||||
const { isBillingEnabled } = await import('@/lib/billing/stripe-prices')
|
||||
const { SUBSCRIPTION_TRIAL_DAYS } = await import('@/lib/billing/trial-constants')
|
||||
const billingEnabled = await isBillingEnabled()
|
||||
|
||||
const priceKeys = [
|
||||
...BILLING_CONFIG_KEYS.filter((k) => k.startsWith('STRIPE_PRICE_')),
|
||||
] as string[]
|
||||
|
||||
const canCallStripe = secretMode === 'test' || secretMode === 'live'
|
||||
let stripe: Awaited<ReturnType<typeof import('@/lib/stripe').getStripe>> | null = null
|
||||
if (canCallStripe) {
|
||||
try {
|
||||
const { getStripe } = await import('@/lib/stripe')
|
||||
stripe = getStripe()
|
||||
} catch {
|
||||
stripe = null
|
||||
}
|
||||
}
|
||||
|
||||
const prices = await Promise.all(
|
||||
priceKeys.map(async (key) => {
|
||||
const priceId = (billingConfig[key] || process.env[key] || '').trim()
|
||||
const configured = Boolean(priceId) && !priceId.startsWith('price_mock_')
|
||||
let stripeAmount: number | null = null
|
||||
let stripeCurrency: string | null = null
|
||||
let stripeActive: boolean | null = null
|
||||
let error: string | null = null
|
||||
|
||||
if (configured && stripe) {
|
||||
try {
|
||||
const price = await stripe.prices.retrieve(priceId)
|
||||
stripeAmount = price.unit_amount != null ? price.unit_amount / 100 : null
|
||||
stripeCurrency = price.currency?.toUpperCase() ?? null
|
||||
stripeActive = price.active
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Stripe price lookup failed'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
key,
|
||||
priceId: configured ? priceId : '',
|
||||
configured,
|
||||
source: billingConfig[key] ? 'db' : process.env[key] ? 'env' : 'missing',
|
||||
stripeAmount,
|
||||
stripeCurrency,
|
||||
stripeActive,
|
||||
error,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
secretConfigured: secretMode === 'test' || secretMode === 'live',
|
||||
secretMode,
|
||||
publishableConfigured: Boolean(publishable) && publishable.startsWith('pk_'),
|
||||
webhookSecretConfigured: Boolean(webhook) && webhook.startsWith('whsec_'),
|
||||
billingEnabled,
|
||||
trialDays: SUBSCRIPTION_TRIAL_DAYS,
|
||||
prices,
|
||||
}
|
||||
}
|
||||
|
||||
async function getSubscriptionStats() {
|
||||
const [byTierRaw, byStatusRaw, cancelAtPeriodEnd, recent] = await Promise.all([
|
||||
prisma.subscription.groupBy({
|
||||
by: ['tier'],
|
||||
_count: { _all: true },
|
||||
}),
|
||||
prisma.subscription.groupBy({
|
||||
by: ['status'],
|
||||
_count: { _all: true },
|
||||
}),
|
||||
prisma.subscription.count({ where: { cancelAtPeriodEnd: true } }),
|
||||
prisma.subscription.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ tier: { in: ['PRO', 'BUSINESS', 'ENTERPRISE'] } },
|
||||
{ status: { in: ['TRIALING', 'PAST_DUE', 'ACTIVE'] } },
|
||||
],
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 15,
|
||||
select: {
|
||||
tier: true,
|
||||
status: true,
|
||||
trialEndsAt: true,
|
||||
currentPeriodEnd: true,
|
||||
cancelAtPeriodEnd: true,
|
||||
updatedAt: true,
|
||||
stripeSubscriptionId: true,
|
||||
user: { select: { email: true, name: true } },
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
const byTier: Record<string, number> = Object.fromEntries(TIERS.map((t) => [t, 0]))
|
||||
for (const row of byTierRaw) {
|
||||
byTier[row.tier] = row._count._all
|
||||
}
|
||||
|
||||
const byStatus: Record<string, number> = {}
|
||||
for (const row of byStatusRaw) {
|
||||
byStatus[row.status] = row._count._all
|
||||
}
|
||||
|
||||
const usersWithoutSub = await prisma.user.count({
|
||||
where: { subscription: null },
|
||||
})
|
||||
|
||||
return {
|
||||
byTier,
|
||||
byStatus,
|
||||
cancelAtPeriodEnd,
|
||||
usersWithoutSub,
|
||||
trialing: byStatus.TRIALING ?? 0,
|
||||
pastDue: byStatus.PAST_DUE ?? 0,
|
||||
paidActive:
|
||||
(byStatus.ACTIVE ?? 0) +
|
||||
(byStatus.TRIALING ?? 0),
|
||||
recent: recent.map((s) => ({
|
||||
email: s.user.email,
|
||||
name: s.user.name,
|
||||
tier: s.tier,
|
||||
status: s.status,
|
||||
trialEndsAt: s.trialEndsAt?.toISOString() ?? null,
|
||||
currentPeriodEnd: s.currentPeriodEnd?.toISOString() ?? null,
|
||||
cancelAtPeriodEnd: s.cancelAtPeriodEnd,
|
||||
hasStripeSub: Boolean(s.stripeSubscriptionId),
|
||||
updatedAt: s.updatedAt.toISOString(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBillingAdminData() {
|
||||
await checkAdmin()
|
||||
const { getSystemConfig } = await import('@/lib/config')
|
||||
@@ -54,7 +203,49 @@ export async function getBillingAdminData() {
|
||||
BILLING_CONFIG_KEYS.map((key) => [key, config[key] ?? '']),
|
||||
)
|
||||
|
||||
return { entitlements, billingConfig, usageOverview, features: [...VALID_FEATURES], tiers: TIERS }
|
||||
const { CREDIT_ALLOCATIONS, CREDIT_COSTS, slideGenerateCreditCost } = await import('@/lib/credits')
|
||||
const { CREDIT_PACKS, CREDIT_PACK_IDS } = await import('@/lib/billing/credit-packs')
|
||||
|
||||
const creditAllocations = TIERS.map((tier) => {
|
||||
const raw = CREDIT_ALLOCATIONS[tier]
|
||||
return {
|
||||
tier,
|
||||
monthlyCredits: raw === 'unlimited' ? null : raw,
|
||||
unlimited: raw === 'unlimited',
|
||||
}
|
||||
})
|
||||
|
||||
const creditCosts = {
|
||||
...CREDIT_COSTS,
|
||||
slide_generate_example: slideGenerateCreditCost(7),
|
||||
}
|
||||
|
||||
const creditPacks = CREDIT_PACK_IDS.map((id) => {
|
||||
const p = CREDIT_PACKS[id]
|
||||
return {
|
||||
id: p.id,
|
||||
credits: p.credits,
|
||||
defaultDisplay: p.defaultDisplay,
|
||||
}
|
||||
})
|
||||
|
||||
const [stripeHealth, subscriptionStats] = await Promise.all([
|
||||
getStripeHealth(billingConfig),
|
||||
getSubscriptionStats(),
|
||||
])
|
||||
|
||||
return {
|
||||
entitlements,
|
||||
billingConfig,
|
||||
usageOverview,
|
||||
features: [...VALID_FEATURES],
|
||||
tiers: TIERS,
|
||||
creditAllocations,
|
||||
creditCosts,
|
||||
creditPacks,
|
||||
stripeHealth,
|
||||
subscriptionStats,
|
||||
}
|
||||
}
|
||||
|
||||
export async function updatePlanEntitlement(
|
||||
|
||||
@@ -42,24 +42,47 @@ export async function getSystemConfig() {
|
||||
return getCachedConfig()
|
||||
}
|
||||
|
||||
/** Keys that may be cleared (empty string) to fall back to chat / defaults. */
|
||||
const CLEARABLE_CONFIG_KEYS = new Set([
|
||||
'AI_PROVIDER_SLIDES',
|
||||
'AI_MODEL_SLIDES',
|
||||
])
|
||||
|
||||
export async function updateSystemConfig(data: Record<string, string>) {
|
||||
await checkAdmin()
|
||||
|
||||
try {
|
||||
// Filter out empty values but keep 'false' as valid
|
||||
const filteredData = Object.fromEntries(
|
||||
Object.entries(data).filter(([key, value]) => value !== '' && value !== null && value !== undefined)
|
||||
)
|
||||
// Filter out empty values but keep 'false' as valid.
|
||||
// Slides provider/model may be empty intentionally (= use Chat fallback).
|
||||
const toUpsert: Record<string, string> = {}
|
||||
const toDelete: string[] = []
|
||||
|
||||
const operations = Object.entries(filteredData).map(([key, value]) =>
|
||||
prisma.systemConfig.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value }
|
||||
})
|
||||
)
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (value === null || value === undefined) continue
|
||||
if (value === '' && CLEARABLE_CONFIG_KEYS.has(key)) {
|
||||
toDelete.push(key)
|
||||
continue
|
||||
}
|
||||
if (value === '') continue
|
||||
toUpsert[key] = value
|
||||
}
|
||||
|
||||
await prisma.$transaction(operations)
|
||||
const operations = [
|
||||
...Object.entries(toUpsert).map(([key, value]) =>
|
||||
prisma.systemConfig.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value },
|
||||
}),
|
||||
),
|
||||
...toDelete.map((key) =>
|
||||
prisma.systemConfig.deleteMany({ where: { key } }),
|
||||
),
|
||||
]
|
||||
|
||||
if (operations.length > 0) {
|
||||
await prisma.$transaction(operations)
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { auth } from '@/auth'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { z } from 'zod'
|
||||
import { SubscriptionTier, SubscriptionStatus } from '@prisma/client'
|
||||
import { addUtcMonths } from '@/lib/billing/period'
|
||||
|
||||
// Schema pour la création d'utilisateur
|
||||
const CreateUserSchema = z.object({
|
||||
@@ -152,8 +153,7 @@ export async function updateUserSubscription(userId: string, tier: string) {
|
||||
const oldTier = existing?.tier ?? 'BASIC'
|
||||
|
||||
const now = new Date()
|
||||
const periodEnd = new Date(now)
|
||||
periodEnd.setFullYear(periodEnd.getFullYear() + 1)
|
||||
const periodEnd = addUtcMonths(now, 1)
|
||||
|
||||
await prisma.subscription.upsert({
|
||||
where: { userId },
|
||||
@@ -186,3 +186,60 @@ export async function updateUserSubscription(userId: string, tier: string) {
|
||||
throw new Error('Failed to update subscription')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remet à zéro les crédits IA + anciens compteurs feature du mois pour un utilisateur.
|
||||
* @param feature (optionnel, legacy) — le reset crédits est global ; feature ignorée pour le solde.
|
||||
*/
|
||||
export async function resetUserQuotas(userId: string, feature?: string) {
|
||||
const session = await checkAdmin()
|
||||
|
||||
if (!userId?.trim()) {
|
||||
throw new Error('Identifiant utilisateur manquant')
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, email: true },
|
||||
})
|
||||
if (!user) {
|
||||
throw new Error('Utilisateur introuvable')
|
||||
}
|
||||
|
||||
// Nouveau système : solde de crédits global
|
||||
const { adminResetCredits } = await import('@/lib/credits')
|
||||
const balance = await adminResetCredits(userId, { clearPurchased: false })
|
||||
|
||||
// Ancien système feature (compat / métriques)
|
||||
const { resetUserUsageForCurrentPeriod } = await import('@/lib/entitlements')
|
||||
const legacy = await resetUserUsageForCurrentPeriod(
|
||||
userId,
|
||||
feature ? { features: [feature] } : undefined,
|
||||
)
|
||||
|
||||
const { logAuditEventAsync } = await import('@/lib/audit-log')
|
||||
await logAuditEventAsync({
|
||||
userId: session.user?.id,
|
||||
action: 'QUOTA_RESET',
|
||||
resource: userId,
|
||||
metadata: {
|
||||
targetUserId: userId,
|
||||
targetEmail: user.email,
|
||||
period: balance.period,
|
||||
creditsRemaining: balance.unlimited ? 'unlimited' : balance.totalRemaining,
|
||||
legacyFeaturesReset: legacy.featuresReset,
|
||||
scope: feature || 'all',
|
||||
},
|
||||
})
|
||||
|
||||
revalidatePath('/admin')
|
||||
revalidatePath('/admin/users')
|
||||
return {
|
||||
success: true as const,
|
||||
period: balance.period,
|
||||
featuresReset: legacy.featuresReset,
|
||||
redisDeleted: legacy.redisDeleted,
|
||||
dbUpdated: legacy.dbUpdated,
|
||||
creditsRemaining: balance.unlimited ? null : balance.totalRemaining,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,10 +240,10 @@ export async function runAgent(id: string) {
|
||||
return { success: false, agentId: id, error: 'Agent introuvable' }
|
||||
}
|
||||
|
||||
// Fire and forget — return immediately so the UI doesn't block
|
||||
import('@/lib/ai/services/agent-executor.service')
|
||||
.then(({ executeAgent }) => executeAgent(id, userId))
|
||||
.then(() => { /* revalidation is handled client-side via polling */ })
|
||||
// Load module first so import failures surface immediately (not silently after response)
|
||||
const { executeAgent } = await import('@/lib/ai/services/agent-executor.service')
|
||||
|
||||
void executeAgent(id, userId)
|
||||
.catch(err => console.error('[runAgent] Background error:', err))
|
||||
|
||||
return { success: true, agentId: id, status: 'running' }
|
||||
@@ -319,9 +319,35 @@ export async function toggleAgent(id: string, isEnabled: boolean) {
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await prisma.agent.findFirst({
|
||||
where: { id, userId: session.user.id },
|
||||
select: {
|
||||
id: true,
|
||||
frequency: true,
|
||||
scheduledTime: true,
|
||||
scheduledDay: true,
|
||||
timezone: true,
|
||||
nextRun: true,
|
||||
},
|
||||
})
|
||||
if (!existing) throw new Error('Agent introuvable')
|
||||
|
||||
const data: { isEnabled: boolean; nextRun?: Date | null } = { isEnabled }
|
||||
|
||||
// When re-enabling a scheduled agent, ensure nextRun is set (was often null → never cron'd)
|
||||
if (isEnabled && existing.frequency !== 'manual' && existing.frequency !== 'one-shot') {
|
||||
const nextRun = calculateNextRun({
|
||||
frequency: existing.frequency,
|
||||
scheduledTime: existing.scheduledTime,
|
||||
scheduledDay: existing.scheduledDay,
|
||||
timezone: existing.timezone,
|
||||
})
|
||||
data.nextRun = nextRun
|
||||
}
|
||||
|
||||
const agent = await prisma.agent.update({
|
||||
where: { id, userId: session.user.id },
|
||||
data: { isEnabled }
|
||||
data,
|
||||
})
|
||||
return { success: true, agent }
|
||||
} catch (error) {
|
||||
|
||||
11
memento-note/app/actions/auth-verify.ts
Normal file
11
memento-note/app/actions/auth-verify.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
'use server'
|
||||
|
||||
import { verifyEmailToken, resendVerificationEmail } from '@/lib/auth/email-verification'
|
||||
|
||||
export async function confirmEmail(token: string) {
|
||||
return verifyEmailToken(token)
|
||||
}
|
||||
|
||||
export async function resendSignupVerification(email: string, locale?: string) {
|
||||
return resendVerificationEmail(email, locale)
|
||||
}
|
||||
@@ -2,20 +2,49 @@
|
||||
|
||||
import { signIn } from '@/auth';
|
||||
import { AuthError } from 'next-auth';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import prisma from '@/lib/prisma';
|
||||
|
||||
export async function authenticate(
|
||||
prevState: string | undefined,
|
||||
formData: FormData,
|
||||
) {
|
||||
const emailRaw = formData.get('email');
|
||||
const passwordRaw = formData.get('password');
|
||||
const email = typeof emailRaw === 'string' ? emailRaw.toLowerCase().trim() : '';
|
||||
const password = typeof passwordRaw === 'string' ? passwordRaw : '';
|
||||
|
||||
// Surface a clear message when credentials are valid but email is unverified.
|
||||
if (email && password.length >= 6) {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
if (user?.password) {
|
||||
const match = await bcrypt.compare(password, user.password);
|
||||
if (match && !user.emailVerified) {
|
||||
return 'EMAIL_NOT_VERIFIED';
|
||||
}
|
||||
}
|
||||
} catch (preCheckErr) {
|
||||
console.error('[authenticate] emailVerified pre-check failed:', preCheckErr);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await signIn('credentials', {
|
||||
email: formData.get('email'),
|
||||
password: formData.get('password'),
|
||||
email,
|
||||
password,
|
||||
redirectTo: '/home',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
console.error('AuthError details:', error.type, error.message);
|
||||
if (
|
||||
error.type === 'CredentialsSignin' &&
|
||||
(error.message?.includes('EMAIL_NOT_VERIFIED') ||
|
||||
(error.cause as { err?: Error } | undefined)?.err?.message === 'EMAIL_NOT_VERIFIED')
|
||||
) {
|
||||
return 'EMAIL_NOT_VERIFIED';
|
||||
}
|
||||
switch (error.type) {
|
||||
case 'CredentialsSignin':
|
||||
return 'Invalid credentials.';
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { chatService } from '@/lib/ai/services'
|
||||
import { auth } from '@/auth'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
/**
|
||||
@@ -38,7 +37,6 @@ export async function sendChatMessage(
|
||||
|
||||
try {
|
||||
const result = await chatService.chat(message, conversationId, notebookId)
|
||||
revalidatePath('/chat')
|
||||
return { success: true, ...result }
|
||||
} catch (error: any) {
|
||||
console.error('[ChatAction] Error:', error)
|
||||
|
||||
@@ -5,6 +5,7 @@ import prisma from '@/lib/prisma';
|
||||
import { z } from 'zod';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getSystemConfig } from '@/lib/config';
|
||||
import { sendVerificationEmail } from '@/lib/auth/email-verification';
|
||||
|
||||
const RegisterSchema = z.object({
|
||||
email: z.string().email(),
|
||||
@@ -17,9 +18,8 @@ const RegisterSchema = z.object({
|
||||
});
|
||||
|
||||
export async function register(prevState: string | undefined, formData: FormData) {
|
||||
// Check if registration is allowed
|
||||
const config = await getSystemConfig();
|
||||
const allowRegister = config.ALLOW_REGISTRATION !== 'false' || process.env.ALLOW_REGISTRATION !== 'false';
|
||||
const allowRegister = config.ALLOW_REGISTRATION !== 'false' && process.env.ALLOW_REGISTRATION !== 'false';
|
||||
|
||||
if (!allowRegister) {
|
||||
return 'Registration is currently disabled by the administrator.';
|
||||
@@ -37,36 +37,48 @@ export async function register(prevState: string | undefined, formData: FormData
|
||||
}
|
||||
|
||||
const { email, password, name } = validatedFields.data;
|
||||
const normalizedEmail = email.toLowerCase();
|
||||
const adminEmail = process.env.ADMIN_EMAIL?.toLowerCase();
|
||||
const isAdmin = Boolean(adminEmail && normalizedEmail === adminEmail);
|
||||
|
||||
try {
|
||||
const existingUser = await prisma.user.findUnique({ where: { email: email.toLowerCase() } });
|
||||
const existingUser = await prisma.user.findUnique({ where: { email: normalizedEmail } });
|
||||
if (existingUser) {
|
||||
return 'User already exists.';
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
const adminEmail = process.env.ADMIN_EMAIL?.toLowerCase();
|
||||
const role = adminEmail && email.toLowerCase() === adminEmail ? 'ADMIN' : 'USER';
|
||||
const role = isAdmin ? 'ADMIN' : 'USER';
|
||||
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
email: email.toLowerCase(),
|
||||
email: normalizedEmail,
|
||||
password: hashedPassword,
|
||||
name,
|
||||
role,
|
||||
// Admin bootstrap + Google OAuth are trusted; everyone else must verify.
|
||||
emailVerified: isAdmin ? new Date() : null,
|
||||
},
|
||||
});
|
||||
|
||||
// Attempt to sign in immediately after registration
|
||||
// We cannot import signIn here directly if it causes circular deps or issues,
|
||||
// but usually it works. If not, redirecting to login is fine.
|
||||
// Let's stick to redirecting to login but with a clear success message?
|
||||
// Or better: lowercase the email to fix the potential bug.
|
||||
|
||||
if (!isAdmin) {
|
||||
const mailResult = await sendVerificationEmail({
|
||||
email: normalizedEmail,
|
||||
name,
|
||||
});
|
||||
|
||||
if (!mailResult.success) {
|
||||
console.error('[register] verification email failed:', mailResult.error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Registration Error:', error);
|
||||
return 'Database Error: Failed to create user.';
|
||||
}
|
||||
|
||||
redirect('/login');
|
||||
if (isAdmin) {
|
||||
redirect('/login?verified=1');
|
||||
}
|
||||
|
||||
redirect(`/check-email?email=${encodeURIComponent(normalizedEmail)}`);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||
import { QuotaExceededError } from '@/lib/entitlements'
|
||||
import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
|
||||
import { slideGenerateCreditCost, notebookSlideCreditCost, resolveCreditCost } from '@/lib/credits'
|
||||
import type { FeatureName } from '@/lib/quota-utils'
|
||||
import { logAuditEvent, getClientIp } from '@/lib/audit-log'
|
||||
import {
|
||||
encodeSlideIntentDescription,
|
||||
normalizeSlideIntent,
|
||||
type SlideAudience,
|
||||
type SlidePurpose,
|
||||
} from '@/lib/ai/services/slide-intent'
|
||||
|
||||
type GenerateType = 'slide-generator' | 'excalidraw-generator'
|
||||
|
||||
@@ -12,9 +21,10 @@ const TYPE_DEFAULTS: Record<GenerateType, {
|
||||
maxSteps: number
|
||||
}> = {
|
||||
'slide-generator': {
|
||||
role: 'Génère une présentation professionnelle à partir du contenu de la note fournie. Appelle generate_slides avec un objet JSON structuré {title, theme, slides:[...]}.',
|
||||
tools: ['note_search', 'note_read', 'generate_slides'],
|
||||
maxSteps: 6,
|
||||
// tools unused: slide-generator runs a structured-output pipeline (no function calling)
|
||||
role: 'Génère un deck exécutif de haute qualité (titres d\'action, une idée par slide, pas de filler, graphiques uniquement avec vrais chiffres).',
|
||||
tools: [],
|
||||
maxSteps: 1,
|
||||
},
|
||||
'excalidraw-generator': {
|
||||
role: 'Génère un diagramme Excalidraw clair et professionnel à partir du contenu de la note fournie.',
|
||||
@@ -32,36 +42,100 @@ export async function POST(req: NextRequest) {
|
||||
const userId = session.user.id
|
||||
|
||||
const body = await req.json()
|
||||
const { noteId, type, theme, style, language, template } = body as {
|
||||
noteId: string
|
||||
const { noteId, notebookId, type, theme, style, language, template, purpose, audience, slideCount } = body as {
|
||||
noteId?: string
|
||||
/** Si fourni (slides), génère un deck multi-notes du carnet */
|
||||
notebookId?: string
|
||||
type: GenerateType
|
||||
theme?: string
|
||||
style?: string
|
||||
language?: string
|
||||
template?: string
|
||||
purpose?: SlidePurpose
|
||||
audience?: SlideAudience
|
||||
slideCount?: number
|
||||
}
|
||||
|
||||
if (!noteId || !type || !TYPE_DEFAULTS[type]) {
|
||||
if ((!noteId && !notebookId) || !type || !TYPE_DEFAULTS[type]) {
|
||||
return NextResponse.json({ error: 'Paramètres invalides' }, { status: 400 })
|
||||
}
|
||||
if (notebookId && type !== 'slide-generator') {
|
||||
return NextResponse.json({ error: 'notebookId réservé aux présentations' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Quota check — feature key depends on generation type
|
||||
const featureKey = type === 'slide-generator' ? 'slide_generate' : 'excalidraw_generate'
|
||||
let noteCountForPack = 1
|
||||
if (notebookId) {
|
||||
noteCountForPack = await prisma.note.count({
|
||||
where: {
|
||||
notebookId,
|
||||
userId,
|
||||
isArchived: false,
|
||||
trashedAt: null,
|
||||
},
|
||||
})
|
||||
if (noteCountForPack < 1) {
|
||||
return NextResponse.json({ error: 'Carnet vide ou introuvable' }, { status: 404 })
|
||||
}
|
||||
}
|
||||
|
||||
// Crédits globaux (respecte le BYOK) — slides note = 1+N ; carnet = coût notebook
|
||||
const featureKey = (type === 'slide-generator' ? 'slide_generate' : 'excalidraw_generate') as FeatureName
|
||||
const creditCost =
|
||||
type === 'slide-generator'
|
||||
? notebookId
|
||||
? notebookSlideCreditCost({ slideCount, noteCount: noteCountForPack })
|
||||
: slideGenerateCreditCost(slideCount)
|
||||
: resolveCreditCost(featureKey)
|
||||
try {
|
||||
await reserveUsageOrThrow(userId, featureKey)
|
||||
await reserveAiUsageOrThrow(userId, featureKey, {
|
||||
amount: creditCost,
|
||||
slideCount: type === 'slide-generator' ? slideCount : undefined,
|
||||
metadata: {
|
||||
noteId: noteId ?? null,
|
||||
notebookId: notebookId ?? null,
|
||||
type,
|
||||
slideCount: slideCount ?? null,
|
||||
noteCount: noteCountForPack,
|
||||
},
|
||||
lane: 'chat',
|
||||
})
|
||||
} catch (e) {
|
||||
if (e instanceof QuotaExceededError) {
|
||||
return NextResponse.json({ error: e.message }, { status: 402 })
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: e.message,
|
||||
code: 'QUOTA_EXCEEDED',
|
||||
feature: featureKey,
|
||||
creditCost,
|
||||
},
|
||||
{ status: 402 },
|
||||
)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
||||
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 })
|
||||
let note: { id: string; title: string | null; notebookId: string | null } | null = null
|
||||
if (noteId) {
|
||||
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 })
|
||||
}
|
||||
} else if (notebookId) {
|
||||
const notebook = await prisma.notebook.findFirst({
|
||||
where: { id: notebookId, userId },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
if (!notebook) {
|
||||
return NextResponse.json({ error: 'Carnet introuvable' }, { status: 404 })
|
||||
}
|
||||
note = {
|
||||
id: notebookId,
|
||||
title: notebook.name,
|
||||
notebookId,
|
||||
}
|
||||
}
|
||||
|
||||
const defaults = TYPE_DEFAULTS[type]
|
||||
@@ -71,31 +145,62 @@ export async function POST(req: NextRequest) {
|
||||
if (isEn) {
|
||||
if (type === 'slide-generator') {
|
||||
const recipeHint = (theme && theme !== 'auto') ? ` Use theme:"${theme}".` : ''
|
||||
role = `Generate a professional presentation from the provided note content.${recipeHint} Call generate_slides with structured JSON {title, theme, slides:[...]}.`
|
||||
role = `Generate a high-quality executive deck (action titles, one idea per slide, no filler, charts only with real numbers).${recipeHint}`
|
||||
} else {
|
||||
role = 'Generate a clear and professional Excalidraw diagram from the provided note content.'
|
||||
}
|
||||
} else if (type === 'slide-generator') {
|
||||
const recipeHint = (theme && theme !== 'auto') ? ` Utilise le thème "${theme}".` : ''
|
||||
role = `Génère une présentation professionnelle à partir du contenu de la note fournie.${recipeHint} Appelle generate_slides avec le JSON structuré {title, theme, slides:[...]}.`
|
||||
role = `Génère un deck exécutif de haute qualité (titres d'action, une idée par slide, pas de filler, graphiques uniquement avec vrais chiffres).${recipeHint}`
|
||||
}
|
||||
|
||||
if (!note) {
|
||||
return NextResponse.json({ error: 'Source introuvable' }, { status: 404 })
|
||||
}
|
||||
|
||||
const agentName = type === 'slide-generator'
|
||||
? `${isEn ? 'Slides' : 'Présentation'} — ${(note.title || 'Note').substring(0, 40)}`
|
||||
: `${isEn ? 'Diagram' : 'Diagramme'} — ${(note.title || 'Note').substring(0, 40)}`
|
||||
|
||||
const slideIntent =
|
||||
type === 'slide-generator'
|
||||
? normalizeSlideIntent({
|
||||
purpose,
|
||||
audience,
|
||||
slideCount,
|
||||
template: template || 'auto',
|
||||
})
|
||||
: null
|
||||
|
||||
// Map purpose → legacy template when user picks intent without template
|
||||
if (slideIntent && (!slideIntent.template || slideIntent.template === 'auto')) {
|
||||
const purposeToTemplate: Record<string, string> = {
|
||||
board: 'board-update',
|
||||
project: 'project-status',
|
||||
strategy: 'strategy-review',
|
||||
}
|
||||
if (slideIntent.purpose && purposeToTemplate[slideIntent.purpose]) {
|
||||
slideIntent.template = purposeToTemplate[slideIntent.purpose]
|
||||
}
|
||||
}
|
||||
|
||||
const sourceNotebookId = notebookId || note.notebookId || undefined
|
||||
const agent = await prisma.agent.create({
|
||||
data: {
|
||||
name: agentName,
|
||||
type,
|
||||
role,
|
||||
description: (type === 'slide-generator' && template && template !== 'auto') ? `template:${template}` : undefined,
|
||||
description:
|
||||
type === 'slide-generator' && slideIntent
|
||||
? encodeSlideIntentDescription(slideIntent)
|
||||
: undefined,
|
||||
tools: JSON.stringify(defaults.tools),
|
||||
maxSteps: defaults.maxSteps,
|
||||
frequency: 'one-shot',
|
||||
isEnabled: true,
|
||||
sourceNoteIds: JSON.stringify([noteId]),
|
||||
targetNotebookId: note.notebookId ?? undefined,
|
||||
sourceNoteIds: noteId ? JSON.stringify([noteId]) : JSON.stringify([]),
|
||||
sourceNotebookId: notebookId ? notebookId : undefined,
|
||||
targetNotebookId: sourceNotebookId,
|
||||
slideTheme: theme ?? 'keynote',
|
||||
slideStyle: style ?? 'soft',
|
||||
userId,
|
||||
@@ -104,19 +209,31 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
// ── Fire and forget — do NOT await so the HTTP response returns immediately ──
|
||||
// In Node.js / Docker self-hosted, the process keeps running after response.
|
||||
// skipQuota: units already reserved above (avoids double-charge in executeAgent).
|
||||
import('@/lib/ai/services/agent-executor.service')
|
||||
.then(({ executeAgent }) => executeAgent(agent.id, userId))
|
||||
.then(({ executeAgent }) => executeAgent(agent.id, userId, undefined, { skipQuota: true }))
|
||||
.catch(err => console.error('[run-for-note] Background agent error:', err))
|
||||
|
||||
logAuditEvent({
|
||||
userId,
|
||||
action: 'AI_REQUEST',
|
||||
resource: featureKey,
|
||||
metadata: { agentId: agent.id, noteId, featureKey },
|
||||
metadata: {
|
||||
agentId: agent.id,
|
||||
noteId: noteId ?? null,
|
||||
notebookId: notebookId ?? null,
|
||||
featureKey,
|
||||
creditCost,
|
||||
},
|
||||
ip: getClientIp(req),
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, agentId: agent.id, status: 'running' })
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
agentId: agent.id,
|
||||
status: 'running',
|
||||
creditCost,
|
||||
})
|
||||
}
|
||||
|
||||
// ─── GET : poll current agent status ──────────────────────────────────────
|
||||
|
||||
@@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { autoLabelCreationService } from '@/lib/ai/services'
|
||||
import { getAISettings } from '@/app/actions/ai-settings'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
import { hasUserAiConsent, aiConsentForbiddenJson } from '@/lib/consent/server-consent'
|
||||
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
||||
|
||||
/**
|
||||
@@ -19,13 +19,11 @@ export async function POST(request: NextRequest) {
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
const userId = session.user.id
|
||||
|
||||
// GDPR AI Consent check
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'ai_consent_required' },
|
||||
{ status: 403 }
|
||||
)
|
||||
return aiConsentForbiddenJson()
|
||||
}
|
||||
|
||||
// Respect user's autoLabeling toggle
|
||||
@@ -61,12 +59,12 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const suggestions = await withAiQuota(
|
||||
session.user.id,
|
||||
userId,
|
||||
'auto_tag',
|
||||
() =>
|
||||
autoLabelCreationService.suggestLabels(
|
||||
notebookId,
|
||||
session.user!.id,
|
||||
userId,
|
||||
language,
|
||||
),
|
||||
{ lane: 'tags' },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { batchOrganizationService } from '@/lib/ai/services'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
import { hasUserAiConsent, aiConsentForbiddenJson } from '@/lib/consent/server-consent'
|
||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||
|
||||
/**
|
||||
@@ -20,10 +20,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// GDPR AI Consent check
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'ai_consent_required' },
|
||||
{ status: 403 }
|
||||
)
|
||||
return aiConsentForbiddenJson()
|
||||
}
|
||||
|
||||
// Get language from request headers or body
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { getAISettings } from '@/app/actions/ai-settings'
|
||||
import { describeImages } from '@/lib/ai/services/image-description.service'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
@@ -13,7 +13,7 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
||||
return aiConsentForbiddenResponse()
|
||||
}
|
||||
|
||||
const userSettings = await getAISettings(session.user.id)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { memoryEchoService } from '@/lib/ai/services/memory-echo.service'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||
|
||||
/**
|
||||
* GET /api/ai/echo/connections?noteId={id}&page={page}&limit={limit}
|
||||
@@ -19,10 +19,7 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ai_consent_required' },
|
||||
{ status: 403 }
|
||||
)
|
||||
return aiConsentForbiddenResponse()
|
||||
}
|
||||
|
||||
// Get query parameters
|
||||
|
||||
@@ -3,7 +3,7 @@ import { auth } from '@/auth'
|
||||
import { getChatProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
||||
|
||||
/**
|
||||
@@ -22,7 +22,7 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
||||
return aiConsentForbiddenResponse()
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { memoryEchoService } from '@/lib/ai/services/memory-echo.service'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||
|
||||
/**
|
||||
@@ -21,10 +21,7 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
// GDPR AI Consent check
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ai_consent_required' },
|
||||
{ status: 403 }
|
||||
)
|
||||
return aiConsentForbiddenResponse()
|
||||
}
|
||||
|
||||
// Get next insight (respects frequency limits)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { getTagsProvider } from '@/lib/ai/factory'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
@@ -13,7 +13,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
||||
return aiConsentForbiddenResponse()
|
||||
}
|
||||
|
||||
const { existingContent, resourceText, mode, language, format } = await request.json()
|
||||
|
||||
125
memento-note/app/api/ai/interactive-demo/route.ts
Normal file
125
memento-note/app/api/ai/interactive-demo/route.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { auth } from '@/auth'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
|
||||
import { QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
|
||||
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||
import { getSlidesProvider } from '@/lib/ai/factory'
|
||||
import { generateInteractiveDemoFromContent } from '@/lib/ai/services/interactive-demo-generate.service'
|
||||
|
||||
export const maxDuration = 180
|
||||
|
||||
const requestSchema = z.object({
|
||||
content: z.string().min(20),
|
||||
selection: z.string().optional().nullable(),
|
||||
lang: z.string().optional(),
|
||||
noteId: z.string().optional(),
|
||||
})
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
return html
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return aiConsentForbiddenResponse()
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const parsed = requestSchema.parse(body)
|
||||
// Keep raw HTML/markdown so formula extractors see $...$ / KaTeX like slides
|
||||
const sourceRaw = parsed.selection?.trim() || parsed.content
|
||||
const sourcePlain = stripHtml(sourceRaw)
|
||||
const wordCount = sourcePlain.split(/\s+/).filter(Boolean).length
|
||||
if (wordCount < 20) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Sélectionne au moins ~20 mots de contenu pour générer une démo interactive',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
await reserveAiUsageOrThrow(session.user.id, 'interactive_demo', {
|
||||
lane: 'chat',
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof QuotaExceededError) {
|
||||
return NextResponse.json(err.toJSON(), { status: 402 })
|
||||
}
|
||||
if (
|
||||
err instanceof QuotaServiceUnavailableError ||
|
||||
process.env.NODE_ENV === 'production'
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'QUOTA_SERVICE_UNAVAILABLE' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
console.error('[/api/ai/interactive-demo] Quota check error (fail-open):', err)
|
||||
}
|
||||
|
||||
const config = await getSystemConfig()
|
||||
const lang = parsed.lang || 'fr'
|
||||
// Same admin model as slide decks (AI_PROVIDER_SLIDES / AI_MODEL_SLIDES → chat fallback)
|
||||
const provider = getSlidesProvider(config)
|
||||
|
||||
const result = await generateInteractiveDemoFromContent({
|
||||
content: sourceRaw,
|
||||
lang,
|
||||
provider,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
const first = result.issues[0]
|
||||
const detail = first
|
||||
? `${first.path ? first.path + ': ' : ''}${first.message}`
|
||||
: ''
|
||||
console.error(
|
||||
'[/api/ai/interactive-demo] validation failed after repairs',
|
||||
result.issues.slice(0, 10)
|
||||
)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: detail
|
||||
? `Démo invalide après correction — ${detail}`
|
||||
: 'La génération a produit un JSON invalide après correction',
|
||||
issues: result.issues.slice(0, 10),
|
||||
attempts: result.attempts,
|
||||
},
|
||||
{ status: 422 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
demo: result.demo,
|
||||
attempts: result.attempts,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: error.issues }, { status: 400 })
|
||||
}
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Erreur génération interactive demo'
|
||||
console.error('[/api/ai/interactive-demo]', error)
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
214
memento-note/app/api/ai/interactive-page/route.ts
Normal file
214
memento-note/app/api/ai/interactive-page/route.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { auth } from '@/auth'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||
import { getSlidesProvider } from '@/lib/ai/factory'
|
||||
import { generateInteractivePageFromContent } from '@/lib/ai/services/interactive-page-generate.service'
|
||||
import {
|
||||
generatePagePlan,
|
||||
generatePageSection,
|
||||
} from '@/lib/ai/services/interactive-page-llm.service'
|
||||
import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
|
||||
import { QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
|
||||
|
||||
export const maxDuration = 60
|
||||
|
||||
const sectionPlanSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
goal: z.string().min(1),
|
||||
demoKind: z.enum(['steps', 'svg-scene', 'chart', 'heatmap-matrix', 'simulation', 'none']),
|
||||
demoGoal: z.string().nullish(),
|
||||
})
|
||||
|
||||
const requestSchema = z.object({
|
||||
/** undefined = legacy deterministic full-page (fallback path) */
|
||||
action: z.enum(['plan', 'section']).optional(),
|
||||
content: z.string().min(40).max(500_000),
|
||||
lang: z.string().optional(),
|
||||
noteId: z.string().optional(),
|
||||
notebookId: z.string().optional(),
|
||||
pageTitle: z.string().optional(),
|
||||
sectionId: z.string().optional(),
|
||||
section: sectionPlanSchema.optional(),
|
||||
})
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return aiConsentForbiddenResponse()
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => null)
|
||||
if (!body || typeof body !== 'object') {
|
||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||
}
|
||||
const parsed = requestSchema.parse(body)
|
||||
const wordCount = parsed.content
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean).length
|
||||
if (wordCount < 40) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Contenu trop court pour une page interactive (~40 mots min.)' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const config = await getSystemConfig()
|
||||
const provider = getSlidesProvider(config)
|
||||
const lang = parsed.lang || 'fr'
|
||||
|
||||
// ── LLM plan (billed 5/20 crédits — le reste est facturé par section) ──
|
||||
if (parsed.action === 'plan') {
|
||||
try {
|
||||
await reserveAiUsageOrThrow(session.user.id, 'interactive_page', {
|
||||
lane: 'chat',
|
||||
amount: 5,
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof QuotaExceededError) {
|
||||
return NextResponse.json(err.toJSON(), { status: 402 })
|
||||
}
|
||||
if (
|
||||
err instanceof QuotaServiceUnavailableError ||
|
||||
process.env.NODE_ENV === 'production'
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'QUOTA_SERVICE_UNAVAILABLE' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
console.error('[/api/ai/interactive-page] Quota check error (fail-open):', err)
|
||||
}
|
||||
|
||||
const result = await generatePagePlan({
|
||||
content: parsed.content,
|
||||
lang,
|
||||
provider,
|
||||
})
|
||||
if (!result.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: result.error, reason: result.reason, attempts: result.attempts },
|
||||
{ status: result.error === 'unsuitable_content' ? 422 : 502 }
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ plan: result.plan, attempts: result.attempts })
|
||||
}
|
||||
|
||||
// ── LLM single section (billed 3/20 crédits per section — no free LLM) ──
|
||||
if (parsed.action === 'section') {
|
||||
if (!parsed.section || !parsed.sectionId || !parsed.pageTitle) {
|
||||
return NextResponse.json(
|
||||
{ error: 'section, sectionId and pageTitle are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
try {
|
||||
await reserveAiUsageOrThrow(session.user.id, 'interactive_page', {
|
||||
lane: 'chat',
|
||||
amount: 3,
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof QuotaExceededError) {
|
||||
return NextResponse.json(err.toJSON(), { status: 402 })
|
||||
}
|
||||
if (
|
||||
err instanceof QuotaServiceUnavailableError ||
|
||||
process.env.NODE_ENV === 'production'
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'QUOTA_SERVICE_UNAVAILABLE' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
console.error('[/api/ai/interactive-page] Quota check error (fail-open):', err)
|
||||
}
|
||||
const result = await generatePageSection({
|
||||
content: parsed.content,
|
||||
lang,
|
||||
provider,
|
||||
pageTitle: parsed.pageTitle,
|
||||
sectionId: parsed.sectionId,
|
||||
section: parsed.section,
|
||||
})
|
||||
if (!result.ok) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'section_generation_failed',
|
||||
issues: result.issues?.slice(0, 12),
|
||||
attempts: result.attempts,
|
||||
},
|
||||
{ status: 422 }
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ section: result.section, attempts: result.attempts })
|
||||
}
|
||||
|
||||
// ── Legacy deterministic full page (fallback, no LLM → no quota) ─────
|
||||
const result = await generateInteractivePageFromContent({
|
||||
content: parsed.content,
|
||||
lang,
|
||||
provider,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
if (result.error === 'unsuitable_content') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'unsuitable_content',
|
||||
reason: result.reason,
|
||||
attempts: result.attempts,
|
||||
},
|
||||
{ status: 422 }
|
||||
)
|
||||
}
|
||||
const first = result.issues?.[0]
|
||||
const reason =
|
||||
result.reason ||
|
||||
(first
|
||||
? `${first.path ? first.path + ': ' : ''}${first.message}`
|
||||
: undefined)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
result.error === 'timeout'
|
||||
? 'La génération a pris trop de temps — réessayez'
|
||||
: reason ||
|
||||
'La génération a produit une page invalide',
|
||||
reason,
|
||||
issues: result.issues?.slice(0, 12),
|
||||
attempts: result.attempts,
|
||||
},
|
||||
{ status: 422 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
page: result.page,
|
||||
attempts: result.attempts,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Requête invalide',
|
||||
issues: error.issues.slice(0, 10).map((i) => ({
|
||||
path: i.path.join('.'),
|
||||
message: i.message,
|
||||
})),
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Erreur génération interactive page'
|
||||
console.error('[/api/ai/interactive-page]', error)
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
62
memento-note/app/api/ai/notebook-slides/route.ts
Normal file
62
memento-note/app/api/ai/notebook-slides/route.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
||||
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||
import { generateSlideDeck } from '@/lib/ai/services/slide-deck-generator.service'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return aiConsentForbiddenResponse()
|
||||
}
|
||||
const userId = session.user.id
|
||||
|
||||
const body = await request.json()
|
||||
const { notebookId, theme, purpose, audience, slideCount, language } = body
|
||||
|
||||
if (!notebookId || typeof notebookId !== 'string') {
|
||||
return NextResponse.json({ error: 'notebookId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const intent =
|
||||
purpose || audience || slideCount
|
||||
? {
|
||||
purpose: purpose || 'auto',
|
||||
audience: audience || 'auto',
|
||||
slideCount: typeof slideCount === 'number' ? slideCount : undefined,
|
||||
template: 'auto' as const,
|
||||
}
|
||||
: null
|
||||
|
||||
const result = await withAiQuota(
|
||||
userId,
|
||||
'slide_generate',
|
||||
() =>
|
||||
generateSlideDeck({
|
||||
userId,
|
||||
sourceNotebookId: notebookId,
|
||||
theme: theme && theme !== 'auto' ? theme : null,
|
||||
lang: language === 'en' ? 'en' : 'fr',
|
||||
intent,
|
||||
}),
|
||||
{ lane: 'chat' },
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json({ error: result.error || 'Failed to generate slides' }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json(result)
|
||||
} catch (error: unknown) {
|
||||
const quotaResp = handleQuotaHttpError(error)
|
||||
if (quotaResp) return quotaResp
|
||||
console.error('[NotebookSlides] Error:', error)
|
||||
const message = error instanceof Error ? error.message : 'Failed to generate slides'
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { rateLimit } from '@/lib/rate-limit'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { notebookSummaryService } from '@/lib/ai/services'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
import { hasUserAiConsent, aiConsentForbiddenJson } from '@/lib/consent/server-consent'
|
||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||
|
||||
/**
|
||||
@@ -20,10 +20,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'ai_consent_required' },
|
||||
{ status: 403 }
|
||||
)
|
||||
return aiConsentForbiddenJson()
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
@@ -33,19 +33,35 @@ export async function POST(request: NextRequest) {
|
||||
throw err
|
||||
}
|
||||
|
||||
// language = UI locale (fallback only). Content language = language of the topic string.
|
||||
const result = await notebookWizardService.generateCarnet(
|
||||
profile as WizardProfile,
|
||||
topic,
|
||||
{ level, count, language: language || 'fr' }
|
||||
)
|
||||
|
||||
// Prefer AI short title over raw user prompt (notebookName from client is often the full message)
|
||||
const resolvedName = (
|
||||
result.notebookName?.trim()
|
||||
|| (notebookName && notebookName.trim().length <= 60 ? notebookName.trim() : '')
|
||||
|| result.notes[0]?.title?.trim()
|
||||
|| topic.trim().slice(0, 60)
|
||||
).slice(0, 80)
|
||||
|
||||
// Place new notebook at top of manual order (before existing min order)
|
||||
const minOrder = await prisma.notebook.aggregate({
|
||||
where: { userId: session.user.id, trashedAt: null },
|
||||
_min: { order: true },
|
||||
})
|
||||
const nextOrder = (minOrder._min.order ?? 0) - 1
|
||||
|
||||
// 1. Create notebook
|
||||
const notebook = await prisma.notebook.create({
|
||||
data: {
|
||||
name: notebookName || topic,
|
||||
name: resolvedName,
|
||||
icon: notebookIcon || '📚',
|
||||
userId: session.user.id,
|
||||
order: 0,
|
||||
order: nextOrder,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -87,6 +103,8 @@ export async function POST(request: NextRequest) {
|
||||
notebookId: notebook.id,
|
||||
type: 'richtext',
|
||||
order: noteIndex++,
|
||||
autoGenerated: true,
|
||||
aiProvider: 'notebook-wizard',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { auth } from '@/auth'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { getTagsProvider } from '@/lib/ai/factory'
|
||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||
|
||||
export type PersonaId = 'engineer' | 'financial' | 'customer' | 'skeptic' | 'optimist'
|
||||
|
||||
@@ -70,7 +70,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
||||
return aiConsentForbiddenResponse()
|
||||
}
|
||||
|
||||
const { content, title, personaId } = await request.json()
|
||||
|
||||
@@ -3,7 +3,7 @@ import { auth } from '@/auth'
|
||||
import { paragraphRefactorService } from '@/lib/ai/services/paragraph-refactor.service'
|
||||
import { getAISettings } from '@/app/actions/ai-settings'
|
||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -14,7 +14,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
||||
return aiConsentForbiddenResponse()
|
||||
}
|
||||
|
||||
// Respect user's paragraphRefactor toggle (Assistant IA)
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getSystemConfig } from '@/lib/config'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { reserveUsageOrThrow, QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||
|
||||
export const maxDuration = 30
|
||||
|
||||
@@ -41,10 +41,7 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return new Response(JSON.stringify({ error: 'ai_consent_required' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
return aiConsentForbiddenResponse()
|
||||
}
|
||||
const userId = session.user.id
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { notebookSuggestionService } from '@/lib/ai/services/notebook-suggestion.service'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
@@ -10,9 +10,10 @@ export async function POST(req: NextRequest) {
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const userId = session.user.id
|
||||
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
||||
return aiConsentForbiddenResponse()
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
@@ -32,11 +33,11 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
const suggestedNotebook = await withAiQuota(
|
||||
session.user.id,
|
||||
userId,
|
||||
'auto_tag',
|
||||
() => notebookSuggestionService.suggestNotebook(
|
||||
noteContent,
|
||||
session.user!.id,
|
||||
userId,
|
||||
language
|
||||
),
|
||||
{ lane: 'tags' },
|
||||
|
||||
@@ -6,7 +6,7 @@ import { runLaneWithBillingUser, willUseByokForLane } from '@/lib/ai/provider-fo
|
||||
import { getSystemConfig } from '@/lib/config';
|
||||
import { z } from 'zod';
|
||||
import { checkEntitlementOrThrow, QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements';
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent';
|
||||
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent';
|
||||
|
||||
import { getAISettings } from '@/app/actions/ai-settings';
|
||||
|
||||
@@ -25,7 +25,7 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
// GDPR AI Consent check
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 });
|
||||
return aiConsentForbiddenResponse();
|
||||
}
|
||||
|
||||
const userSettings = await getAISettings(session.user.id);
|
||||
|
||||
@@ -7,26 +7,13 @@ import { getAISettings } from '@/app/actions/ai-settings'
|
||||
import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
|
||||
import { QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
|
||||
import { z } from 'zod'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||
import { countPlainWords, MIN_WORDS_FOR_TITLE_SUGGESTION, stripHtmlToPlainText } from '@/lib/text/plain-text'
|
||||
|
||||
const requestSchema = z.object({
|
||||
content: z.string().min(1, "Le contenu ne peut pas être vide"),
|
||||
})
|
||||
|
||||
/** Supprime les balises HTML pour extraire le texte brut */
|
||||
function stripHtml(html: string): string {
|
||||
return html
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Check authentication and user setting
|
||||
@@ -37,7 +24,7 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
// GDPR AI Consent check
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
||||
return aiConsentForbiddenResponse()
|
||||
}
|
||||
|
||||
const settings = await getAISettings(session.user.id)
|
||||
@@ -48,17 +35,10 @@ export async function POST(req: NextRequest) {
|
||||
const body = await req.json()
|
||||
const { content: rawContent } = requestSchema.parse(body)
|
||||
|
||||
// Nettoyer le HTML (l'éditeur TipTap envoie du HTML)
|
||||
const content = stripHtml(rawContent)
|
||||
const content = stripHtmlToPlainText(rawContent)
|
||||
|
||||
// Vérifier qu'il y a au moins 10 mots
|
||||
const wordCount = content.split(/\s+/).filter(w => w.length > 0).length
|
||||
|
||||
if (wordCount < 10) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Le contenu doit avoir au moins 10 mots' },
|
||||
{ status: 400 }
|
||||
)
|
||||
if (countPlainWords(content) < MIN_WORDS_FOR_TITLE_SUGGESTION) {
|
||||
return NextResponse.json({ suggestions: [] })
|
||||
}
|
||||
|
||||
const config = await getSystemConfig()
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { getChatProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
@@ -14,7 +14,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
||||
return aiConsentForbiddenResponse()
|
||||
}
|
||||
|
||||
const { text } = await request.json()
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { getTagsProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
@@ -13,7 +13,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
||||
return aiConsentForbiddenResponse()
|
||||
}
|
||||
|
||||
const { text, targetLanguage } = await request.json()
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@/auth';
|
||||
import { stripe } from '@/lib/stripe';
|
||||
import { resolvePriceId } from '@/lib/billing/stripe-prices';
|
||||
import { isBillingEnabled, resolvePriceId } from '@/lib/billing/stripe-prices';
|
||||
import {
|
||||
shouldOfferSubscriptionTrial,
|
||||
SUBSCRIPTION_TRIAL_DAYS,
|
||||
} from '@/lib/billing/trial';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { z } from 'zod';
|
||||
|
||||
const bodySchema = z.object({
|
||||
tier: z.enum(['PRO', 'BUSINESS']),
|
||||
interval: z.enum(['month', 'year']),
|
||||
/** Prefer hosted redirect when embedded checkout is unavailable */
|
||||
mode: z.enum(['hosted', 'embedded']).optional(),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
@@ -16,17 +22,46 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!(await isBillingEnabled())) {
|
||||
return NextResponse.json({ error: 'Billing is not enabled' }, { status: 403 });
|
||||
}
|
||||
|
||||
const secret = process.env.STRIPE_SECRET_KEY;
|
||||
if (!secret || secret === 'sk_test_placeholder') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Stripe is not configured (STRIPE_SECRET_KEY missing)' },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = bodySchema.safeParse(await req.json());
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { tier, interval } = parsed.data;
|
||||
const preferredMode = parsed.data.mode ?? 'hosted';
|
||||
const userId = session.user.id;
|
||||
const userEmail = session.user.email;
|
||||
|
||||
try {
|
||||
const priceId = await resolvePriceId(tier, interval);
|
||||
let priceId: string;
|
||||
try {
|
||||
priceId = await resolvePriceId(tier, interval);
|
||||
} catch (e) {
|
||||
console.error('[billing/create-checkout] price resolve failed:', e);
|
||||
return NextResponse.json(
|
||||
{ error: 'Stripe price IDs not configured. Set them in Admin > Billing.' },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
if (priceId.startsWith('price_mock_')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Stripe price IDs not configured. Set them in Admin > Billing.' },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
const subscription = await prisma.subscription.findUnique({ where: { userId } });
|
||||
let customerId = subscription?.stripeCustomerId ?? undefined;
|
||||
@@ -41,8 +76,7 @@ export async function POST(req: NextRequest) {
|
||||
metadata: { userId },
|
||||
});
|
||||
customerId = customer.id;
|
||||
|
||||
// Update DB to save the real Stripe customer ID
|
||||
|
||||
await prisma.subscription.upsert({
|
||||
where: { userId },
|
||||
update: { stripeCustomerId: customerId },
|
||||
@@ -52,8 +86,8 @@ export async function POST(req: NextRequest) {
|
||||
tier: 'BASIC',
|
||||
status: 'ACTIVE',
|
||||
currentPeriodStart: new Date(),
|
||||
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 3600 * 1000), // temp basic dates
|
||||
}
|
||||
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 3600 * 1000),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -61,29 +95,67 @@ export async function POST(req: NextRequest) {
|
||||
const proto = req.headers.get('x-forwarded-proto') ?? 'http';
|
||||
const origin = `${proto}://${host}`;
|
||||
|
||||
const sessionParams = {
|
||||
customer: customerId,
|
||||
mode: 'subscription' as const,
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
ui_mode: 'embedded_page',
|
||||
return_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`,
|
||||
const offerTrial = await shouldOfferSubscriptionTrial(userId);
|
||||
const subscriptionData: {
|
||||
metadata: { userId: string; tier: string };
|
||||
trial_period_days?: number;
|
||||
} = {
|
||||
metadata: { userId, tier },
|
||||
subscription_data: { metadata: { userId, tier } },
|
||||
customer_update: { address: 'auto' },
|
||||
allow_promotion_codes: true,
|
||||
};
|
||||
const checkoutSession = await stripe.checkout.sessions.create(sessionParams as any);
|
||||
|
||||
if (checkoutSession.client_secret) {
|
||||
return NextResponse.json({
|
||||
clientSecret: checkoutSession.client_secret,
|
||||
sessionId: checkoutSession.id,
|
||||
});
|
||||
if (offerTrial) {
|
||||
subscriptionData.trial_period_days = SUBSCRIPTION_TRIAL_DAYS;
|
||||
}
|
||||
|
||||
return NextResponse.json({ url: checkoutSession.url });
|
||||
// Hosted Checkout is the most reliable path (redirect). Embedded is optional.
|
||||
if (preferredMode === 'embedded') {
|
||||
try {
|
||||
const embedded = await stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
mode: 'subscription',
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
ui_mode: 'embedded' as any,
|
||||
return_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`,
|
||||
metadata: { userId, tier, trial: offerTrial ? '1' : '0' },
|
||||
subscription_data: subscriptionData,
|
||||
customer_update: { address: 'auto' },
|
||||
allow_promotion_codes: true,
|
||||
} as any);
|
||||
if (embedded.client_secret) {
|
||||
return NextResponse.json({
|
||||
clientSecret: embedded.client_secret,
|
||||
sessionId: embedded.id,
|
||||
trialDays: offerTrial ? SUBSCRIPTION_TRIAL_DAYS : 0,
|
||||
});
|
||||
}
|
||||
} catch (embeddedErr) {
|
||||
console.warn('[billing/create-checkout] embedded failed, falling back to hosted:', embeddedErr);
|
||||
}
|
||||
}
|
||||
|
||||
const checkoutSession = await stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
mode: 'subscription',
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
success_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${origin}/settings/billing?canceled=1`,
|
||||
metadata: { userId, tier, trial: offerTrial ? '1' : '0' },
|
||||
subscription_data: subscriptionData,
|
||||
customer_update: { address: 'auto' },
|
||||
allow_promotion_codes: true,
|
||||
});
|
||||
|
||||
if (!checkoutSession.url) {
|
||||
return NextResponse.json({ error: 'Checkout session has no URL' }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
url: checkoutSession.url,
|
||||
sessionId: checkoutSession.id,
|
||||
trialDays: offerTrial ? SUBSCRIPTION_TRIAL_DAYS : 0,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[billing/create-checkout]', error);
|
||||
return NextResponse.json({ error: 'Failed to create checkout session' }, { status: 500 });
|
||||
const msg = error instanceof Error ? error.message : 'Failed to create checkout session';
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
135
memento-note/app/api/billing/create-pack-checkout/route.ts
Normal file
135
memento-note/app/api/billing/create-pack-checkout/route.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { stripe } from '@/lib/stripe'
|
||||
import { isBillingEnabled } from '@/lib/billing/stripe-prices'
|
||||
import {
|
||||
isCreditPackId,
|
||||
resolvePackPriceId,
|
||||
getCreditPack,
|
||||
} from '@/lib/billing/credit-packs'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { z } from 'zod'
|
||||
|
||||
const bodySchema = z.object({
|
||||
packId: z.enum(['S', 'M', 'L']),
|
||||
})
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id || !session.user.email) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!(await isBillingEnabled())) {
|
||||
return NextResponse.json({ error: 'Billing is not enabled' }, { status: 403 })
|
||||
}
|
||||
|
||||
const secret = process.env.STRIPE_SECRET_KEY
|
||||
if (!secret || secret === 'sk_test_placeholder') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Stripe is not configured (STRIPE_SECRET_KEY missing)' },
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = bodySchema.safeParse(await req.json())
|
||||
if (!parsed.success || !isCreditPackId(parsed.data.packId)) {
|
||||
return NextResponse.json({ error: 'Invalid pack' }, { status: 400 })
|
||||
}
|
||||
|
||||
const packId = parsed.data.packId
|
||||
const pack = getCreditPack(packId)
|
||||
const userId = session.user.id
|
||||
const userEmail = session.user.email
|
||||
|
||||
try {
|
||||
let priceId: string
|
||||
try {
|
||||
priceId = await resolvePackPriceId(packId)
|
||||
} catch (e) {
|
||||
console.error('[billing/create-pack-checkout] price resolve failed:', e)
|
||||
return NextResponse.json(
|
||||
{ error: 'Credit pack price IDs not configured. Set them in Admin > Billing.' },
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
|
||||
if (priceId.startsWith('price_mock_')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Credit pack price IDs not configured. Set them in Admin > Billing.' },
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
|
||||
const subscription = await prisma.subscription.findUnique({ where: { userId } })
|
||||
let customerId = subscription?.stripeCustomerId ?? undefined
|
||||
|
||||
if (customerId && customerId.startsWith('cus_mock')) {
|
||||
customerId = undefined
|
||||
}
|
||||
|
||||
if (!customerId) {
|
||||
const customer = await stripe.customers.create({
|
||||
email: userEmail,
|
||||
metadata: { userId },
|
||||
})
|
||||
customerId = customer.id
|
||||
|
||||
await prisma.subscription.upsert({
|
||||
where: { userId },
|
||||
update: { stripeCustomerId: customerId },
|
||||
create: {
|
||||
userId,
|
||||
stripeCustomerId: customerId,
|
||||
tier: 'BASIC',
|
||||
status: 'ACTIVE',
|
||||
currentPeriodStart: new Date(),
|
||||
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 3600 * 1000),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const host = req.headers.get('x-forwarded-host') ?? req.headers.get('host') ?? 'localhost:3000'
|
||||
const proto = req.headers.get('x-forwarded-proto') ?? 'http'
|
||||
const origin = `${proto}://${host}`
|
||||
|
||||
const checkoutSession = await stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
mode: 'payment',
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
success_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}&pack=1`,
|
||||
cancel_url: `${origin}/settings/billing?canceled=1`,
|
||||
metadata: {
|
||||
userId,
|
||||
type: 'credit_pack',
|
||||
packId,
|
||||
credits: String(pack.credits),
|
||||
},
|
||||
payment_intent_data: {
|
||||
metadata: {
|
||||
userId,
|
||||
type: 'credit_pack',
|
||||
packId,
|
||||
credits: String(pack.credits),
|
||||
},
|
||||
},
|
||||
customer_update: { address: 'auto' },
|
||||
allow_promotion_codes: true,
|
||||
})
|
||||
|
||||
if (!checkoutSession.url) {
|
||||
return NextResponse.json({ error: 'Checkout session has no URL' }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
url: checkoutSession.url,
|
||||
sessionId: checkoutSession.id,
|
||||
packId,
|
||||
credits: pack.credits,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[billing/create-pack-checkout]', error)
|
||||
const msg = error instanceof Error ? error.message : 'Failed to create pack checkout'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,10 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@/auth';
|
||||
import { getUserInfo, getEffectiveTier } from '@/lib/entitlements';
|
||||
import { stripe } from '@/lib/stripe';
|
||||
import type Stripe from 'stripe';
|
||||
import { priceIdToTier, getDynamicPrices, isBillingEnabled } from '@/lib/billing/stripe-prices';
|
||||
import { getDynamicPrices, isBillingEnabled } from '@/lib/billing/stripe-prices';
|
||||
import { syncSubscriptionFromStripe } from '@/lib/billing/sync-subscription-from-stripe';
|
||||
import { shouldOfferSubscriptionTrial, SUBSCRIPTION_TRIAL_DAYS } from '@/lib/billing/trial';
|
||||
import { periodsDiffer, rollBillingPeriod } from '@/lib/billing/period';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -24,68 +26,107 @@ export async function GET(req: NextRequest) {
|
||||
if (checkoutSession.subscription && checkoutSession.status === 'complete') {
|
||||
const subId = typeof checkoutSession.subscription === 'string'
|
||||
? checkoutSession.subscription
|
||||
: (checkoutSession.subscription as any).id;
|
||||
: (checkoutSession.subscription as { id: string }).id;
|
||||
|
||||
const sub = await stripe.subscriptions.retrieve(subId) as any;
|
||||
const priceId = sub.items.data[0].price.id;
|
||||
const tier = (await priceIdToTier(priceId)) || (checkoutSession.metadata?.tier as any) || 'PRO';
|
||||
|
||||
const currentPeriodStartTimestamp =
|
||||
sub.current_period_start ??
|
||||
sub.items?.data?.[0]?.current_period_start ??
|
||||
sub.start_date ??
|
||||
Math.floor(Date.now() / 1000);
|
||||
|
||||
const currentPeriodEndTimestamp =
|
||||
sub.current_period_end ??
|
||||
sub.items?.data?.[0]?.current_period_end ??
|
||||
(currentPeriodStartTimestamp + 30 * 24 * 3600);
|
||||
|
||||
await prisma.subscription.upsert({
|
||||
where: { userId },
|
||||
update: {
|
||||
tier,
|
||||
status: 'ACTIVE',
|
||||
stripeCustomerId: checkoutSession.customer as string,
|
||||
stripeSubscriptionId: sub.id,
|
||||
stripePriceId: priceId,
|
||||
currentPeriodStart: new Date(currentPeriodStartTimestamp * 1000),
|
||||
currentPeriodEnd: new Date(currentPeriodEndTimestamp * 1000),
|
||||
canceledAt: sub.canceled_at ? new Date(sub.canceled_at * 1000) : null,
|
||||
cancelAtPeriodEnd: sub.cancel_at_period_end,
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
tier,
|
||||
status: 'ACTIVE',
|
||||
stripeCustomerId: checkoutSession.customer as string,
|
||||
stripeSubscriptionId: sub.id,
|
||||
stripePriceId: priceId,
|
||||
currentPeriodStart: new Date(currentPeriodStartTimestamp * 1000),
|
||||
currentPeriodEnd: new Date(currentPeriodEndTimestamp * 1000),
|
||||
},
|
||||
});
|
||||
const sub = await stripe.subscriptions.retrieve(subId);
|
||||
const syncUserId =
|
||||
(checkoutSession.metadata?.userId as string | undefined) ??
|
||||
(sub.metadata?.userId as string | undefined) ??
|
||||
userId;
|
||||
if (syncUserId === userId) {
|
||||
await syncSubscriptionFromStripe(sub, userId);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[billing/status] Failed to sync Stripe session:', err);
|
||||
}
|
||||
}
|
||||
try {
|
||||
let subscription = await prisma.subscription.findUnique({ where: { userId } });
|
||||
const stripeSecret = process.env.STRIPE_SECRET_KEY;
|
||||
const canTalkToStripe = !!stripeSecret && stripeSecret !== 'sk_test_placeholder';
|
||||
|
||||
if (
|
||||
subscription?.stripeSubscriptionId
|
||||
&& !subscription.stripeSubscriptionId.startsWith('sub_mock')
|
||||
&& canTalkToStripe
|
||||
) {
|
||||
try {
|
||||
const live = await stripe.subscriptions.retrieve(subscription.stripeSubscriptionId);
|
||||
await syncSubscriptionFromStripe(live, userId);
|
||||
subscription = await prisma.subscription.findUnique({ where: { userId } });
|
||||
} catch (syncErr) {
|
||||
console.error('[billing/status] live Stripe period sync failed:', syncErr);
|
||||
}
|
||||
} else if (subscription && !subscription.cancelAtPeriodEnd) {
|
||||
const rolled = rollBillingPeriod(subscription.currentPeriodStart);
|
||||
if (periodsDiffer(rolled, subscription)) {
|
||||
subscription = await prisma.subscription.update({
|
||||
where: { userId },
|
||||
data: {
|
||||
currentPeriodStart: rolled.currentPeriodStart,
|
||||
currentPeriodEnd: rolled.currentPeriodEnd,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const { tier, status, currentPeriodEnd } = await getUserInfo(userId);
|
||||
const effectiveTier = await getEffectiveTier(userId);
|
||||
const subscription = await prisma.subscription.findUnique({ where: { userId } });
|
||||
const prices = await getDynamicPrices();
|
||||
const billingEnabled = await isBillingEnabled();
|
||||
const { getPackPublicPrices } = await import('@/lib/billing/credit-packs');
|
||||
const creditPacks = await getPackPublicPrices();
|
||||
|
||||
// Si retour checkout pack : créditer au cas où le webhook n'a pas encore tourné
|
||||
if (sessionId && sessionId.startsWith('cs_')) {
|
||||
try {
|
||||
const checkoutSession = await stripe.checkout.sessions.retrieve(sessionId);
|
||||
if (
|
||||
checkoutSession.mode === 'payment' &&
|
||||
checkoutSession.status === 'complete' &&
|
||||
(checkoutSession.metadata?.type === 'credit_pack' || checkoutSession.metadata?.packId) &&
|
||||
checkoutSession.metadata?.userId === userId
|
||||
) {
|
||||
const { addPurchasedCredits } = await import('@/lib/credits');
|
||||
const {
|
||||
getCreditPack,
|
||||
isCreditPackId,
|
||||
} = await import('@/lib/billing/credit-packs');
|
||||
const packId = checkoutSession.metadata?.packId;
|
||||
let credits = Number(checkoutSession.metadata?.credits ?? 0);
|
||||
if (packId && isCreditPackId(packId) && (!Number.isFinite(credits) || credits <= 0)) {
|
||||
credits = getCreditPack(packId).credits;
|
||||
}
|
||||
if (Number.isFinite(credits) && credits > 0) {
|
||||
await addPurchasedCredits(userId, credits, {
|
||||
stripeSessionId: checkoutSession.id,
|
||||
packId: packId ?? null,
|
||||
type: 'credit_pack',
|
||||
source: 'billing_status_sync',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (packSyncErr) {
|
||||
console.error('[billing/status] pack sync failed:', packSyncErr);
|
||||
}
|
||||
}
|
||||
|
||||
const trialEligible = await shouldOfferSubscriptionTrial(userId);
|
||||
|
||||
return NextResponse.json({
|
||||
tier,
|
||||
effectiveTier,
|
||||
status,
|
||||
currentPeriodEnd: currentPeriodEnd ?? null,
|
||||
currentPeriodStart: subscription?.currentPeriodStart ?? null,
|
||||
currentPeriodEnd: subscription?.currentPeriodEnd?.toISOString() ?? currentPeriodEnd?.toISOString() ?? null,
|
||||
currentPeriodStart: subscription?.currentPeriodStart?.toISOString() ?? null,
|
||||
cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd ?? false,
|
||||
hasStripeSubscription: !!subscription?.stripeSubscriptionId,
|
||||
trialEndsAt: subscription?.trialEndsAt?.toISOString() ?? null,
|
||||
trialEligible,
|
||||
trialDays: trialEligible ? SUBSCRIPTION_TRIAL_DAYS : 0,
|
||||
prices,
|
||||
creditPacks,
|
||||
billingEnabled,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -6,11 +6,81 @@ import {
|
||||
handleSubscriptionDeleted,
|
||||
resolveUserIdFromStripeEvent,
|
||||
} from '@/lib/billing/sync-subscription-from-stripe';
|
||||
import { addPurchasedCredits } from '@/lib/credits';
|
||||
import {
|
||||
getCreditPack,
|
||||
isCreditPackId,
|
||||
resolvePackFromPriceId,
|
||||
} from '@/lib/billing/credit-packs';
|
||||
import { sendTrialEndingReminder } from '@/lib/billing/trial-reminder-email';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
async function fulfillCreditPackPurchase(session: Stripe.Checkout.Session) {
|
||||
if (session.mode !== 'payment') return;
|
||||
if (session.payment_status && session.payment_status !== 'paid' && session.payment_status !== 'no_payment_required') {
|
||||
console.warn('[billing/webhook] pack checkout not paid yet', session.id, session.payment_status);
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = session.metadata?.userId as string | undefined;
|
||||
if (!userId) {
|
||||
console.warn('[billing/webhook] credit pack: no userId', session.id);
|
||||
return;
|
||||
}
|
||||
|
||||
let packId = session.metadata?.packId as string | undefined;
|
||||
let credits = Number(session.metadata?.credits ?? 0);
|
||||
|
||||
if ((!packId || !Number.isFinite(credits) || credits <= 0) && session.line_items == null) {
|
||||
try {
|
||||
const full = await stripe.checkout.sessions.retrieve(session.id, {
|
||||
expand: ['line_items.data.price'],
|
||||
});
|
||||
const priceId = full.line_items?.data?.[0]?.price?.id;
|
||||
if (priceId) {
|
||||
const resolved = await resolvePackFromPriceId(priceId);
|
||||
if (resolved) {
|
||||
packId = resolved.packId;
|
||||
credits = resolved.credits;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[billing/webhook] expand line_items failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (packId && isCreditPackId(packId) && (!Number.isFinite(credits) || credits <= 0)) {
|
||||
credits = getCreditPack(packId).credits;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(credits) || credits <= 0) {
|
||||
console.warn('[billing/webhook] credit pack: invalid credits', session.id, { packId, credits });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await addPurchasedCredits(userId, credits, {
|
||||
stripeSessionId: session.id,
|
||||
packId: packId ?? null,
|
||||
type: 'credit_pack',
|
||||
paymentIntent:
|
||||
typeof session.payment_intent === 'string'
|
||||
? session.payment_intent
|
||||
: session.payment_intent?.id ?? null,
|
||||
});
|
||||
|
||||
if (result.applied) {
|
||||
console.info('[billing/webhook] credited pack', {
|
||||
userId,
|
||||
credits: result.units,
|
||||
packId,
|
||||
sessionId: session.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.text();
|
||||
const headersList = await headers();
|
||||
@@ -49,6 +119,11 @@ export async function POST(req: NextRequest) {
|
||||
} else {
|
||||
console.warn('[billing/webhook] checkout.session.completed: no userId in metadata', session.id);
|
||||
}
|
||||
} else if (
|
||||
session.mode === 'payment' &&
|
||||
(session.metadata?.type === 'credit_pack' || session.metadata?.packId)
|
||||
) {
|
||||
await fulfillCreditPackPurchase(session);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -86,6 +161,48 @@ export async function POST(req: NextRequest) {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'customer.subscription.trial_will_end': {
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
const userId = await resolveUserIdFromStripeEvent(subscription);
|
||||
if (!userId) {
|
||||
console.warn('[billing/webhook] trial_will_end: no userId', subscription.id);
|
||||
break;
|
||||
}
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
email: true,
|
||||
name: true,
|
||||
aiSettings: { select: { preferredLanguage: true } },
|
||||
},
|
||||
});
|
||||
if (!user?.email) break;
|
||||
|
||||
await syncSubscriptionFromStripe(subscription, userId);
|
||||
|
||||
const trialEndsAt = subscription.trial_end
|
||||
? new Date(subscription.trial_end * 1000)
|
||||
: new Date(Date.now() + 3 * 24 * 3600 * 1000);
|
||||
|
||||
const appUrl =
|
||||
process.env.NEXTAUTH_URL?.replace(/\/$/, '') ||
|
||||
process.env.NEXT_PUBLIC_APP_URL?.replace(/\/$/, '') ||
|
||||
'https://memento-note.com';
|
||||
|
||||
const preferred = user.aiSettings?.preferredLanguage ?? 'en';
|
||||
const mailResult = await sendTrialEndingReminder({
|
||||
to: user.email,
|
||||
name: user.name,
|
||||
trialEndsAt,
|
||||
billingUrl: `${appUrl}/settings/billing`,
|
||||
locale: preferred === 'auto' ? 'en' : preferred,
|
||||
});
|
||||
if (!mailResult.success) {
|
||||
console.error('[billing/webhook] trial reminder email failed:', mailResult.error);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ export async function GET() {
|
||||
const [
|
||||
recentNotes,
|
||||
inboxCount,
|
||||
inboxPreview,
|
||||
dueFlashcards,
|
||||
upcomingReminders,
|
||||
unviewedInsights,
|
||||
@@ -76,13 +77,20 @@ export async function GET() {
|
||||
notebookId: true, updatedAt: true, createdAt: true,
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 8,
|
||||
take: 12,
|
||||
}),
|
||||
|
||||
prisma.note.count({
|
||||
where: { userId, notebookId: null, isArchived: false, trashedAt: null },
|
||||
}),
|
||||
|
||||
prisma.note.findMany({
|
||||
where: { userId, notebookId: null, isArchived: false, trashedAt: null },
|
||||
select: { id: true, title: true, content: true, notebookId: true, updatedAt: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 3,
|
||||
}),
|
||||
|
||||
prisma.flashcard.count({
|
||||
where: { deck: { userId }, nextReviewAt: { lte: now } },
|
||||
}),
|
||||
@@ -124,7 +132,7 @@ export async function GET() {
|
||||
|
||||
prisma.note.findMany({
|
||||
where: { userId, isPinned: true, trashedAt: null, isArchived: false },
|
||||
select: { id: true, title: true, notebookId: true, updatedAt: true },
|
||||
select: { id: true, title: true, content: true, notebookId: true, updatedAt: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 5,
|
||||
}),
|
||||
@@ -162,7 +170,10 @@ export async function GET() {
|
||||
insightRows = [...insightRows, ...viewedRecent]
|
||||
}
|
||||
|
||||
const notebookIds = [...new Set(recentNotes.map(n => n.notebookId).filter(Boolean))] as string[]
|
||||
const notebookIds = [...new Set([
|
||||
...recentNotes.map(n => n.notebookId),
|
||||
...pinnedNotes.map(n => n.notebookId),
|
||||
].filter(Boolean))] as string[]
|
||||
const notebooks = notebookIds.length > 0
|
||||
? await prisma.notebook.findMany({
|
||||
where: { id: { in: notebookIds } },
|
||||
@@ -177,6 +188,13 @@ export async function GET() {
|
||||
notebook: n.notebookId ? notebookMap.get(n.notebookId) || null : null,
|
||||
})),
|
||||
inboxCount,
|
||||
inboxPreview: inboxPreview.map(n => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
excerpt: excerptNoteContent(n.content, 80),
|
||||
notebookId: n.notebookId,
|
||||
updatedAt: n.updatedAt.toISOString(),
|
||||
})),
|
||||
dueFlashcards,
|
||||
upcomingReminders: upcomingReminders.map(r => ({
|
||||
id: r.id,
|
||||
@@ -221,8 +239,10 @@ export async function GET() {
|
||||
pinnedNotes: pinnedNotes.map(n => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
excerpt: excerptNoteContent(n.content, 80),
|
||||
notebookId: n.notebookId,
|
||||
updatedAt: n.updatedAt.toISOString(),
|
||||
notebook: n.notebookId ? notebookMap.get(n.notebookId) || null : null,
|
||||
})),
|
||||
writingActivity,
|
||||
agentSuggestions: agentSuggestions.map(s => ({
|
||||
|
||||
@@ -22,7 +22,7 @@ export async function GET() {
|
||||
|
||||
const userId = session.user.id
|
||||
const locale = await detectUserLanguage()
|
||||
const cacheKey = `briefing:sentiment:${userId}:${locale}`
|
||||
const cacheKey = `briefing:sentiment:${userId}:${locale}:v2`
|
||||
|
||||
try {
|
||||
const cached = await redis.get(cacheKey)
|
||||
@@ -39,7 +39,7 @@ export async function GET() {
|
||||
isArchived: false,
|
||||
updatedAt: { gte: weekAgo },
|
||||
},
|
||||
select: { title: true, content: true },
|
||||
select: { id: true, title: true, content: true, notebookId: true },
|
||||
take: 20,
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
})
|
||||
@@ -64,10 +64,14 @@ export async function GET() {
|
||||
}
|
||||
|
||||
const localeLabel = locale === 'fr' ? 'French' : locale === 'en' ? 'English' : locale
|
||||
const voice = locale === 'fr'
|
||||
? 'Write summary in French, second person singular (tu). Example: « Cette semaine, tu as surtout… ». Never write « the person », « the user », or third person.'
|
||||
: `Write summary in ${localeLabel}, second person ("you"). Never write "the person", "the user", or third person.`
|
||||
const prompt = `Analyze the emotional patterns in these notes from the past week. Return ONLY valid JSON (no markdown, no code fences).
|
||||
Write "summary" and "topTopic" in ${localeLabel} (locale code: ${locale}). Keep dominantEmotion keys in English as listed.
|
||||
${voice}
|
||||
Keep dominantEmotion keys in English as listed. One short sentence for summary. Do not quote secrets, passwords, or URLs.
|
||||
|
||||
{"dominantEmotion":"focused|curious|enthusiastic|frustrated|calm|anxious|creative|reflective","sentimentScore":number from -1 to 1,"emotions":{"focused":number,"curious":number,"enthusiastic":number,"frustrated":number,"calm":number,"anxious":number,"creative":number,"reflective":number},"summary":"one sentence describing the emotional pattern","topTopic":"most discussed topic"}
|
||||
{"dominantEmotion":"focused|curious|enthusiastic|frustrated|calm|anxious|creative|reflective","sentimentScore":number from -1 to 1,"emotions":{"focused":number,"curious":number,"enthusiastic":number,"frustrated":number,"calm":number,"anxious":number,"creative":number,"reflective":number},"summary":"one sentence","topTopic":"most discussed topic"}
|
||||
|
||||
Notes:
|
||||
${snippets.slice(0, 3000)}`
|
||||
@@ -84,7 +88,16 @@ ${snippets.slice(0, 3000)}`
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(jsonMatch[0])
|
||||
const payload = { available: true, ...parsed }
|
||||
const relatedNotes = recentNotes.slice(0, 2).map(n => {
|
||||
const title = n.title?.trim()
|
||||
const plain = n.content.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
return {
|
||||
id: n.id,
|
||||
title: title || (plain ? (plain.length > 80 ? `${plain.slice(0, 80).trim()}…` : plain) : null),
|
||||
notebookId: n.notebookId,
|
||||
}
|
||||
})
|
||||
const payload = { available: true, ...parsed, relatedNotes }
|
||||
try { await redis.setex(cacheKey, CACHE_TTL_SEC, JSON.stringify(payload)) } catch {}
|
||||
return NextResponse.json(payload)
|
||||
} catch (error) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getChatProvider } from '@/lib/ai/factory'
|
||||
import { semanticSearchService } from '@/lib/ai/services/semantic-search.service'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||
import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n'
|
||||
import { toolRegistry } from '@/lib/ai/tools'
|
||||
import { reserveAiUsageOrThrow, handleQuotaHttpError } from '@/lib/ai-quota'
|
||||
@@ -65,10 +65,7 @@ export async function POST(req: Request) {
|
||||
|
||||
// GDPR AI Consent check
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return new Response(JSON.stringify({ error: 'ai_consent_required' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
return aiConsentForbiddenResponse()
|
||||
}
|
||||
|
||||
// 1.5 Quota check (per-provider BYOK bypass — only when BYOK will be used for resolved provider)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { extractArticleFromHtml } from '@/lib/clip/extract-article'
|
||||
import { extractArticleFromHtml, makeExcerpt } from '@/lib/clip/extract-article'
|
||||
import { analyzeClipContent } from '@/lib/clip/analyze-clip'
|
||||
import { resolveClipLocale, wrapClipPlainParagraph } from '@/lib/clip/rtl-content'
|
||||
|
||||
@@ -92,7 +92,7 @@ export async function POST(request: NextRequest) {
|
||||
tags: analysis.tags,
|
||||
readingTime: analysis.readingTimeMinutes,
|
||||
content: contentHtml,
|
||||
excerpt: textContent.slice(0, 500),
|
||||
excerpt: makeExcerpt(textContent, 500),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[POST /api/clip/analyze]', error)
|
||||
|
||||
@@ -27,10 +27,12 @@ export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const now = new Date()
|
||||
|
||||
// Due = nextRun in the past; also backfill agents whose nextRun was never set
|
||||
// (legacy / toggle bug) — but only schedule them, don't stampede-execute all nulls
|
||||
const dueAgents = await prisma.agent.findMany({
|
||||
where: {
|
||||
isEnabled: true,
|
||||
frequency: { not: 'manual' },
|
||||
frequency: { notIn: ['manual', 'one-shot'] },
|
||||
nextRun: { lte: now },
|
||||
},
|
||||
select: {
|
||||
@@ -40,23 +42,77 @@ export async function POST(request: NextRequest) {
|
||||
scheduledTime: true,
|
||||
scheduledDay: true,
|
||||
timezone: true,
|
||||
nextRun: true,
|
||||
},
|
||||
orderBy: { nextRun: 'asc' },
|
||||
})
|
||||
|
||||
if (dueAgents.length === 0) {
|
||||
return NextResponse.json({ success: true, executed: 0 })
|
||||
// Repair: set nextRun for enabled scheduled agents stuck with null
|
||||
const unscheduled = await prisma.agent.findMany({
|
||||
where: {
|
||||
isEnabled: true,
|
||||
frequency: { notIn: ['manual', 'one-shot'] },
|
||||
nextRun: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
frequency: true,
|
||||
scheduledTime: true,
|
||||
scheduledDay: true,
|
||||
timezone: true,
|
||||
},
|
||||
take: 50,
|
||||
})
|
||||
const repairedDue: typeof dueAgents = []
|
||||
for (const a of unscheduled) {
|
||||
const nextRun = calculateNextRun({
|
||||
frequency: a.frequency,
|
||||
scheduledTime: a.scheduledTime,
|
||||
scheduledDay: a.scheduledDay,
|
||||
timezone: a.timezone,
|
||||
})
|
||||
// Si le créneau calculé est déjà passé / immédiat → exécuter ce cycle
|
||||
const effective = !nextRun || nextRun <= now ? now : nextRun
|
||||
await prisma.agent.update({ where: { id: a.id }, data: { nextRun: effective } })
|
||||
if (effective <= now) {
|
||||
repairedDue.push({
|
||||
id: a.id,
|
||||
userId: a.userId,
|
||||
frequency: a.frequency,
|
||||
scheduledTime: a.scheduledTime,
|
||||
scheduledDay: a.scheduledDay,
|
||||
timezone: a.timezone,
|
||||
nextRun: effective,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const queue = [...dueAgents]
|
||||
const seen = new Set(dueAgents.map((a) => a.id))
|
||||
for (const a of repairedDue) {
|
||||
if (!seen.has(a.id)) {
|
||||
queue.push(a)
|
||||
seen.add(a.id)
|
||||
}
|
||||
}
|
||||
|
||||
if (queue.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
executed: 0,
|
||||
repairedSchedules: unscheduled.length,
|
||||
})
|
||||
}
|
||||
|
||||
const results: { id: string; success: boolean; error?: string }[] = []
|
||||
|
||||
// Execute agents sequentially (max 3 per cycle)
|
||||
for (const agent of dueAgents.slice(0, 3)) {
|
||||
// Execute agents sequentially (max 5 per cycle — rattrapage + due)
|
||||
for (const agent of queue.slice(0, 5)) {
|
||||
try {
|
||||
const { executeAgent } = await import('@/lib/ai/services/agent-executor.service')
|
||||
const result = await executeAgent(agent.id, agent.userId)
|
||||
|
||||
// Calculate and set next run
|
||||
const nextRun = calculateNextRun({
|
||||
frequency: agent.frequency,
|
||||
scheduledTime: agent.scheduledTime,
|
||||
@@ -64,7 +120,6 @@ export async function POST(request: NextRequest) {
|
||||
timezone: agent.timezone,
|
||||
})
|
||||
|
||||
|
||||
await prisma.agent.update({
|
||||
where: { id: agent.id },
|
||||
data: { nextRun },
|
||||
@@ -75,7 +130,6 @@ export async function POST(request: NextRequest) {
|
||||
const msg = error instanceof Error ? error.message : 'Unknown error'
|
||||
console.error(`[CronAgents] Agent ${agent.id} failed:`, msg)
|
||||
|
||||
// Still schedule next run even on failure
|
||||
const nextRun = calculateNextRun({
|
||||
frequency: agent.frequency,
|
||||
scheduledTime: agent.scheduledTime,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { auth } from '@/auth'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { getAISettings } from '@/app/actions/ai-settings'
|
||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||
import { generateFlashcardsFromNote, type FlashcardStyle } from '@/lib/flashcards/generate-flashcards'
|
||||
import { stripHtmlToText } from '@/lib/flashcards/deck-utils'
|
||||
|
||||
@@ -15,7 +15,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
||||
return aiConsentForbiddenResponse()
|
||||
}
|
||||
|
||||
const userSettings = await getAISettings(session.user.id)
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
|
||||
// ── Stopwords FR + EN ─────────────────────────────────────────────────────────
|
||||
const STOPWORDS = new Set([
|
||||
'le','la','les','de','du','des','un','une','et','en','au','aux','ce','se',
|
||||
'sa','son','ses','mon','ma','mes','ton','ta','tes','que','qui','quoi','dont',
|
||||
'il','elle','ils','elles','nous','vous','je','tu','on','par','pour','sur',
|
||||
'sous','avec','dans','est','sont','pas','ne','plus','très','tout','comme',
|
||||
'mais','donc','car','cet','cette','ces','leur','leurs','note','notes',
|
||||
'the','a','an','and','or','but','in','on','at','to','for','of','with','by',
|
||||
'from','is','are','was','were','be','been','have','has','had','do','does',
|
||||
'did','will','would','could','should','may','might','this','that','these',
|
||||
'those','it','its','they','them','their','he','she','we','you','not','no',
|
||||
'so','if','as','up','out','about','also','just','can','all','any','get',
|
||||
])
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
return html.replace(/<[^>]+>/g, ' ').replace(/&\w+;/g, ' ')
|
||||
}
|
||||
|
||||
function extractKeywords(text: string): Set<string> {
|
||||
return new Set(
|
||||
stripHtml(text)
|
||||
.toLowerCase()
|
||||
.split(/[\s\p{P}]+/u)
|
||||
.filter(w => w.length >= 3 && !STOPWORDS.has(w) && !/^\d+$/.test(w))
|
||||
)
|
||||
}
|
||||
|
||||
function jaccardSimilarity(a: Set<string>, b: Set<string>): number {
|
||||
if (a.size === 0 || b.size === 0) return 0
|
||||
let intersection = 0
|
||||
for (const w of a) if (b.has(w)) intersection++
|
||||
return intersection / (a.size + b.size - intersection)
|
||||
}
|
||||
|
||||
type EdgeType = 'title_mention' | 'shared_label' | 'jaccard' | 'explicit_link' | 'semantic_echo'
|
||||
|
||||
interface GraphEdge { source: string; target: string; weight: number; type: EdgeType }
|
||||
|
||||
// GET /api/graph — connexions automatiques à 3 niveaux
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const userId = session.user.id
|
||||
const { searchParams } = new URL(request.url)
|
||||
const notebookId = searchParams.get('notebookId') || undefined
|
||||
|
||||
const notes = await prisma.note.findMany({
|
||||
where: { userId, trashedAt: null, ...(notebookId ? { notebookId } : {}) },
|
||||
select: {
|
||||
id: true, title: true, content: true, notebookId: true, createdAt: true,
|
||||
labelRelations: { select: { id: true } },
|
||||
notebook: { select: { id: true, name: true } },
|
||||
},
|
||||
take: 500,
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
})
|
||||
|
||||
if (notes.length === 0) return NextResponse.json({ nodes: [], edges: [] })
|
||||
|
||||
const ids = notes.map(n => n.id)
|
||||
|
||||
// Query NoteLink manually created relationships
|
||||
const noteLinks = await (prisma as any).noteLink.findMany({
|
||||
where: {
|
||||
sourceNoteId: { in: ids },
|
||||
targetNoteId: { in: ids }
|
||||
},
|
||||
select: {
|
||||
sourceNoteId: true,
|
||||
targetNoteId: true,
|
||||
contextSnippet: true
|
||||
}
|
||||
})
|
||||
|
||||
// Query MemoryEchoInsight semantic relationships
|
||||
const echoInsights = await (prisma as any).memoryEchoInsight.findMany({
|
||||
where: {
|
||||
userId,
|
||||
dismissed: false,
|
||||
note1Id: { in: ids },
|
||||
note2Id: { in: ids }
|
||||
},
|
||||
select: {
|
||||
note1Id: true,
|
||||
note2Id: true,
|
||||
similarityScore: true,
|
||||
insight: true
|
||||
}
|
||||
})
|
||||
|
||||
// Pré-calcul
|
||||
const keywordsMap = new Map<string, Set<string>>()
|
||||
const labelMap = new Map<string, Set<string>>()
|
||||
for (const note of notes) {
|
||||
keywordsMap.set(note.id, extractKeywords(`${note.title ?? ''} ${note.content}`))
|
||||
labelMap.set(note.id, new Set(note.labelRelations.map((l: any) => l.id)))
|
||||
}
|
||||
|
||||
const EDGE_TYPE_PRIORITY: Record<EdgeType, number> = {
|
||||
explicit_link: 5,
|
||||
semantic_echo: 4,
|
||||
title_mention: 3,
|
||||
shared_label: 2,
|
||||
jaccard: 1,
|
||||
}
|
||||
|
||||
const edgeMap = new Map<string, GraphEdge>()
|
||||
function upsertEdge(a: string, b: string, weight: number, type: EdgeType) {
|
||||
const key = a < b ? `${a}--${b}` : `${b}--${a}`
|
||||
const ex = edgeMap.get(key)
|
||||
if (!ex) {
|
||||
edgeMap.set(key, { source: a < b ? a : b, target: a < b ? b : a, weight, type })
|
||||
} else {
|
||||
const exPriority = EDGE_TYPE_PRIORITY[ex.type] || 0
|
||||
const curPriority = EDGE_TYPE_PRIORITY[type] || 0
|
||||
if (curPriority > exPriority || (curPriority === exPriority && weight > ex.weight)) {
|
||||
edgeMap.set(key, { source: a < b ? a : b, target: a < b ? b : a, weight, type })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Niveau 1 : Title Mention (comme Obsidian "unlinked mentions") ──────────
|
||||
for (const noteA of notes) {
|
||||
const title = (noteA.title ?? '').trim().toLowerCase()
|
||||
if (title.length < 3) continue
|
||||
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const re = new RegExp(`(?<!\\p{L})${escaped}(?!\\p{L})`, 'ui')
|
||||
for (const noteB of notes) {
|
||||
if (noteA.id === noteB.id) continue
|
||||
if (re.test(stripHtml(noteB.content))) upsertEdge(noteA.id, noteB.id, 1.0, 'title_mention')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Niveau 2 : Labels partagés ────────────────────────────────────────────
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
for (let j = i + 1; j < ids.length; j++) {
|
||||
const la = labelMap.get(ids[i])!
|
||||
const lb = labelMap.get(ids[j])!
|
||||
const shared = [...la].filter(l => lb.has(l)).length
|
||||
if (shared > 0) upsertEdge(ids[i], ids[j], Math.min(0.5 + shared * 0.15, 0.9), 'shared_label')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Niveau 3 : Jaccard (désactivé > 500 notes) ───────────────────────────
|
||||
if (notes.length <= 500) {
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
const kwI = keywordsMap.get(ids[i])!
|
||||
const candidates: { j: number; score: number }[] = []
|
||||
for (let j = i + 1; j < ids.length; j++) {
|
||||
const score = jaccardSimilarity(kwI, keywordsMap.get(ids[j])!)
|
||||
if (score >= 0.12) candidates.push({ j, score })
|
||||
}
|
||||
candidates.sort((a, b) => b.score - a.score).slice(0, 10)
|
||||
.forEach(({ j, score }) => upsertEdge(ids[i], ids[j], score * 0.8, 'jaccard'))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Niveau 4 : WikiLinks explicites (NoteLink) ─────────────────────────────
|
||||
for (const link of noteLinks) {
|
||||
upsertEdge(link.sourceNoteId, link.targetNoteId, 1.0, 'explicit_link')
|
||||
}
|
||||
|
||||
// ── Niveau 5 : Échos sémantiques IA (MemoryEchoInsight) ────────────────────
|
||||
for (const echo of echoInsights) {
|
||||
upsertEdge(echo.note1Id, echo.note2Id, echo.similarityScore, 'semantic_echo')
|
||||
}
|
||||
|
||||
const degreeMap = new Map<string, number>()
|
||||
for (const e of edgeMap.values()) {
|
||||
degreeMap.set(e.source, (degreeMap.get(e.source) ?? 0) + 1)
|
||||
degreeMap.set(e.target, (degreeMap.get(e.target) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const nodes = notes.map(n => ({
|
||||
id: n.id,
|
||||
title: n.title || 'Sans titre',
|
||||
notebookId: n.notebookId,
|
||||
createdAt: n.createdAt,
|
||||
degree: degreeMap.get(n.id) ?? 0,
|
||||
}))
|
||||
|
||||
// Build clusters (notebooks)
|
||||
const notebookMap = new Map<string, string>()
|
||||
for (const n of notes) {
|
||||
if (n.notebook) notebookMap.set(n.notebook.id, n.notebook.name)
|
||||
}
|
||||
const clusters = [...notebookMap.entries()].map(([id, name]) => ({ id, name }))
|
||||
|
||||
return NextResponse.json({ nodes, edges: Array.from(edgeMap.values()), clusters })
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
|
||||
const WIKILINK_RE = /\[\[([^\]|#]+?)(?:[|#][^\]]+)?\]\]/g
|
||||
|
||||
function extractWikilinks(content: string): { title: string; snippet: string }[] {
|
||||
const plain = content.replace(/<[^>]+>/g, ' ')
|
||||
const results: { title: string; snippet: string }[] = []
|
||||
const seen = new Set<string>()
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
WIKILINK_RE.lastIndex = 0
|
||||
while ((match = WIKILINK_RE.exec(plain)) !== null) {
|
||||
const title = match[1].trim()
|
||||
if (!title || seen.has(title.toLowerCase())) continue
|
||||
seen.add(title.toLowerCase())
|
||||
const start = Math.max(0, match.index - 50)
|
||||
const end = Math.min(plain.length, match.index + match[0].length + 50)
|
||||
const snippet = plain.slice(start, end).replace(/\s+/g, ' ').trim()
|
||||
results.push({ title, snippet })
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/graph/sync-all
|
||||
* Batch-sync [[wikilinks]] for ALL notes of the authenticated user.
|
||||
* Call once to populate the NoteLink table from existing notes.
|
||||
*/
|
||||
export async function POST() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const userId = session.user.id
|
||||
|
||||
// Get all non-trashed notes with content
|
||||
const notes = await prisma.note.findMany({
|
||||
where: { userId, trashedAt: null, content: { not: '' } },
|
||||
select: { id: true, content: true, notebookId: true },
|
||||
})
|
||||
|
||||
let totalLinks = 0
|
||||
|
||||
for (const note of notes) {
|
||||
if (!note.content.includes('[[')) continue
|
||||
|
||||
const wikilinks = extractWikilinks(note.content)
|
||||
if (wikilinks.length === 0) continue
|
||||
|
||||
for (const { title, snippet } of wikilinks) {
|
||||
const targetNote = await prisma.note.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
title: { equals: title, mode: 'insensitive' },
|
||||
trashedAt: null,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (!targetNote) {
|
||||
// Skip stubs in batch sync — we only link existing notes
|
||||
continue
|
||||
}
|
||||
|
||||
if (targetNote.id === note.id) continue
|
||||
|
||||
try {
|
||||
await (prisma as any).noteLink.upsert({
|
||||
where: { sourceNoteId_targetNoteId: { sourceNoteId: note.id, targetNoteId: targetNote.id } },
|
||||
update: { contextSnippet: snippet },
|
||||
create: { sourceNoteId: note.id, targetNoteId: targetNote.id, contextSnippet: snippet },
|
||||
})
|
||||
totalLinks++
|
||||
} catch {
|
||||
// ignore duplicate constraint errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ synced: notes.length, links: totalLinks })
|
||||
}
|
||||
@@ -4,9 +4,13 @@ import prisma from '@/lib/prisma'
|
||||
import { contentModerationService, type ModerationResult } from '@/lib/ai/services/content-moderation.service'
|
||||
import { publishEnhanceService } from '@/lib/ai/services/publish-enhance.service'
|
||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
import { isPublishTemplateId } from '@/lib/publish/types'
|
||||
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||
import { isPublishTemplateId, isInteractivePageTemplate } from '@/lib/publish/types'
|
||||
import { computePublishedSourceHash, renderPublishedTemplate, renderRewrittenTemplate } from '@/lib/publish/template-render'
|
||||
import { validateInteractivePage, type PageSpecV1, type PageValidationResult } from '@/lib/interactive-page'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { getSlidesProvider } from '@/lib/ai/factory'
|
||||
import { generateInteractivePageFromContent } from '@/lib/ai/services/interactive-page-generate.service'
|
||||
|
||||
const MODERATION_TIMEOUT_MS = 12_000
|
||||
|
||||
@@ -88,18 +92,69 @@ async function updateNotePublishState(noteId: string, data: PublishUpdateData) {
|
||||
}
|
||||
}
|
||||
|
||||
/** All human-facing text of a PageSpecV1 — fed to moderation. */
|
||||
function collectPageText(page: PageSpecV1): string[] {
|
||||
const out: string[] = [
|
||||
page.hero.kicker,
|
||||
page.hero.title,
|
||||
page.hero.subtitle ?? '',
|
||||
page.hero.meta ?? '',
|
||||
page.footer ?? '',
|
||||
]
|
||||
if (page.overview) {
|
||||
out.push(page.overview.lead)
|
||||
for (const c of page.overview.cards) out.push(c.badge, c.title, c.body)
|
||||
}
|
||||
for (const section of page.sections) {
|
||||
out.push(section.title)
|
||||
for (const b of section.blocks) {
|
||||
if (b.type === 'prose') out.push(b.md)
|
||||
else if (b.type === 'formula') out.push(b.caption ?? '')
|
||||
else if (b.type === 'callout') out.push(b.title, b.md)
|
||||
else if (b.type === 'demo') {
|
||||
out.push(b.caption ?? '', b.demo.disclaimer ?? '')
|
||||
for (const act of b.demo.acts) {
|
||||
out.push(act.title)
|
||||
for (const st of act.steps) out.push(st.speak)
|
||||
}
|
||||
for (const panel of b.demo.scene.panels) {
|
||||
if (panel.type === 'svg-scene') {
|
||||
for (const n of panel.payload.nodes) out.push(n.label ?? '')
|
||||
}
|
||||
}
|
||||
} else if (b.type === 'chart') {
|
||||
out.push(b.caption ?? '')
|
||||
for (const s of b.payload.series) out.push(s.label ?? '')
|
||||
} else if (b.type === 'stats') {
|
||||
for (const it of b.items) out.push(it.value, it.label)
|
||||
} else if (b.type === 'table') {
|
||||
out.push(b.caption ?? '', ...b.columns, ...b.rows.flat())
|
||||
} else if (b.type === 'image') {
|
||||
out.push(b.alt, b.caption ?? '')
|
||||
} else if (b.type === 'sim') {
|
||||
out.push(b.caption ?? '', b.sim.title ?? '', b.sim.disclaimer ?? '')
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.filter((s) => s && s.trim())
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const body = await request.json()
|
||||
const { noteId, action, mode, template, language, rewrite } = body as {
|
||||
const body = await request.json().catch(() => null)
|
||||
if (!body || typeof body !== 'object') {
|
||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||
}
|
||||
const { noteId, action, mode, template, language, rewrite, pageSpec } = body as {
|
||||
noteId?: string
|
||||
action?: string
|
||||
mode?: 'simple' | 'ai'
|
||||
mode?: 'simple' | 'ai' | 'interactive-page'
|
||||
template?: string
|
||||
language?: string
|
||||
rewrite?: boolean
|
||||
pageSpec?: unknown
|
||||
}
|
||||
|
||||
if (!noteId) return NextResponse.json({ error: 'noteId required' }, { status: 400 })
|
||||
@@ -111,13 +166,98 @@ export async function POST(request: NextRequest) {
|
||||
if (!note) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
|
||||
if (action === 'publish') {
|
||||
// ── Interactive page (PageSpecV1 JSON snapshot) ─────────────────────
|
||||
if (mode === 'interactive-page' || isInteractivePageTemplate(template)) {
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return aiConsentForbiddenResponse()
|
||||
}
|
||||
|
||||
let validatedPage: PageValidationResult | null = null
|
||||
if (pageSpec) {
|
||||
// Client-provided page (from the preview dialog): must validate as-is.
|
||||
// Never silently substitute a different page than the one previewed.
|
||||
const checked = validateInteractivePage(pageSpec)
|
||||
if (!checked.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: 'invalid_page_spec', issues: checked.issues.slice(0, 12) },
|
||||
{ status: 422 }
|
||||
)
|
||||
}
|
||||
validatedPage = checked
|
||||
}
|
||||
|
||||
if (!validatedPage) {
|
||||
// Deterministic generate (no LLM quota)
|
||||
const config = await getSystemConfig()
|
||||
const provider = getSlidesProvider(config)
|
||||
const generated = await generateInteractivePageFromContent({
|
||||
content: note.content || '',
|
||||
lang: language || 'fr',
|
||||
provider,
|
||||
})
|
||||
if (!generated.ok) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: generated.error || 'interactive_page_generation_failed',
|
||||
reason: generated.reason,
|
||||
issues: generated.issues?.slice(0, 12),
|
||||
},
|
||||
{ status: 422 }
|
||||
)
|
||||
}
|
||||
validatedPage = { ok: true as const, page: generated.page }
|
||||
}
|
||||
|
||||
// Moderate ALL human-facing text of the page (prose, callouts, demo
|
||||
// narration, tables, captions) — not just titles.
|
||||
const textForModeration = collectPageText(validatedPage.page).join('\n')
|
||||
|
||||
const moderation = await moderateWithFallback(
|
||||
note.title || '',
|
||||
textForModeration
|
||||
)
|
||||
if (moderation.verdict === 'blocked') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'blocked',
|
||||
reason: moderation.reason,
|
||||
categories: moderation.categories,
|
||||
},
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
if (moderation.verdict === 'flagged') {
|
||||
await notifyFlaggedAdmins(note.id, note.title || '', moderation.reason)
|
||||
}
|
||||
|
||||
const slug = await ensureSlug(note.id, note.title || '', note.publicSlug)
|
||||
const sourceHash = computePublishedSourceHash(note.content || '')
|
||||
|
||||
await updateNotePublishState(noteId, {
|
||||
isPublic: true,
|
||||
publicSlug: slug,
|
||||
publishedAt: new Date(),
|
||||
publishedContent: JSON.stringify(validatedPage.page),
|
||||
publishedTemplate: 'interactive-page',
|
||||
publishedSourceHash: sourceHash,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
slug,
|
||||
mode: 'interactive-page',
|
||||
template: 'interactive-page',
|
||||
moderation: moderation.verdict === 'flagged' ? 'flagged' : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const publishMode = mode === 'ai' ? 'ai' : 'simple'
|
||||
|
||||
if (publishMode === 'ai') {
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
||||
return aiConsentForbiddenResponse()
|
||||
}
|
||||
if (!template || !isPublishTemplateId(template)) {
|
||||
if (!template || !isPublishTemplateId(template) || isInteractivePageTemplate(template)) {
|
||||
return NextResponse.json({ error: 'Invalid template' }, { status: 400 })
|
||||
}
|
||||
|
||||
|
||||
13
memento-note/app/api/public/byok-catalog/route.ts
Normal file
13
memento-note/app/api/public/byok-catalog/route.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getPublicByokCatalog } from '@/lib/ai/byok-catalog'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/** Liste publique des fournisseurs / modèles BYOK — aucun secret. */
|
||||
export async function GET() {
|
||||
const providers = await getPublicByokCatalog()
|
||||
return NextResponse.json(
|
||||
{ providers },
|
||||
{ headers: { 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400' } },
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { writeFile, mkdir } from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { auth } from '@/auth'
|
||||
import { writeUploadMeta } from '@/lib/upload-access'
|
||||
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
|
||||
const MAX_SIZE = 5 * 1024 * 1024 // 5MB
|
||||
@@ -14,14 +15,12 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
const formData = await request.formData()
|
||||
const file = formData.get('file') as File
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No file uploaded' },
|
||||
{ status: 400 }
|
||||
)
|
||||
return NextResponse.json({ error: 'No file uploaded' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
@@ -33,7 +32,6 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
// Resolve extension from file name, falling back to MIME type (e.g. clipboard pastes)
|
||||
let ext = path.extname(file.name).toLowerCase()
|
||||
if (!['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {
|
||||
const mimeToExt: Record<string, string> = {
|
||||
@@ -44,23 +42,28 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
ext = mimeToExt[file.type] || '.png'
|
||||
}
|
||||
|
||||
const filename = `${randomUUID()}${ext}`
|
||||
|
||||
// Ensure directory exists (data/uploads is outside public/, served via API route)
|
||||
const uploadDir = path.join(process.cwd(), 'data/uploads/notes')
|
||||
// Ownership in path: /uploads/notes/{userId}/{filename}
|
||||
const relativeUnderNotes = `${userId}/${filename}`
|
||||
const uploadDir = path.join(process.cwd(), 'data', 'uploads', 'notes', userId)
|
||||
await mkdir(uploadDir, { recursive: true })
|
||||
|
||||
|
||||
const filePath = path.join(uploadDir, filename)
|
||||
await writeFile(filePath, buffer)
|
||||
await writeUploadMeta(relativeUnderNotes, userId)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
url: `/uploads/notes/${filename}`
|
||||
const url = `/uploads/notes/${relativeUnderNotes}`
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
url,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[upload]', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to upload file' },
|
||||
{ status: 500 }
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,16 +21,30 @@ export async function GET(
|
||||
const session = await auth()
|
||||
const { path: segments } = await params
|
||||
|
||||
// Only serve from uploads/notes/ subdirectory
|
||||
if (segments[0] !== 'notes') {
|
||||
// Only serve from uploads/notes/ (flat or userId/file)
|
||||
if (!segments.length || segments[0] !== 'notes') {
|
||||
return new NextResponse('Not found', { status: 404 })
|
||||
}
|
||||
|
||||
// Reject path traversal
|
||||
if (segments.some((s) => s === '..' || s.includes('\0'))) {
|
||||
return new NextResponse('Forbidden', { status: 403 })
|
||||
}
|
||||
|
||||
const filename = segments[segments.length - 1]
|
||||
const allowed = await canAccessUploadedNoteImage(filename, session?.user?.id)
|
||||
// Never serve meta sidecars as images
|
||||
if (filename.endsWith('.meta.json')) {
|
||||
return new NextResponse('Not found', { status: 404 })
|
||||
}
|
||||
|
||||
const allowed = await canAccessUploadedNoteImage(segments, session?.user?.id)
|
||||
if (!allowed) {
|
||||
return new NextResponse(session?.user?.id ? 'Forbidden' : 'Unauthorized', {
|
||||
status: session?.user?.id ? 403 : 401,
|
||||
headers: {
|
||||
// Do not let browsers / CDNs cache a denial (would stick a broken image)
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -40,9 +54,8 @@ export async function GET(
|
||||
return new NextResponse('Unsupported file type', { status: 400 })
|
||||
}
|
||||
|
||||
// Prevent path traversal
|
||||
const safePath = path.join(UPLOAD_DIR, ...segments)
|
||||
if (!safePath.startsWith(UPLOAD_DIR)) {
|
||||
if (!safePath.startsWith(UPLOAD_DIR + path.sep) && safePath !== UPLOAD_DIR) {
|
||||
return new NextResponse('Forbidden', { status: 403 })
|
||||
}
|
||||
|
||||
@@ -57,7 +70,8 @@ export async function GET(
|
||||
return new NextResponse(buffer, {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
// private: only for authenticated session fetches; still long cache after auth ok
|
||||
'Cache-Control': 'private, max-age=31536000, immutable',
|
||||
'Content-Length': String(buffer.length),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,27 +1,48 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/auth';
|
||||
import { getUserQuotas, getEffectiveTier } from '@/lib/entitlements';
|
||||
import { NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { getCreditUsageSummary, getQuotasCompatFromCredits } from '@/lib/credits'
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const [quotas, tier] = await Promise.all([
|
||||
getUserQuotas(session.user.id),
|
||||
getEffectiveTier(session.user.id),
|
||||
]);
|
||||
return NextResponse.json({ quotas, tier });
|
||||
const summary = await getCreditUsageSummary(session.user.id)
|
||||
// Compat anciens clients : quotas dérivés des crédits
|
||||
const quotas = await getQuotasCompatFromCredits(session.user.id)
|
||||
|
||||
return NextResponse.json({
|
||||
tier: summary.tier,
|
||||
period: summary.period,
|
||||
balance: {
|
||||
totalRemaining: summary.balance.unlimited
|
||||
? null
|
||||
: summary.balance.totalRemaining,
|
||||
subscriptionRemaining: summary.balance.unlimited
|
||||
? null
|
||||
: summary.balance.subscriptionRemaining,
|
||||
purchasedRemaining: summary.balance.purchasedRemaining,
|
||||
subscriptionGranted: summary.balance.unlimited
|
||||
? null
|
||||
: summary.balance.subscriptionGranted,
|
||||
subscriptionSpent: summary.balance.subscriptionSpent,
|
||||
spentThisPeriod: summary.balance.spentThisPeriod,
|
||||
unlimited: summary.balance.unlimited,
|
||||
},
|
||||
breakdown: summary.breakdown,
|
||||
// legacy
|
||||
quotas,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[usage/current] Failed to fetch quotas:', error);
|
||||
console.error('[usage/current] Failed to fetch usage data:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch usage data' },
|
||||
{ status: 503 },
|
||||
);
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@/auth';
|
||||
import { getEffectiveTier } from '@/lib/entitlements';
|
||||
import { isByokProviderAllowed } from '@/lib/byok';
|
||||
import { fetchLiveModelsForProvider, PROVIDER_MODEL_SUGGESTIONS, type FetchModelsResult } from '@/lib/ai/models-list';
|
||||
import { getActiveByokKey, isByokProviderAllowed } from '@/lib/byok';
|
||||
import { decryptApiKey } from '@/lib/crypto';
|
||||
import { fetchLiveModelsForProvider, type FetchModelsResult } from '@/lib/ai/models-list';
|
||||
import { VALID_PROVIDERS, type AiGatewayProvider } from '@/lib/ai/router';
|
||||
|
||||
// Providers that return static suggestions regardless of key
|
||||
const STATIC_PROVIDERS = new Set(['anthropic', 'anthropic_custom', 'custom_anthropic', 'google', 'minimax']);
|
||||
|
||||
/**
|
||||
* GET /api/user/api-keys/live-models?provider=<provider>[&key=<api_key>][&baseUrl=<url>]
|
||||
*
|
||||
* - Static providers (minimax, anthropic, google): returns suggestions immediately, no key needed.
|
||||
* - Live providers (openai, deepseek…): requires key to fetch live from provider.
|
||||
* Liste les modèles chez le fournisseur. Sans clé (saisie ou déjà enregistrée) : liste vide.
|
||||
* Pas de liste de secours figée.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await auth();
|
||||
@@ -27,8 +25,8 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const { searchParams } = request.nextUrl;
|
||||
const provider = searchParams.get('provider') as AiGatewayProvider;
|
||||
const apiKey = searchParams.get('key') ?? '';
|
||||
const baseUrl = searchParams.get('baseUrl') ?? undefined;
|
||||
let apiKey = searchParams.get('key') ?? '';
|
||||
let baseUrl = searchParams.get('baseUrl') ?? undefined;
|
||||
|
||||
if (!provider) {
|
||||
return NextResponse.json({ error: 'Missing provider' }, { status: 400 });
|
||||
@@ -42,16 +40,20 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Tier restricted' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Static suggestion providers: return immediately without a key
|
||||
if (STATIC_PROVIDERS.has(provider)) {
|
||||
const base = provider === 'anthropic_custom' || provider === 'custom_anthropic' ? 'anthropic' : provider;
|
||||
const models = PROVIDER_MODEL_SUGGESTIONS[base] ?? [];
|
||||
return NextResponse.json({ success: true, models, fromApi: false });
|
||||
if (!apiKey || apiKey.length < 4) {
|
||||
const saved = await getActiveByokKey(session.user.id, provider, tier);
|
||||
if (saved) {
|
||||
try {
|
||||
apiKey = await decryptApiKey(saved.encryptedKey);
|
||||
if (!baseUrl && saved.baseUrl) baseUrl = saved.baseUrl;
|
||||
} catch {
|
||||
apiKey = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Live providers need a key
|
||||
if (!apiKey || apiKey.length < 4) {
|
||||
return NextResponse.json({ success: true, models: PROVIDER_MODEL_SUGGESTIONS[provider] ?? [], fromApi: false });
|
||||
return NextResponse.json({ success: true, models: [], fromApi: false });
|
||||
}
|
||||
|
||||
const result: FetchModelsResult = await fetchLiveModelsForProvider(provider, apiKey, baseUrl);
|
||||
|
||||
@@ -4,7 +4,21 @@
|
||||
@import "vazirmatn/Vazirmatn-font-face.css";
|
||||
@import "katex/dist/katex.min.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
/* Inclure .dark lui-même + ses descendants (évite styles clairs manquants) */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* Filet de sécurité anti-flash : fond sombre dès que html a .dark,
|
||||
même si une frame Tailwind n’a pas encore appliqué dark: sur un enfant. */
|
||||
html.dark {
|
||||
color-scheme: dark;
|
||||
background-color: #202020;
|
||||
}
|
||||
|
||||
html.dark body {
|
||||
color-scheme: dark;
|
||||
background-color: #202020;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* ── Global UX fixes (UI/UX Pro Max audit) ────────────────────────────── */
|
||||
|
||||
@@ -1623,7 +1637,11 @@ html.font-system * {
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: 0.5em 0;
|
||||
transition: filter 0.15s ease;
|
||||
transition: filter 0.15s ease, outline 0.15s ease;
|
||||
cursor: pointer;
|
||||
/* Ensure clicks select the node (not swallowed by surrounding layout) */
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.notion-editor-wrapper .ProseMirror img:hover {
|
||||
@@ -1635,6 +1653,14 @@ html.font-system * {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Image node wrapper when selected as block */
|
||||
.notion-editor-wrapper .ProseMirror .ProseMirror-selectednode img,
|
||||
.notion-editor-wrapper .ProseMirror img.ProseMirror-selectednode {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 2px;
|
||||
box-shadow: 0 0 0 3px color-mix(in oklab, var(--primary) 25%, transparent);
|
||||
}
|
||||
|
||||
/* --- Links --- */
|
||||
.notion-editor-wrapper .ProseMirror a {
|
||||
color: var(--primary);
|
||||
|
||||
@@ -5,14 +5,17 @@ import { SessionProviderWrapper } from "@/components/session-provider-wrapper";
|
||||
import { getAISettings } from "@/app/actions/ai-settings";
|
||||
import { getUserSettings } from "@/app/actions/user-settings";
|
||||
import { ThemeInitializer } from "@/components/theme-initializer";
|
||||
import { ThemeClassGuard } from "@/components/theme-class-guard";
|
||||
import { DirectionInitializer } from "@/components/direction-initializer";
|
||||
import { ErrorReporter } from "@/components/error-reporter";
|
||||
import { auth } from "@/auth";
|
||||
import { CookieConsentRoot } from "@/components/legal/cookie-consent-root";
|
||||
import { LanguageProvider } from "@/lib/i18n/LanguageProvider";
|
||||
import Script from "next/script";
|
||||
import type { CSSProperties } from "react";
|
||||
import { normalizeThemeId } from "@/lib/apply-document-theme";
|
||||
import { cookies } from "next/headers";
|
||||
import { normalizeThemeId, THEME_COOKIE_NAME } from "@/lib/apply-document-theme";
|
||||
import { THEME_INIT_SCRIPT, DIRECTION_INIT_SCRIPT } from "@/lib/inline-boot-scripts";
|
||||
import { SwCleanup } from "@/components/sw-cleanup";
|
||||
|
||||
import { Inter, Manrope, Playfair_Display, JetBrains_Mono } from "next/font/google";
|
||||
|
||||
@@ -39,8 +42,8 @@ const jetbrainsMono = JetBrains_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Memento - Your Digital Notepad",
|
||||
description: "A beautiful note-taking app built with Next.js 16",
|
||||
title: "Memento — Your Second Brain",
|
||||
description: "A Second Brain that connects while you write. Search, brief, and act on your ideas every morning.",
|
||||
manifest: "/api/manifest",
|
||||
icons: {
|
||||
icon: "/icons/icon-512.svg",
|
||||
@@ -76,16 +79,29 @@ export default async function RootLayout({
|
||||
const session = await auth();
|
||||
const userId = session?.user?.id;
|
||||
|
||||
const [aiSettings, userSettings] = await Promise.all([
|
||||
const [aiSettings, userSettings, cookieStore] = await Promise.all([
|
||||
getAISettings(userId),
|
||||
getUserSettings(userId),
|
||||
cookies(),
|
||||
])
|
||||
|
||||
const htmlTheme = serverHtmlThemeState(userSettings.theme)
|
||||
// Cookie = préférence immédiate (bascule barre latérale) ; base = profil utilisateur.
|
||||
// Sans cookie, un dark seulement en localStorage était écrasé par le serveur → flash clair.
|
||||
const cookieTheme = cookieStore.get(THEME_COOKIE_NAME)?.value
|
||||
const resolvedTheme = normalizeThemeId(cookieTheme || userSettings.theme || 'light')
|
||||
|
||||
const htmlTheme = serverHtmlThemeState(resolvedTheme)
|
||||
const serverAccent = userSettings.accentColor ?? '#A47148'
|
||||
const serverTheme = normalizeThemeId(userSettings.theme || 'light')
|
||||
const serverTheme = resolvedTheme
|
||||
const wantsDarkClass =
|
||||
resolvedTheme === 'dark' ||
|
||||
resolvedTheme === 'midnight' ||
|
||||
// auto : le serveur ne connaît pas prefers-color-scheme → laisser le script client
|
||||
false
|
||||
const htmlStyle = {
|
||||
'--color-brand-accent': serverAccent,
|
||||
// Aide le navigateur à peindre scrollbars/inputs dans le bon mode
|
||||
colorScheme: wantsDarkClass ? 'dark' : resolvedTheme === 'light' ? 'light' : undefined,
|
||||
} as CSSProperties
|
||||
|
||||
return (
|
||||
@@ -97,14 +113,18 @@ export default async function RootLayout({
|
||||
data-server-accent={serverAccent}
|
||||
style={htmlStyle}
|
||||
>
|
||||
<body className={`${inter.className} ${inter.variable} ${manrope.variable} ${playfair.variable} ${jetbrainsMono.variable} h-screen overflow-hidden`}>
|
||||
<Script id="theme-init" src="/scripts/theme-init.js" strategy="beforeInteractive" />
|
||||
<Script id="direction-init" src="/scripts/direction-init.js" strategy="beforeInteractive" />
|
||||
<Script id="sw-cleanup" src="/scripts/sw-cleanup.js" strategy="afterInteractive" />
|
||||
<head>
|
||||
{/* Scripts boot inline : anti-flash thème/RTL, sans next/script (React 19). */}
|
||||
<script id="theme-init" dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
|
||||
<script id="direction-init" dangerouslySetInnerHTML={{ __html: DIRECTION_INIT_SCRIPT }} />
|
||||
</head>
|
||||
<body className={`${inter.className} ${inter.variable} ${manrope.variable} ${playfair.variable} ${jetbrainsMono.variable} h-screen overflow-hidden bg-background text-foreground`}>
|
||||
<SessionProviderWrapper>
|
||||
<SwCleanup />
|
||||
<ErrorReporter />
|
||||
<DirectionInitializer />
|
||||
<ThemeInitializer theme={userSettings.theme} fontSize={aiSettings.fontSize} fontFamily={aiSettings.fontFamily} accentColor={userSettings.accentColor} />
|
||||
<ThemeInitializer theme={resolvedTheme} fontSize={aiSettings.fontSize} fontFamily={aiSettings.fontFamily} accentColor={userSettings.accentColor} />
|
||||
<ThemeClassGuard />
|
||||
{children}
|
||||
<LanguageProvider initialLanguage="en">
|
||||
<CookieConsentRoot />
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTitleSuggestions } from '@/hooks/use-title-suggestions'
|
||||
|
||||
export default function TestTitleSuggestionsPage() {
|
||||
const [content, setContent] = useState('')
|
||||
|
||||
const { suggestions, isAnalyzing, error } = useTitleSuggestions({
|
||||
content,
|
||||
enabled: true // Always enabled for testing
|
||||
})
|
||||
|
||||
const wordCount = content.split(/\s+/).filter(w => w.length > 0).length
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px', maxWidth: '800px', margin: '0 auto' }}>
|
||||
<h1>Test Title Suggestions</h1>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label>Content (need 50+ words):</label>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
style={{ width: '100%', height: '200px', marginTop: '10px' }}
|
||||
placeholder="Type at least 50 words here..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '20px', padding: '10px', background: '#f0f0f0' }}>
|
||||
<p><strong>Word count:</strong> {wordCount} / 50</p>
|
||||
<p><strong>Status:</strong> {isAnalyzing ? 'Analyzing...' : 'Idle'}</p>
|
||||
{error && <p style={{ color: 'red' }}><strong>Error:</strong> {error}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Suggestions ({suggestions.length}):</h2>
|
||||
{suggestions.length > 0 ? (
|
||||
<ul>
|
||||
{suggestions.map((s, i) => (
|
||||
<li key={i}>
|
||||
<strong>{s.title}</strong> (confidence: {s.confidence}%)
|
||||
{s.reasoning && <p>→ {s.reasoning}</p>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p style={{ color: '#666' }}>No suggestions yet. Type 50+ words and wait 2 seconds.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
<button onClick={() => {
|
||||
setContent('word '.repeat(50))
|
||||
}}>
|
||||
Fill with 50 words (test)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -23,20 +23,19 @@ export const authConfig = {
|
||||
nextUrl.pathname.startsWith('/settings') ||
|
||||
nextUrl.pathname.startsWith('/lab') ||
|
||||
nextUrl.pathname.startsWith('/agents') ||
|
||||
nextUrl.pathname.startsWith('/chat') ||
|
||||
nextUrl.pathname.startsWith('/canvas') ||
|
||||
nextUrl.pathname.startsWith('/notebooks') ||
|
||||
nextUrl.pathname.startsWith('/note/') ||
|
||||
nextUrl.pathname.startsWith('/brainstorm') ||
|
||||
nextUrl.pathname.startsWith('/insights') ||
|
||||
nextUrl.pathname.startsWith('/graph') ||
|
||||
nextUrl.pathname.startsWith('/revision') ||
|
||||
nextUrl.pathname.startsWith('/support');
|
||||
nextUrl.pathname.startsWith('/revision');
|
||||
const isAdminPage = nextUrl.pathname.startsWith('/admin');
|
||||
const isPublicPage = nextUrl.pathname === '/' ||
|
||||
nextUrl.pathname === '/login' ||
|
||||
nextUrl.pathname === '/register' ||
|
||||
nextUrl.pathname === '/forgot-password' ||
|
||||
nextUrl.pathname === '/check-email' ||
|
||||
nextUrl.pathname === '/verify-email' ||
|
||||
nextUrl.pathname.startsWith('/reset-password');
|
||||
|
||||
if (isAdminPage) {
|
||||
|
||||
@@ -25,14 +25,14 @@ export function AdminMetrics({ metrics, className }: AdminMetricsProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4',
|
||||
'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{metrics.map((metric, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
className="p-6 bg-white dark:bg-zinc-900 border-gray-200 dark:border-gray-800"
|
||||
className="p-6 bg-card border-border"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
|
||||
@@ -55,6 +55,16 @@ const typeConfig: Record<string, { icon: typeof Globe }> = {
|
||||
'task-extractor': { icon: ListChecks },
|
||||
}
|
||||
|
||||
function kebabToCamel(value: string) {
|
||||
return value.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase())
|
||||
}
|
||||
|
||||
function resolveStoredLabel(t: (key: string) => string, value: string) {
|
||||
if (!value.startsWith('agents.')) return value
|
||||
const translated = t(value)
|
||||
return translated !== value ? translated : value
|
||||
}
|
||||
|
||||
const frequencyKeys: Record<string, string> = {
|
||||
manual: 'agents.frequencies.manual',
|
||||
hourly: 'agents.frequencies.hourly',
|
||||
@@ -128,7 +138,7 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
|
||||
if (pollRef.current) clearInterval(pollRef.current)
|
||||
pollRef.current = null
|
||||
setIsRunning(false)
|
||||
toast.error(t('agents.toasts.runError', { error: data.error || t('agents.toasts.runFailed') }), {
|
||||
toast.error(t('agents.toasts.runError', { error: data.errorKey ? t(data.errorKey) : (data.error || t('agents.toasts.runFailed')) }), {
|
||||
id: toastId,
|
||||
description: '' // Clear the loading description
|
||||
})
|
||||
@@ -209,9 +219,9 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-[13px] font-bold text-foreground">{agent.name}</h4>
|
||||
<h4 className="text-[13px] font-bold text-foreground">{resolveStoredLabel(t, agent.name)}</h4>
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground opacity-60">
|
||||
{t(`agents.types.${agent.type || 'custom'}`)}
|
||||
{t(`agents.types.${kebabToCamel(agent.type || 'custom')}`)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -237,7 +247,7 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
|
||||
|
||||
{agent.description && (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-3">
|
||||
{agent.description}
|
||||
{resolveStoredLabel(t, agent.description)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -63,14 +63,26 @@ const templateConfig = [
|
||||
type TemplateId = typeof templateConfig[number]['id']
|
||||
type CategoryId = 'all' | 'veille' | 'digest' | 'tools' | 'generate'
|
||||
|
||||
const CATEGORIES: { id: CategoryId; label: string }[] = [
|
||||
{ id: 'all', label: 'Tous' },
|
||||
{ id: 'veille', label: '📡 Veille' },
|
||||
{ id: 'digest', label: '📰 Digest' },
|
||||
{ id: 'tools', label: '🔧 Outils' },
|
||||
{ id: 'generate', label: '✨ Génération' },
|
||||
const CATEGORIES: { id: CategoryId; labelKey: string }[] = [
|
||||
{ id: 'all', labelKey: 'agents.templates.categoryAll' },
|
||||
{ id: 'veille', labelKey: 'agents.templates.categoryWatch' },
|
||||
{ id: 'digest', labelKey: 'agents.templates.categoryDigest' },
|
||||
{ id: 'tools', labelKey: 'agents.templates.categoryTools' },
|
||||
{ id: 'generate', labelKey: 'agents.templates.categoryGenerate' },
|
||||
]
|
||||
|
||||
const PREVIEW_COUNT = 3
|
||||
|
||||
function templateNameKey(id: string) {
|
||||
return `agents.templates.${id}.name`
|
||||
}
|
||||
|
||||
function hasTranslatedName(t: (key: string) => string, id: string) {
|
||||
const key = templateNameKey(id)
|
||||
const name = t(key)
|
||||
return Boolean(name.trim()) && name !== key
|
||||
}
|
||||
|
||||
const typeIcons: Record<string, typeof Globe> = {
|
||||
scraper: Globe,
|
||||
researcher: Search,
|
||||
@@ -98,6 +110,7 @@ export function AgentTemplates({ onInstalled, existingAgentNames }: AgentTemplat
|
||||
const { t } = useLanguage()
|
||||
const [installingId, setInstallingId] = useState<string | null>(null)
|
||||
const [activeCategory, setActiveCategory] = useState<CategoryId>('all')
|
||||
const [showAll, setShowAll] = useState(false)
|
||||
|
||||
const handleInstall = async (tpl: typeof templateConfig[number]) => {
|
||||
setInstallingId(tpl.id)
|
||||
@@ -142,55 +155,62 @@ export function AgentTemplates({ onInstalled, existingAgentNames }: AgentTemplat
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = activeCategory === 'all'
|
||||
const filtered = (activeCategory === 'all'
|
||||
? templateConfig
|
||||
: templateConfig.filter((tpl) => tpl.category === activeCategory)
|
||||
).filter((tpl) => hasTranslatedName(t, tpl.id))
|
||||
|
||||
const visible = showAll ? filtered : filtered.slice(0, PREVIEW_COUNT)
|
||||
const hiddenCount = filtered.length - visible.length
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Category filter */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CATEGORIES.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => setActiveCategory(cat.id)}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveCategory(cat.id)
|
||||
setShowAll(false)
|
||||
}}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-semibold transition-all border ${
|
||||
activeCategory === cat.id
|
||||
? 'bg-ink text-paper border-ink'
|
||||
: 'bg-paper text-muted-ink border-border/40 hover:border-ink/30'
|
||||
}`}
|
||||
>
|
||||
{cat.label}
|
||||
{t(cat.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Template grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filtered.map(tpl => {
|
||||
{visible.map(tpl => {
|
||||
const Icon = templateIcons[tpl.id as TemplateId] ?? typeIcons[tpl.type] ?? Settings
|
||||
const isInstalling = installingId === tpl.id
|
||||
const nameKey = `agents.templates.${tpl.id}.name`
|
||||
const nameKey = templateNameKey(tpl.id)
|
||||
const descKey = `agents.templates.${tpl.id}.description`
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tpl.id}
|
||||
className="bg-card/40 border border-dashed border-border rounded-2xl p-6 group cursor-pointer hover:bg-card hover:border-foreground/20 transition-all"
|
||||
className="bg-card/40 border border-dashed border-border rounded-2xl p-6 hover:bg-card hover:border-foreground/20 transition-all"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg bg-muted flex items-center justify-center text-muted-foreground group-hover:bg-foreground group-hover:text-background mb-4 transition-all">
|
||||
<div className="w-8 h-8 rounded-lg bg-muted flex items-center justify-center text-muted-foreground mb-4">
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<h4 className="text-[13px] font-bold text-foreground">{t(nameKey)}</h4>
|
||||
{tpl.frequency !== 'manual' && (
|
||||
<span className="text-[10px] font-semibold text-concrete bg-border/20 rounded-full px-2 py-0.5 shrink-0">
|
||||
{tpl.frequency === 'daily' ? '📅 Quotidien' : '📆 Hebdo'}
|
||||
{t(`agents.frequencies.${tpl.frequency}`)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed mb-4">{t(descKey)}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleInstall(tpl)}
|
||||
disabled={isInstalling}
|
||||
className="text-[11px] font-bold uppercase tracking-widest text-foreground hover:opacity-60 transition-opacity flex items-center gap-2 disabled:opacity-50"
|
||||
@@ -211,6 +231,25 @@ export function AgentTemplates({ onInstalled, existingAgentNames }: AgentTemplat
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{hiddenCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAll(true)}
|
||||
className="text-[12px] font-semibold text-foreground hover:opacity-70 transition-opacity"
|
||||
>
|
||||
{t('agents.templates.seeAll')}
|
||||
</button>
|
||||
)}
|
||||
{showAll && filtered.length > PREVIEW_COUNT && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAll(false)}
|
||||
className="text-[12px] font-semibold text-foreground hover:opacity-70 transition-opacity"
|
||||
>
|
||||
{t('agents.templates.showLess')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,26 +16,16 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// ─── Provider display info ────────────────────────────────────────────────────
|
||||
const PROVIDER_INFO: Record<string, { name: string; hint: string }> = {
|
||||
openai: { name: 'OpenAI', hint: 'GPT-4o, GPT-4' },
|
||||
anthropic: { name: 'Anthropic', hint: 'Claude 3.5 Sonnet, Haiku' },
|
||||
minimax: { name: 'MiniMax', hint: 'MiniMax-M2.7, M2.5' },
|
||||
google: { name: 'Google AI', hint: 'Gemini 1.5 Flash, Pro' },
|
||||
deepseek: { name: 'DeepSeek', hint: 'DeepSeek Chat, Reasoner' },
|
||||
openrouter: { name: 'OpenRouter', hint: 'Multi-provider access' },
|
||||
mistral: { name: 'Mistral AI', hint: 'Mistral Small, Large' },
|
||||
glm: { name: 'GLM (Zhipu)', hint: 'GLM-4, GLM-4-Flash' },
|
||||
zai: { name: 'Zuki Journey', hint: 'OpenAI/Anthropic proxy' },
|
||||
anthropic_custom: { name: 'Anthropic (custom)', hint: 'Anthropic-compatible proxy' },
|
||||
custom_openai: { name: 'Compatible OpenAI', hint: 'Any OpenAI-compatible proxy' },
|
||||
custom_anthropic: { name: 'Compatible Anthropic', hint: 'Any Anthropic-compatible proxy' },
|
||||
custom: { name: 'Custom API', hint: 'Your own endpoint' },
|
||||
}
|
||||
import { providerDisplayName } from '@/lib/ai/provider-labels'
|
||||
import { PROVIDER_MODEL_SUGGESTIONS } from '@/lib/ai/models-list'
|
||||
|
||||
function displayName(provider: string): string {
|
||||
return PROVIDER_INFO[provider]?.name ?? provider
|
||||
return providerDisplayName(provider)
|
||||
}
|
||||
|
||||
function providerHint(provider: string): string {
|
||||
const models = PROVIDER_MODEL_SUGGESTIONS[provider] ?? []
|
||||
return models.slice(0, 2).join(', ')
|
||||
}
|
||||
|
||||
const MANUAL_MODEL_PROVIDERS = new Set(['custom'])
|
||||
@@ -155,20 +145,20 @@ function EditKeyForm({
|
||||
const showModelInput = manualModel || (!loadingModels && models.length === 0)
|
||||
|
||||
return (
|
||||
<div className="mt-2 border border-violet-500/20 rounded-xl bg-violet-500/5 p-4 space-y-3">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-violet-600 dark:text-violet-400">
|
||||
<div className="mt-2 border border-brand-accent/20 rounded-xl bg-brand-accent/5 p-4 space-y-3">
|
||||
<p className="text-[13px] font-semibold uppercase tracking-wider text-brand-accent">
|
||||
{t('byok.editLabel', { name: displayName(savedKey.provider) })}
|
||||
</p>
|
||||
|
||||
{/* Alias */}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`edit-alias-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">{t('byok.aliasLabel')}</Label>
|
||||
<Label htmlFor={`edit-alias-${savedKey.provider}`} className="text-[13px] font-medium uppercase tracking-wider text-concrete">{t('byok.aliasLabel')}</Label>
|
||||
<Input id={`edit-alias-${savedKey.provider}`} value={alias} onChange={(e) => setAlias(e.target.value)} placeholder={t('byok.aliasPlaceholder')} />
|
||||
</div>
|
||||
{needsUrl && (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`edit-baseurl-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">{t('byok.apiUrl')}</Label>
|
||||
<Label htmlFor={`edit-baseurl-${savedKey.provider}`} className="text-[13px] font-medium uppercase tracking-wider text-concrete">{t('byok.apiUrl')}</Label>
|
||||
<Input id={`edit-baseurl-${savedKey.provider}`} value={baseUrl} onChange={(e) => setBaseUrl(e.target.value.trim())} placeholder="https://api.example.com/v1" />
|
||||
</div>
|
||||
)}
|
||||
@@ -176,7 +166,7 @@ function EditKeyForm({
|
||||
|
||||
{/* Model */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`edit-model-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">{t('byok.model')}</Label>
|
||||
<Label htmlFor={`edit-model-${savedKey.provider}`} className="text-[13px] font-medium uppercase tracking-wider text-concrete">{t('byok.model')}</Label>
|
||||
{showModelDropdown ? (
|
||||
<Select value={model} onValueChange={setModel}>
|
||||
<SelectTrigger id={`edit-model-${savedKey.provider}`}><SelectValue placeholder={t('byok.choose')} /></SelectTrigger>
|
||||
@@ -189,7 +179,7 @@ function EditKeyForm({
|
||||
|
||||
{/* Key rotation (optional) */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`edit-key-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">
|
||||
<Label htmlFor={`edit-key-${savedKey.provider}`} className="text-[13px] font-medium uppercase tracking-wider text-concrete">
|
||||
{t('byok.newKey')} <span className="normal-case font-normal text-concrete">{t('byok.newKeyHint')}</span>
|
||||
</Label>
|
||||
<Input
|
||||
@@ -205,7 +195,7 @@ function EditKeyForm({
|
||||
{/* Test result */}
|
||||
{testResult && (
|
||||
<div className={cn(
|
||||
'flex items-start gap-2 rounded-lg px-3 py-2 text-[10px] border',
|
||||
'flex items-start gap-2 rounded-lg px-3 py-2 text-[13px] border',
|
||||
testResult.ok ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-700 dark:text-emerald-400' : 'bg-rose-500/10 border-rose-500/20 text-rose-700 dark:text-rose-400'
|
||||
)}>
|
||||
{testResult.ok ? <CheckCircle2 size={12} className="shrink-0 mt-0.5" /> : <XCircle size={12} className="shrink-0 mt-0.5" />}
|
||||
@@ -224,7 +214,7 @@ function EditKeyForm({
|
||||
type="button"
|
||||
disabled={!newKey || newKey.length < 8 || testing}
|
||||
onClick={testModel}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-[9px] font-bold uppercase tracking-[0.1em] border border-border bg-white dark:bg-white/5 hover:border-violet-400 transition-colors disabled:opacity-40 disabled:pointer-events-none"
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-[13px] font-semibold uppercase tracking-[0.1em] border border-border bg-white dark:bg-white/5 hover:border-brand-accent/50 transition-colors disabled:opacity-40 disabled:pointer-events-none"
|
||||
>
|
||||
{testing ? <Loader2 size={11} className="animate-spin" /> : <FlaskConical size={11} />}
|
||||
{t('byok.test')}
|
||||
@@ -233,7 +223,7 @@ function EditKeyForm({
|
||||
type="button"
|
||||
disabled={saveMutation.isPending}
|
||||
onClick={() => saveMutation.mutate()}
|
||||
className="flex-1 py-2 rounded-lg text-[9px] font-bold uppercase tracking-[0.12em] bg-ink text-paper shadow hover:scale-[1.01] active:scale-[0.99] transition-all disabled:opacity-40"
|
||||
className="flex-1 py-2 rounded-lg text-[13px] font-semibold uppercase tracking-[0.12em] bg-brand-accent text-white shadow hover:scale-[1.01] active:scale-[0.99] transition-all disabled:opacity-40"
|
||||
>
|
||||
{saveMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : t('byok.saveChanges')}
|
||||
</Button>
|
||||
@@ -286,13 +276,6 @@ export function ByokSettingsPanel() {
|
||||
setTestResult(null)
|
||||
setModel('')
|
||||
setModels([])
|
||||
if (MANUAL_MODEL_PROVIDERS.has(p)) return
|
||||
setLoadingModels(true)
|
||||
try {
|
||||
const list = await fetchModelsFromServer(p)
|
||||
setModels(list)
|
||||
if (list.length > 0) setModel(list[0])
|
||||
} finally { setLoadingModels(false) }
|
||||
}
|
||||
|
||||
async function refreshModels(p: string, key: string, _baseUrl?: string) {
|
||||
@@ -403,10 +386,10 @@ export function ByokSettingsPanel() {
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-5">
|
||||
<div className="p-3 bg-violet-500/10 rounded-2xl text-violet-500 border border-violet-500/20"><KeyRound size={18} /></div>
|
||||
<div className="p-3 bg-brand-accent/10 rounded-2xl text-brand-accent border border-brand-accent/20"><KeyRound size={18} /></div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<h3 className="text-[13px] font-bold text-ink">{t('byokSettings.title')}</h3>
|
||||
<p className="text-[10px] text-concrete leading-relaxed">{t('byokSettings.description')}</p>
|
||||
<p className="text-sm text-concrete leading-relaxed">{t('byokSettings.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -419,7 +402,7 @@ export function ByokSettingsPanel() {
|
||||
: 'bg-amber-500/10 text-amber-700 dark:text-amber-400 border-amber-500/20'
|
||||
)}>
|
||||
{activeKey ? (
|
||||
<><Zap size={14} className="shrink-0" /><span>{t('byok.byokActive')} · <strong>{displayName(activeKey.provider)}</strong>{activeKey.model && <> · <code className="font-mono text-[10px]">{activeKey.model}</code></>}{activeKey.alias && <> · {activeKey.alias}</>}</span></>
|
||||
<><Zap size={14} className="shrink-0" /><span>{t('byok.byokActive')} · <strong>{displayName(activeKey.provider)}</strong>{activeKey.model && <> · <code className="font-mono text-[13px]">{activeKey.model}</code></>}{activeKey.alias && <> · {activeKey.alias}</>}</span></>
|
||||
) : (
|
||||
<><Shield size={14} className="shrink-0" /><span>{t('byok.noActiveKey')}</span></>
|
||||
)}
|
||||
@@ -433,7 +416,7 @@ export function ByokSettingsPanel() {
|
||||
{/* Saved keys */}
|
||||
{keys.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete">{t('byok.savedKeys')}</p>
|
||||
<p className="text-[13px] font-semibold uppercase tracking-wider text-concrete">{t('byok.savedKeys')}</p>
|
||||
<ul className="space-y-1">
|
||||
{keys.map((key) => (
|
||||
<li key={key.provider}>
|
||||
@@ -447,9 +430,9 @@ export function ByokSettingsPanel() {
|
||||
<div>
|
||||
<div className="text-[13px] font-semibold text-ink leading-tight">{displayName(key.provider)}</div>
|
||||
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||
{key.model && <code className="text-[9px] font-mono text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded">{key.model}</code>}
|
||||
{key.alias && <span className="text-[9px] text-concrete">{key.alias}</span>}
|
||||
<span className={cn('text-[9px] font-medium', key.isActive ? 'text-emerald-600 dark:text-emerald-400' : 'text-concrete')}>
|
||||
{key.model && <code className="text-[13px] font-mono text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded">{key.model}</code>}
|
||||
{key.alias && <span className="text-[13px] text-concrete">{key.alias}</span>}
|
||||
<span className={cn('text-[13px] font-medium', key.isActive ? 'text-emerald-600 dark:text-emerald-400' : 'text-concrete')}>
|
||||
{key.isActive ? t('byok.activeStatus') : t('byok.inactiveStatus')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -462,7 +445,7 @@ export function ByokSettingsPanel() {
|
||||
className={cn(
|
||||
'h-7 w-7 rounded-lg flex items-center justify-center transition-colors',
|
||||
editingProvider === key.provider
|
||||
? 'text-violet-600 bg-violet-500/10 border border-violet-500/30'
|
||||
? 'text-brand-accent bg-brand-accent/10 border border-brand-accent/30'
|
||||
: 'text-concrete hover:text-ink hover:bg-muted border border-transparent'
|
||||
)}
|
||||
onClick={() => setEditingProvider(editingProvider === key.provider ? null : key.provider)}
|
||||
@@ -492,7 +475,7 @@ export function ByokSettingsPanel() {
|
||||
|
||||
{/* Inline edit form */}
|
||||
{editingProvider === key.provider && (
|
||||
<div className="border border-violet-500/20 border-t-0 rounded-b-xl overflow-hidden">
|
||||
<div className="border border-brand-accent/20 border-t-0 rounded-b-xl overflow-hidden">
|
||||
<EditKeyForm
|
||||
savedKey={key}
|
||||
onDone={() => setEditingProvider(null)}
|
||||
@@ -508,40 +491,40 @@ export function ByokSettingsPanel() {
|
||||
|
||||
{/* Add key form */}
|
||||
<div className="space-y-4 border border-border/60 rounded-2xl p-5">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete">
|
||||
<p className="text-[13px] font-semibold uppercase tracking-wider text-concrete">
|
||||
{keys.length > 0 ? t('byok.addOrReplace') : t('byok.connectProvider')}
|
||||
</p>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="byok-provider" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byokSettings.provider')}</Label>
|
||||
<Label htmlFor="byok-provider" className="text-[13px] font-medium uppercase tracking-wider text-concrete">{t('byokSettings.provider')}</Label>
|
||||
<Select value={provider} onValueChange={onProviderChange} disabled={saveMutation.isPending}>
|
||||
<SelectTrigger id="byok-provider"><SelectValue placeholder={t('byok.chooseProvider')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{allowed.map((p) => (
|
||||
<SelectItem key={p} value={p}>
|
||||
<span className="font-medium">{displayName(p)}</span>
|
||||
{PROVIDER_INFO[p]?.hint && <span className="ml-2 text-[10px] text-concrete">{PROVIDER_INFO[p].hint}</span>}
|
||||
{providerHint(p) && <span className="ml-2 text-[13px] text-concrete">{providerHint(p)}</span>}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="byok-alias" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byok.aliasLabel')} <span className="normal-case font-normal">{t('byok.optional')}</span></Label>
|
||||
<Label htmlFor="byok-alias" className="text-[13px] font-medium uppercase tracking-wider text-concrete">{t('byok.aliasLabel')} <span className="normal-case font-normal">{t('byok.optional')}</span></Label>
|
||||
<Input id="byok-alias" value={alias} onChange={(e) => setAlias(e.target.value)} placeholder={t('byok.aliasPlaceholder')} disabled={saveMutation.isPending} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{NEEDS_BASE_URL.has(provider) && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="byok-baseurl" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byok.apiUrl')}</Label>
|
||||
<Label htmlFor="byok-baseurl" className="text-[13px] font-medium uppercase tracking-wider text-concrete">{t('byok.apiUrl')}</Label>
|
||||
<Input id="byok-baseurl" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value.trim())} placeholder="https://api.example.com/v1" disabled={saveMutation.isPending} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="byok-key" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byok.apiKey')}</Label>
|
||||
<Label htmlFor="byok-key" className="text-[13px] font-medium uppercase tracking-wider text-concrete">{t('byok.apiKey')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
@@ -554,8 +537,8 @@ export function ByokSettingsPanel() {
|
||||
</div>
|
||||
<button
|
||||
type="button" disabled={!provider || apiKey.length < 8 || verifying} onClick={verifyKey}
|
||||
className={cn('shrink-0 px-4 py-2 rounded-xl text-[10px] font-bold uppercase tracking-[0.1em] transition-all border disabled:opacity-40 disabled:pointer-events-none',
|
||||
keyOk ? 'bg-emerald-500/10 border-emerald-500/40 text-emerald-700 dark:text-emerald-400' : 'bg-white dark:bg-white/5 border-border hover:border-violet-400')}
|
||||
className={cn('shrink-0 px-4 py-2 rounded-xl text-[13px] font-semibold uppercase tracking-[0.1em] transition-all border disabled:opacity-40 disabled:pointer-events-none',
|
||||
keyOk ? 'bg-emerald-500/10 border-emerald-500/40 text-emerald-700 dark:text-emerald-400' : 'bg-white dark:bg-white/5 border-border hover:border-brand-accent/50')}
|
||||
>
|
||||
{verifying ? <Loader2 className="h-4 w-4 animate-spin" /> : keyOk ? <CheckCircle2 className="h-4 w-4" /> : t('byok.verify')}
|
||||
</button>
|
||||
@@ -564,7 +547,7 @@ export function ByokSettingsPanel() {
|
||||
|
||||
{provider && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="byok-model" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byok.model')}</Label>
|
||||
<Label htmlFor="byok-model" className="text-[13px] font-medium uppercase tracking-wider text-concrete">{t('byok.model')}</Label>
|
||||
{loadingModels ? (
|
||||
<div className="flex items-center gap-2 text-xs text-concrete py-2"><Loader2 className="h-3 w-3 animate-spin" />{t('byok.fetchingModels')}</div>
|
||||
) : showModelDropdown ? (
|
||||
@@ -593,13 +576,13 @@ export function ByokSettingsPanel() {
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button" disabled={!canTest || testing || saveMutation.isPending} onClick={testModel}
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-xl text-[10px] font-bold uppercase tracking-[0.1em] transition-all border bg-white dark:bg-white/5 border-border hover:border-violet-400 disabled:opacity-40 disabled:pointer-events-none"
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-xl text-[13px] font-semibold uppercase tracking-[0.1em] transition-all border bg-white dark:bg-white/5 border-border hover:border-brand-accent/50 disabled:opacity-40 disabled:pointer-events-none"
|
||||
>
|
||||
{testing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FlaskConical size={13} />}{t('byok.test')}
|
||||
</button>
|
||||
<Button
|
||||
type="button" disabled={saveDisabled} onClick={() => saveMutation.mutate()}
|
||||
className="flex-1 py-2.5 rounded-xl text-[10px] font-bold uppercase tracking-[0.15em] transition-all duration-200 bg-ink text-paper shadow-lg hover:scale-[1.01] active:scale-[0.99] disabled:opacity-40 disabled:pointer-events-none disabled:shadow-none"
|
||||
className="flex-1 py-2.5 rounded-xl text-[13px] font-semibold uppercase tracking-[0.15em] transition-all duration-200 bg-brand-accent text-white shadow-lg hover:scale-[1.01] active:scale-[0.99] disabled:opacity-40 disabled:pointer-events-none disabled:shadow-none"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{saveMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
|
||||
@@ -9,6 +9,8 @@ import { getNoteById, deleteNote, toggleArchive } from '@/app/actions/notes'
|
||||
import { emitNoteChange } from '@/lib/note-change-sync'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { ConfirmDeleteNoteDialog } from '@/components/confirm-delete-note-dialog'
|
||||
import { showNoteTrashedToast } from '@/lib/notes/trash-toast'
|
||||
|
||||
const NoteEditor = dynamic(
|
||||
() => import('@/components/note-editor').then(m => ({ default: m.NoteEditor })),
|
||||
@@ -23,6 +25,7 @@ export function ArchiveClient({ notes: initialNotes }: ArchiveClientProps) {
|
||||
const { t } = useLanguage()
|
||||
const [notes, setNotes] = useState<Note[]>(initialNotes)
|
||||
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly: boolean } | null>(null)
|
||||
const [notePendingDelete, setNotePendingDelete] = useState<Note | null>(null)
|
||||
|
||||
const handleOpen = useCallback(async (note: Note, readOnly = false) => {
|
||||
const fresh = await getNoteById(note.id)
|
||||
@@ -50,17 +53,24 @@ export function ArchiveClient({ notes: initialNotes }: ArchiveClientProps) {
|
||||
}
|
||||
}, [t])
|
||||
|
||||
const handleDeleteNote = useCallback(async (note: Note) => {
|
||||
const handleDeleteNote = useCallback((note: Note) => {
|
||||
setNotePendingDelete(note)
|
||||
}, [])
|
||||
|
||||
const confirmDeleteNote = useCallback(async () => {
|
||||
const note = notePendingDelete
|
||||
if (!note) return
|
||||
setNotePendingDelete(null)
|
||||
setNotes((prev) => prev.filter((n) => n.id !== note.id))
|
||||
try {
|
||||
await deleteNote(note.id, { skipRevalidation: true })
|
||||
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
|
||||
toast.success(t('notes.deleted') || 'Note supprimée')
|
||||
showNoteTrashedToast(note, t, () => setNotes((prev) => [note, ...prev]))
|
||||
} catch {
|
||||
setNotes((prev) => [note, ...prev])
|
||||
toast.error(t('general.error'))
|
||||
}
|
||||
}, [t])
|
||||
}, [notePendingDelete, t])
|
||||
|
||||
if (editingNote) {
|
||||
return (
|
||||
@@ -78,11 +88,20 @@ export function ArchiveClient({ notes: initialNotes }: ArchiveClientProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<NotesEditorialView
|
||||
notes={notes}
|
||||
onOpen={handleOpen}
|
||||
onArchiveNote={handleArchiveNote}
|
||||
onDeleteNote={handleDeleteNote}
|
||||
/>
|
||||
<>
|
||||
<NotesEditorialView
|
||||
notes={notes}
|
||||
onOpen={handleOpen}
|
||||
onArchiveNote={handleArchiveNote}
|
||||
onDeleteNote={handleDeleteNote}
|
||||
/>
|
||||
<ConfirmDeleteNoteDialog
|
||||
open={notePendingDelete != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setNotePendingDelete(null)
|
||||
}}
|
||||
onConfirm={confirmDeleteNote}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
52
memento-note/components/auth-shell.tsx
Normal file
52
memento-note/components/auth-shell.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import { useLanguage } from '@/lib/i18n/LanguageProvider';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
function AuthHeader() {
|
||||
const { t } = useLanguage();
|
||||
|
||||
return (
|
||||
<header className="p-6 md:p-8 flex justify-between items-center relative z-10">
|
||||
<Link
|
||||
href="/"
|
||||
aria-label={t('general.back')}
|
||||
className="flex items-center gap-2 text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors group"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full border border-[var(--border)] flex items-center justify-center group-hover:border-[var(--color-brand-accent)] transition-colors">
|
||||
<ArrowLeft size={14} className="group-hover:-translate-x-0.5 transition-transform rtl:rotate-180" />
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-[var(--foreground)] text-[var(--background)] rounded-xl flex items-center justify-center shadow-lg">
|
||||
<span className="font-serif font-bold text-xl">M</span>
|
||||
</div>
|
||||
<span className="font-serif text-xl font-medium tracking-tight">Memento</span>
|
||||
</Link>
|
||||
|
||||
<div className="w-8" />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#FDFCFB] dark:bg-[#0D0D0D] flex flex-col relative overflow-hidden">
|
||||
<div className="absolute top-[-10%] right-[-10%] w-[50%] h-[50%] bg-[var(--color-brand-accent)]/5 blur-[120px] rounded-full pointer-events-none" />
|
||||
<div className="absolute bottom-[-10%] left-[-10%] w-[50%] h-[50%] bg-[#D4A373]/5 blur-[120px] rounded-full pointer-events-none" />
|
||||
|
||||
<AuthHeader />
|
||||
|
||||
<main className="flex-1 flex items-center justify-center p-4 md:p-6 relative z-10">
|
||||
<div className="w-full max-w-md">
|
||||
{children}
|
||||
<p className="text-center mt-8 text-[9px] text-[var(--muted-foreground)] font-bold uppercase tracking-[0.3em] opacity-40 select-none">
|
||||
© 2026 Memento
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -122,7 +122,7 @@ export function AutoLabelSuggestionDialog({
|
||||
onLabelsCreated()
|
||||
onOpenChange(false)
|
||||
} else {
|
||||
toast.error(data.error || t('ai.autoLabels.error'))
|
||||
toast.error(data.errorKey ? t(data.errorKey) : (data.error || t('ai.autoLabels.error')))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create labels:', error)
|
||||
|
||||
@@ -149,7 +149,7 @@ export function BatchOrganizationDialog({
|
||||
onNotesMoved()
|
||||
onOpenChange(false)
|
||||
} else {
|
||||
toast.error(data.error || t('ai.batchOrganization.applyFailed'))
|
||||
toast.error(data.errorKey ? t(data.errorKey) : (data.error || t('ai.batchOrganization.applyFailed')))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to apply organization plan:', error)
|
||||
|
||||
@@ -406,7 +406,12 @@ export function BlockActionMenu({
|
||||
{/* Création de diagramme */}
|
||||
<button type="button" className="block-action-item" onClick={() => { void handleCreateDiagram() }}>
|
||||
<Sparkles size={16} className="text-amber-500 transition-all duration-200" />
|
||||
<span className="font-medium text-amber-700 dark:text-amber-400">{t('blockAction.createDiagram')}</span>
|
||||
<span className="font-medium text-amber-700 dark:text-amber-400 flex flex-col items-start gap-0.5">
|
||||
<span>{t('blockAction.createDiagram')}</span>
|
||||
<span className="text-[9px] font-normal text-muted-foreground normal-case tracking-normal">
|
||||
{t('blockAction.createDiagramCostHint')}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="block-action-separator" />
|
||||
|
||||
@@ -64,7 +64,7 @@ export function ChartSuggestionsDialog({
|
||||
console.error('[ChartSuggestionsDialog] Error:', err)
|
||||
setResponse({
|
||||
suggestions: [],
|
||||
analyzedText: textToAnalyze?.substring(0, 100) || '',
|
||||
analyzedText: (selection || content || '').substring(0, 100),
|
||||
detectedData: 'Error occurred',
|
||||
hasData: false,
|
||||
error: err.message || 'Network error - check console',
|
||||
@@ -124,7 +124,7 @@ export function ChartSuggestionsDialog({
|
||||
<div className="flex items-center gap-3">
|
||||
<BarChart3 className="w-5 h-5 text-blue-500" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Chart Suggestions</h2>
|
||||
<h2 className="text-lg font-semibold">{t('chart.suggestionsTitle') || 'Suggestions de graphiques'}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{loading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
@@ -137,6 +137,9 @@ export function ChartSuggestionsDialog({
|
||||
t('chart.noDataDetected')
|
||||
)}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">
|
||||
{t('chart.creditCostHint') || 'Coût estimé : 2 crédits IA'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useChat } from '@ai-sdk/react'
|
||||
import { DefaultChatTransport } from 'ai'
|
||||
import { ChatSidebar } from './chat-sidebar'
|
||||
import { ChatMessages } from './chat-messages'
|
||||
import { ChatInput } from './chat-input'
|
||||
import { createConversation, getConversationDetails, getConversations, deleteConversation } from '@/app/actions/chat-actions'
|
||||
import { toast } from 'sonner'
|
||||
import type { UIMessage } from 'ai'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface ChatContainerProps {
|
||||
initialConversations: any[]
|
||||
notebooks: any[]
|
||||
webSearchAvailable?: boolean
|
||||
}
|
||||
|
||||
export function ChatContainer({ initialConversations, notebooks, webSearchAvailable }: ChatContainerProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const [conversations, setConversations] = useState(initialConversations)
|
||||
const [currentId, setCurrentId] = useState<string | null>(null)
|
||||
const [selectedNotebook, setSelectedNotebook] = useState<string | undefined>(undefined)
|
||||
const [webSearchEnabled, setWebSearchEnabled] = useState(false)
|
||||
const [historyMessages, setHistoryMessages] = useState<UIMessage[]>([])
|
||||
const [isLoadingHistory, setIsLoadingHistory] = useState(false)
|
||||
|
||||
// Prevents the useEffect from loading an empty conversation
|
||||
// when we just created one via createConversation()
|
||||
const skipHistoryLoad = useRef(false)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const transport = useRef(new DefaultChatTransport({
|
||||
api: '/api/chat',
|
||||
})).current
|
||||
|
||||
const {
|
||||
messages,
|
||||
sendMessage,
|
||||
status,
|
||||
setMessages,
|
||||
stop,
|
||||
} = useChat({
|
||||
transport,
|
||||
onError: (error) => {
|
||||
toast.error(error.message || t('chat.assistantError'))
|
||||
},
|
||||
})
|
||||
|
||||
const isLoading = status === 'submitted' || status === 'streaming'
|
||||
|
||||
const refreshConversations = useCallback(async () => {
|
||||
try {
|
||||
const updated = await getConversations()
|
||||
setConversations(updated)
|
||||
} catch {}
|
||||
}, [])
|
||||
|
||||
// Timeout warning: show toast if response takes > 30s
|
||||
useEffect(() => {
|
||||
if (!isLoading) return
|
||||
const timer = setTimeout(() => {
|
||||
toast.warning(t('chat.timeoutWarning') || 'Response is taking longer than expected...')
|
||||
}, 30000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [isLoading, t])
|
||||
|
||||
// Sync historyMessages after each completed streaming response
|
||||
// so the display doesn't revert to stale history.
|
||||
// Also refresh sidebar so new conversation appears.
|
||||
useEffect(() => {
|
||||
if (status === 'ready' && messages.length > 0) {
|
||||
setHistoryMessages([...messages])
|
||||
refreshConversations()
|
||||
}
|
||||
}, [status, messages, refreshConversations])
|
||||
|
||||
// Load conversation details when the user selects a different conversation
|
||||
useEffect(() => {
|
||||
// Skip if we just created the conversation — useChat already has the messages
|
||||
if (skipHistoryLoad.current) {
|
||||
skipHistoryLoad.current = false
|
||||
return
|
||||
}
|
||||
|
||||
if (currentId) {
|
||||
const loadMessages = async () => {
|
||||
setIsLoadingHistory(true)
|
||||
try {
|
||||
const details = await getConversationDetails(currentId)
|
||||
if (details) {
|
||||
const loaded: UIMessage[] = details.messages.map((m: any, i: number) => ({
|
||||
id: m.id || `hist-${i}`,
|
||||
role: m.role as 'user' | 'assistant',
|
||||
parts: [{ type: 'text' as const, text: m.content }],
|
||||
}))
|
||||
setHistoryMessages(loaded)
|
||||
setMessages(loaded)
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t('chat.loadError'))
|
||||
} finally {
|
||||
setIsLoadingHistory(false)
|
||||
}
|
||||
}
|
||||
loadMessages()
|
||||
} else {
|
||||
setMessages([])
|
||||
setHistoryMessages([])
|
||||
}
|
||||
|
||||
}, [currentId])
|
||||
|
||||
const handleSendMessage = async (content: string, notebookId?: string) => {
|
||||
if (notebookId) {
|
||||
setSelectedNotebook(notebookId)
|
||||
}
|
||||
|
||||
// If no active conversation, create one BEFORE streaming
|
||||
let convId = currentId
|
||||
if (!convId) {
|
||||
try {
|
||||
const result = await createConversation(content, notebookId || selectedNotebook)
|
||||
convId = result.id
|
||||
// Tell the useEffect to skip — we don't want to load an empty conversation
|
||||
skipHistoryLoad.current = true
|
||||
setCurrentId(convId)
|
||||
setHistoryMessages([])
|
||||
setConversations((prev) => [
|
||||
{ id: result.id, title: result.title, updatedAt: new Date() },
|
||||
...prev,
|
||||
])
|
||||
} catch {
|
||||
toast.error(t('chat.createError'))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await sendMessage(
|
||||
{ text: content },
|
||||
{
|
||||
body: {
|
||||
conversationId: convId,
|
||||
notebookId: notebookId || selectedNotebook || undefined,
|
||||
language,
|
||||
webSearch: webSearchEnabled,
|
||||
},
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Chat send error:', error)
|
||||
toast.error(t('chat.assistantError') || 'Failed to send message')
|
||||
}
|
||||
}
|
||||
|
||||
const handleNewChat = () => {
|
||||
setCurrentId(null)
|
||||
setMessages([])
|
||||
setHistoryMessages([])
|
||||
setSelectedNotebook(undefined)
|
||||
setWebSearchEnabled(false)
|
||||
}
|
||||
|
||||
const handleDeleteConversation = async (id: string) => {
|
||||
try {
|
||||
await deleteConversation(id)
|
||||
if (currentId === id) {
|
||||
handleNewChat()
|
||||
}
|
||||
await refreshConversations()
|
||||
} catch {
|
||||
toast.error(t('chat.deleteError'))
|
||||
}
|
||||
}
|
||||
|
||||
// During streaming or if useChat has more messages than history, prefer useChat
|
||||
const displayMessages = isLoading || messages.length > historyMessages.length
|
||||
? messages
|
||||
: historyMessages
|
||||
|
||||
// Auto-scroll to bottom when messages change
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}
|
||||
}, [displayMessages])
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex overflow-hidden bg-background">
|
||||
<ChatSidebar
|
||||
conversations={conversations}
|
||||
currentId={currentId}
|
||||
onSelect={setCurrentId}
|
||||
onNew={handleNewChat}
|
||||
onDelete={handleDeleteConversation}
|
||||
/>
|
||||
|
||||
<div className="flex-1 flex flex-col h-full overflow-hidden">
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto scrollbar-hide pb-6 w-full flex justify-center">
|
||||
<ChatMessages messages={displayMessages} isLoading={isLoading || isLoadingHistory} />
|
||||
</div>
|
||||
|
||||
<div className="w-full flex justify-center sticky bottom-0 bg-gradient-to-t from-background via-background/90 to-transparent pt-6 pb-4">
|
||||
<div className="w-full max-w-4xl px-4">
|
||||
<ChatInput
|
||||
onSend={handleSendMessage}
|
||||
isLoading={isLoading}
|
||||
onStop={stop}
|
||||
notebooks={notebooks}
|
||||
currentNotebookId={selectedNotebook || null}
|
||||
webSearchEnabled={webSearchEnabled}
|
||||
onToggleWebSearch={() => setWebSearchEnabled(prev => !prev)}
|
||||
webSearchAvailable={webSearchAvailable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Send, BookOpen, X, Globe, Square } from 'lucide-react'
|
||||
import { getNotebookIcon } from '@/lib/notebook-icon'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (message: string, notebookId?: string) => void
|
||||
isLoading?: boolean
|
||||
onStop?: () => void
|
||||
notebooks: any[]
|
||||
currentNotebookId?: string | null
|
||||
webSearchEnabled?: boolean
|
||||
onToggleWebSearch?: () => void
|
||||
webSearchAvailable?: boolean
|
||||
}
|
||||
|
||||
export function ChatInput({ onSend, isLoading, onStop, notebooks, currentNotebookId, webSearchEnabled, onToggleWebSearch, webSearchAvailable }: ChatInputProps) {
|
||||
const { t } = useLanguage()
|
||||
const [input, setInput] = useState('')
|
||||
const [selectedNotebook, setSelectedNotebook] = useState<string | undefined>(currentNotebookId || undefined)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (currentNotebookId) {
|
||||
setSelectedNotebook(currentNotebookId)
|
||||
}
|
||||
}, [currentNotebookId])
|
||||
|
||||
const handleSend = () => {
|
||||
if (!input.trim() || isLoading) return
|
||||
onSend(input, selectedNotebook)
|
||||
setInput('')
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto'
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto'
|
||||
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`
|
||||
}
|
||||
}, [input])
|
||||
|
||||
return (
|
||||
<div className="w-full relative">
|
||||
<div className="relative flex flex-col bg-muted/50 rounded-[24px] border border-border/60 shadow-sm focus-within:shadow-md focus-within:border-border transition-all duration-300 overflow-hidden">
|
||||
|
||||
{/* Input Area */}
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
placeholder={t('chat.placeholder')}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="flex-1 min-h-[56px] max-h-[40vh] bg-transparent border-none focus-visible:ring-0 resize-none py-4 px-5 text-[15px] placeholder:text-slate-400"
|
||||
/>
|
||||
|
||||
{/* Bottom Actions Bar */}
|
||||
<div className="flex items-center justify-between px-3 pb-3 pt-1">
|
||||
{/* Context Selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={selectedNotebook || 'global'}
|
||||
onValueChange={(val) => setSelectedNotebook(val === 'global' ? undefined : val)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-auto min-w-[130px] rounded-full bg-background border-border/60 shadow-sm text-xs font-medium gap-2 ring-offset-transparent focus:ring-0 focus:ring-offset-0 hover:bg-muted/50 transition-colors">
|
||||
<BookOpen className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<SelectValue placeholder={t('chat.allNotebooks')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl shadow-lg border-slate-200 dark:border-white/10">
|
||||
<SelectItem value="global" className="rounded-lg text-sm text-muted-foreground">{t('chat.inAllNotebooks')}</SelectItem>
|
||||
{notebooks.map((nb) => (
|
||||
<SelectItem key={nb.id} value={nb.id} className="rounded-lg text-sm">
|
||||
{(() => {
|
||||
const Icon = getNotebookIcon(nb.icon)
|
||||
return <Icon className="w-3.5 h-3.5" />
|
||||
})()} {nb.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{selectedNotebook && (
|
||||
<Badge variant="secondary" className="text-[10px] bg-primary/10 text-primary border-none rounded-full px-2.5 h-6 font-semibold tracking-wide">
|
||||
{t('chat.active')}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{webSearchAvailable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleWebSearch}
|
||||
className={cn(
|
||||
"h-8 rounded-full border shadow-sm text-xs font-medium gap-1.5 flex items-center px-3 transition-all duration-200",
|
||||
webSearchEnabled
|
||||
? "bg-primary/10 text-primary border-primary/30 hover:bg-primary/20"
|
||||
: "bg-background border-border/60 text-muted-foreground hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
<Globe className="h-3.5 w-3.5" />
|
||||
{webSearchEnabled && t('chat.webSearch')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Send / Stop Button */}
|
||||
{isLoading && onStop ? (
|
||||
<Button
|
||||
onClick={onStop}
|
||||
size="icon"
|
||||
className="rounded-full h-8 w-8 bg-red-500 text-white shadow-sm hover:bg-red-600 transition-all duration-200"
|
||||
>
|
||||
<Square className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
disabled={!input.trim() || isLoading}
|
||||
onClick={handleSend}
|
||||
size="icon"
|
||||
className={cn(
|
||||
"rounded-full h-8 w-8 transition-all duration-200",
|
||||
input.trim() ? "bg-primary text-primary-foreground shadow-sm hover:scale-105" : "bg-slate-200 dark:bg-slate-700 text-slate-400 dark:text-slate-500"
|
||||
)}
|
||||
>
|
||||
<Send className="h-4 w-4 ml-0.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-3">
|
||||
<span className="text-[11px] text-muted-foreground/60 w-full block">
|
||||
{t('chat.disclaimer')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user