Compare commits
21 Commits
fix/bugs-b
...
afbb0dfc2d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afbb0dfc2d | ||
|
|
ebf6f16fde | ||
|
|
e0fd5393ca | ||
|
|
80ccc1f6de | ||
|
|
69c99e4f4f | ||
|
|
f385d43d5d | ||
|
|
324bf40658 | ||
|
|
86505659cf | ||
|
|
4946c0d44a | ||
|
|
0ee2afbd62 | ||
|
|
bff060ad20 | ||
|
|
1987b084c3 | ||
|
|
07ac42cbee | ||
|
|
1e9b1b30c9 | ||
|
|
d5c44aafb6 | ||
|
|
9d10815415 | ||
|
|
cf3b5483d2 | ||
|
|
6eb46fd1b7 | ||
|
|
a7c15e9fc3 | ||
|
|
88a7d2ad0a | ||
|
|
e8ee53c815 |
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'],
|
||||
},
|
||||
});
|
||||
@@ -8,7 +8,32 @@ import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { updateBillingConfig } from '@/app/actions/admin-billing'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { CreditCard, Gauge, Coins, Package } 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 PriceHealth = {
|
||||
key: string
|
||||
priceId: string
|
||||
configured: boolean
|
||||
source: string
|
||||
stripeAmount: number | null
|
||||
stripeCurrency: string | null
|
||||
stripeActive: boolean | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
type BillingAdminData = {
|
||||
billingConfig: Record<string, string>
|
||||
@@ -19,7 +44,6 @@ type BillingAdminData = {
|
||||
topUsers: Array<{ userId: string; email: string; name: string | null; requests: number }>
|
||||
}
|
||||
tiers: string[]
|
||||
/** Allocations mensuelles du solde unique (source de vérité débit) */
|
||||
creditAllocations?: Array<{
|
||||
tier: string
|
||||
monthlyCredits: number | null
|
||||
@@ -31,6 +55,35 @@ type BillingAdminData = {
|
||||
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
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
const SUBSCRIPTION_PRICE_KEYS = [
|
||||
@@ -46,7 +99,6 @@ const PACK_PRICE_KEYS = [
|
||||
'STRIPE_PRICE_CREDITS_L',
|
||||
] as const
|
||||
|
||||
/** Date stable SSR/client (pas de toLocaleString — mismatch locale + fuseau). */
|
||||
function formatStableUtc(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
@@ -54,10 +106,21 @@ function formatStableUtc(iso: string): string {
|
||||
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 [billingEnabled, setBillingEnabled] = useState(initialData.billingConfig.BILLING_ENABLED === 'true')
|
||||
const [isSavingBilling, setIsSavingBilling] = useState(false)
|
||||
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)
|
||||
@@ -77,6 +140,15 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
<div>
|
||||
@@ -84,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">
|
||||
@@ -106,17 +418,28 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
|
||||
<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) => (
|
||||
<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>
|
||||
))}
|
||||
{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">
|
||||
@@ -144,7 +467,6 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Solde unique — source de vérité du débit */}
|
||||
<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">
|
||||
@@ -216,7 +538,6 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
|
||||
{t('admin.billing.usagePeriod', { period: initialData.usageOverview.period })}
|
||||
{initialData.usageOverview.lastSyncedAt
|
||||
? ` · ${t('admin.billing.lastSync', {
|
||||
// Format fixe UTC (évite mismatch SSR locale/fuseau vs client)
|
||||
date: formatStableUtc(initialData.usageOverview.lastSyncedAt),
|
||||
})}`
|
||||
: ` · ${t('admin.billing.notSynced')}`}
|
||||
|
||||
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,58 +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 { AuthShell } from '@/components/auth-shell';
|
||||
|
||||
import { LanguageProvider, 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 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" />
|
||||
|
||||
<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>
|
||||
<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">
|
||||
|
||||
@@ -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,9 +12,10 @@ export default function SettingsLayout({
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-[#F2F0E9] dark:bg-zinc-950">
|
||||
<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 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'))}
|
||||
@@ -22,10 +24,10 @@ export default function SettingsLayout({
|
||||
<Menu size={22} />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-3xl sm:text-5xl md:text-[64px] font-serif text-ink dark:text-zinc-50 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 dark:text-zinc-500 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>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import { processNoteHtmlForPublish } from '@/lib/publish/process-note-html'
|
||||
import { REWRITE_SHARED_CSS, KATEX_PUBLISH_CSS } from '@/lib/publish/shared-css'
|
||||
import { ReadingProgress } from '@/components/publish/reading-progress'
|
||||
import { CopyLinkButton } from '@/components/publish/copy-link-button'
|
||||
import { InteractivePublishedPage } from '@/components/interactive-page/interactive-published-page'
|
||||
import { isInteractivePageTemplate } from '@/lib/publish/types'
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params
|
||||
@@ -1075,16 +1077,29 @@ export default async function PublishedNotePage({ params }: { params: Promise<{
|
||||
const note = await getPublishedNote(slug)
|
||||
if (!note) notFound()
|
||||
|
||||
const isStale = Boolean(
|
||||
note.publishedSourceHash
|
||||
&& computePublishedSourceHash(note.content || '') !== note.publishedSourceHash
|
||||
)
|
||||
|
||||
if (
|
||||
isInteractivePageTemplate(note.publishedTemplate) &&
|
||||
note.publishedContent
|
||||
) {
|
||||
return (
|
||||
<InteractivePublishedPage
|
||||
publishedContent={note.publishedContent}
|
||||
isStale={isStale}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const usesAiLayout = Boolean(note.publishedContent)
|
||||
const rawHtml = usesAiLayout ? note.publishedContent! : (note.content || '')
|
||||
const bodyHtml = processNoteHtmlForPublish(rawHtml)
|
||||
|
||||
const readingSource = usesAiLayout ? note.publishedContent! : (note.content || '')
|
||||
const readingTime = estimateReadingTime(readingSource)
|
||||
const isStale = Boolean(
|
||||
note.publishedSourceHash
|
||||
&& computePublishedSourceHash(note.content || '') !== note.publishedSourceHash
|
||||
)
|
||||
|
||||
const props: PageProps = { note, bodyHtml, readingTime, slug, isStale }
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -46,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')
|
||||
@@ -83,6 +229,11 @@ export async function getBillingAdminData() {
|
||||
}
|
||||
})
|
||||
|
||||
const [stripeHealth, subscriptionStats] = await Promise.all([
|
||||
getStripeHealth(billingConfig),
|
||||
getSubscriptionStats(),
|
||||
])
|
||||
|
||||
return {
|
||||
entitlements,
|
||||
billingConfig,
|
||||
@@ -92,6 +243,8 @@ export async function getBillingAdminData() {
|
||||
creditAllocations,
|
||||
creditCosts,
|
||||
creditPacks,
|
||||
stripeHealth,
|
||||
subscriptionStats,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
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)}`);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -2,6 +2,10 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@/auth';
|
||||
import { stripe } from '@/lib/stripe';
|
||||
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';
|
||||
|
||||
@@ -91,6 +95,17 @@ export async function POST(req: NextRequest) {
|
||||
const proto = req.headers.get('x-forwarded-proto') ?? 'http';
|
||||
const origin = `${proto}://${host}`;
|
||||
|
||||
const offerTrial = await shouldOfferSubscriptionTrial(userId);
|
||||
const subscriptionData: {
|
||||
metadata: { userId: string; tier: string };
|
||||
trial_period_days?: number;
|
||||
} = {
|
||||
metadata: { userId, tier },
|
||||
};
|
||||
if (offerTrial) {
|
||||
subscriptionData.trial_period_days = SUBSCRIPTION_TRIAL_DAYS;
|
||||
}
|
||||
|
||||
// Hosted Checkout is the most reliable path (redirect). Embedded is optional.
|
||||
if (preferredMode === 'embedded') {
|
||||
try {
|
||||
@@ -100,8 +115,8 @@ export async function POST(req: NextRequest) {
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
ui_mode: 'embedded' as any,
|
||||
return_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`,
|
||||
metadata: { userId, tier },
|
||||
subscription_data: { metadata: { userId, tier } },
|
||||
metadata: { userId, tier, trial: offerTrial ? '1' : '0' },
|
||||
subscription_data: subscriptionData,
|
||||
customer_update: { address: 'auto' },
|
||||
allow_promotion_codes: true,
|
||||
} as any);
|
||||
@@ -109,6 +124,7 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({
|
||||
clientSecret: embedded.client_secret,
|
||||
sessionId: embedded.id,
|
||||
trialDays: offerTrial ? SUBSCRIPTION_TRIAL_DAYS : 0,
|
||||
});
|
||||
}
|
||||
} catch (embeddedErr) {
|
||||
@@ -122,8 +138,8 @@ export async function POST(req: NextRequest) {
|
||||
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 },
|
||||
subscription_data: { metadata: { userId, tier } },
|
||||
metadata: { userId, tier, trial: offerTrial ? '1' : '0' },
|
||||
subscription_data: subscriptionData,
|
||||
customer_update: { address: 'auto' },
|
||||
allow_promotion_codes: true,
|
||||
});
|
||||
@@ -132,7 +148,11 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'Checkout session has no URL' }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ url: checkoutSession.url, sessionId: checkoutSession.id });
|
||||
return NextResponse.json({
|
||||
url: checkoutSession.url,
|
||||
sessionId: checkoutSession.id,
|
||||
trialDays: offerTrial ? SUBSCRIPTION_TRIAL_DAYS : 0,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[billing/create-checkout]', error);
|
||||
const msg = error instanceof Error ? error.message : 'Failed to create checkout session';
|
||||
|
||||
@@ -2,7 +2,10 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@/auth';
|
||||
import { getUserInfo, getEffectiveTier } from '@/lib/entitlements';
|
||||
import { stripe } from '@/lib/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';
|
||||
|
||||
@@ -23,56 +26,53 @@ 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');
|
||||
@@ -112,14 +112,19 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
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';
|
||||
@@ -159,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)
|
||||
|
||||
@@ -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' } },
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -42,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",
|
||||
@@ -101,7 +101,7 @@ export default async function RootLayout({
|
||||
const htmlStyle = {
|
||||
'--color-brand-accent': serverAccent,
|
||||
// Aide le navigateur à peindre scrollbars/inputs dans le bon mode
|
||||
colorScheme: wantsDarkClass || resolvedTheme === 'dark' || resolvedTheme === 'midnight' ? 'dark' : resolvedTheme === 'light' ? 'light' : undefined,
|
||||
colorScheme: wantsDarkClass ? 'dark' : resolvedTheme === 'light' ? 'light' : undefined,
|
||||
} as CSSProperties
|
||||
|
||||
return (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { User, Bot, Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface ChatMessagesProps {
|
||||
messages: any[]
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
function getMessageContent(msg: any): string {
|
||||
if (typeof msg.content === 'string') return msg.content
|
||||
if (msg.parts && Array.isArray(msg.parts)) {
|
||||
return msg.parts
|
||||
.filter((p: any) => p.type === 'text')
|
||||
.map((p: any) => p.text)
|
||||
.join('')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function ChatMessages({ messages, isLoading }: ChatMessagesProps) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-4xl flex flex-col pt-8 pb-4">
|
||||
{messages.length === 0 && !isLoading && (
|
||||
<div className="flex flex-col items-center justify-center h-[60vh] text-center space-y-6">
|
||||
<div className="p-5 bg-gradient-to-br from-primary/10 to-primary/5 rounded-full shadow-inner ring-1 ring-primary/10">
|
||||
<Bot className="h-12 w-12 text-primary opacity-60" />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm md:text-base max-w-md px-4 font-medium">
|
||||
{t('chat.welcome')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, index) => {
|
||||
const content = getMessageContent(msg)
|
||||
const isLastAssistant = msg.role === 'assistant' && index === messages.length - 1 && isLoading
|
||||
|
||||
return (
|
||||
<div
|
||||
key={msg.id || index}
|
||||
className={cn(
|
||||
"flex w-full px-4 md:px-0 py-6 my-2 group",
|
||||
msg.role === 'user' ? "justify-end" : "justify-start border-y border-transparent dark:border-transparent"
|
||||
)}
|
||||
>
|
||||
{msg.role === 'user' ? (
|
||||
<div dir="auto" className="max-w-[85%] md:max-w-[70%] bg-muted text-foreground rounded-3xl rounded-br-md px-6 py-4 shadow-sm border border-border/50">
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none text-[15px] leading-relaxed">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-4 md:gap-6 w-full max-w-3xl">
|
||||
<Avatar className="h-8 w-8 shrink-0 bg-transparent border border-primary/20 text-primary mt-1 shadow-sm">
|
||||
<AvatarFallback className="bg-transparent"><Bot className="h-4 w-4" /></AvatarFallback>
|
||||
</Avatar>
|
||||
<div dir="auto" className="flex-1 overflow-hidden pt-1">
|
||||
{content ? (
|
||||
<div className="prose prose-slate dark:prose-invert max-w-none prose-p:leading-relaxed prose-pre:bg-slate-900 prose-pre:shadow-sm prose-pre:border prose-pre:border-slate-800 prose-headings:font-semibold marker:text-primary/50 text-[15px] prose-table:border prose-table:border-slate-300 prose-th:border prose-th:border-slate-300 prose-th:px-3 prose-th:py-2 prose-th:bg-slate-100 dark:prose-th:bg-slate-800 prose-td:border prose-td:border-slate-300 prose-td:px-3 prose-td:py-2">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||
</div>
|
||||
) : isLastAssistant ? (
|
||||
<div className="flex items-center gap-3 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||
<span className="text-[15px] animate-pulse">{t('chat.searching')}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale/fr'
|
||||
import { enUS } from 'date-fns/locale/en-US'
|
||||
import { MessageSquare, Trash2, Plus, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface ChatSidebarProps {
|
||||
conversations: any[]
|
||||
currentId?: string | null
|
||||
onSelect: (id: string) => void
|
||||
onNew: () => void
|
||||
onDelete?: (id: string) => void
|
||||
}
|
||||
|
||||
export function ChatSidebar({
|
||||
conversations,
|
||||
currentId,
|
||||
onSelect,
|
||||
onNew,
|
||||
onDelete,
|
||||
}: ChatSidebarProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const dateLocale = language === 'fr' ? fr : enUS
|
||||
const [pendingDelete, setPendingDelete] = useState<string | null>(null)
|
||||
|
||||
const confirmDelete = (id: string) => {
|
||||
setPendingDelete(id)
|
||||
}
|
||||
|
||||
const cancelDelete = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setPendingDelete(null)
|
||||
}
|
||||
|
||||
const executeDelete = async (e: React.MouseEvent, id: string) => {
|
||||
e.stopPropagation()
|
||||
setPendingDelete(null)
|
||||
if (onDelete) {
|
||||
await onDelete(id)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-64 border-r flex flex-col h-full bg-sidebar">
|
||||
<div className="p-4 border-bottom">
|
||||
<Button
|
||||
onClick={onNew}
|
||||
className="w-full justify-start gap-2 shadow-sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('chat.newConversation')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
{conversations.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||
{t('chat.noHistory')}
|
||||
</div>
|
||||
) : (
|
||||
conversations.map((chat) => (
|
||||
<div
|
||||
key={chat.id}
|
||||
onClick={() => onSelect(chat.id)}
|
||||
className={cn(
|
||||
"relative cursor-pointer rounded-lg transition-all group",
|
||||
currentId === chat.id
|
||||
? "bg-primary/10 text-primary dark:bg-primary/20"
|
||||
: "hover:bg-muted/50 text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<div className="p-3 flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate text-sm font-medium pr-6">
|
||||
{chat.title || t('chat.untitled')}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] opacity-60 ml-6" suppressHydrationWarning>
|
||||
{formatDistanceToNow(new Date(chat.updatedAt), { addSuffix: true, locale: dateLocale })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Delete button — visible on hover or when confirming */}
|
||||
{pendingDelete !== chat.id && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); confirmDelete(chat.id) }}
|
||||
className="absolute top-3 right-2 opacity-0 group-hover:opacity-100 p-1 hover:text-destructive transition-all"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Inline confirmation banner */}
|
||||
{pendingDelete === chat.id && (
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-destructive/10 text-destructive text-xs border-t border-destructive/20 rounded-b-lg"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className="flex-1 font-medium">{t('chat.deleteConfirm')}</span>
|
||||
<button
|
||||
onClick={(e) => executeDelete(e, chat.id)}
|
||||
className="px-2 py-0.5 bg-destructive text-white rounded text-[10px] font-semibold hover:bg-destructive/90 transition-colors"
|
||||
>
|
||||
{t('chat.yes')}
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelDelete}
|
||||
className="p-0.5 hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
42
memento-note/components/confirm-delete-note-dialog.tsx
Normal file
42
memento-note/components/confirm-delete-note-dialog.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export function ConfirmDeleteNoteDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConfirm: () => void | Promise<void>
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('notes.confirmDeleteTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t('notes.confirmDelete')}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={() => { void onConfirm() }}>
|
||||
{t('notes.delete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -345,7 +345,7 @@ export function ContextualAIChat({
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || t('ai.genericError'))
|
||||
if (!res.ok) throw new Error(data.errorKey ? t(data.errorKey) : (data.error || t('ai.genericError')))
|
||||
const descs = data.descriptions || []
|
||||
let resultText = descs.map((d: any) =>
|
||||
noteImages.length > 1 ? `**Image ${d.index + 1}:** ${d.description}` : d.description
|
||||
@@ -381,7 +381,7 @@ export function ContextualAIChat({
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || t('ai.genericError'))
|
||||
if (!res.ok) throw new Error(data.errorKey ? t(data.errorKey) : (data.error || t('ai.genericError')))
|
||||
const result = data[action.resultKey] || ''
|
||||
setActionPreview({ label: t(action.i18nKey), text: result, asRichText: action.id === 'toRichText' })
|
||||
} catch (e: any) {
|
||||
@@ -457,7 +457,7 @@ export function ContextualAIChat({
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok || !data.success) {
|
||||
mToast.error(data.error || t('ai.errorShort'), { id: toastId })
|
||||
mToast.error(data.errorKey ? t(data.errorKey) : (data.error || t('ai.errorShort')), { id: toastId })
|
||||
setGenerateLoading(null)
|
||||
return
|
||||
}
|
||||
@@ -577,7 +577,7 @@ export function ContextualAIChat({
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || t('ai.resource.enrichError'))
|
||||
if (!res.ok) throw new Error(data.errorKey ? t(data.errorKey) : (data.error || t('ai.resource.enrichError')))
|
||||
setResourcePreview({ text: data.enrichedContent, source: resourceMode })
|
||||
} catch (e: any) {
|
||||
mToast.error(e.message || t('ai.resource.enrichError'))
|
||||
@@ -622,7 +622,7 @@ export function ContextualAIChat({
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || t('ai.genericError'))
|
||||
if (!res.ok) throw new Error(data.errorKey ? t(data.errorKey) : (data.error || t('ai.genericError')))
|
||||
setResourcePreview({ text: data.enrichedContent, source: mode })
|
||||
} catch (e: any) {
|
||||
mToast.error(e.message || t('ai.resource.enrichErrorShort'))
|
||||
|
||||
@@ -86,18 +86,17 @@ export function DashboardActionStrip({
|
||||
const Wrapper = item.pulse && !prefersReducedMotion ? motion.button : 'button'
|
||||
const motionProps = item.pulse && !prefersReducedMotion
|
||||
? {
|
||||
key: `inbox-pulse-${inboxPulse}`,
|
||||
animate: { scale: [1, 1.03, 1] },
|
||||
transition: { duration: 0.45 },
|
||||
}
|
||||
: {}
|
||||
return (
|
||||
<Wrapper
|
||||
key={item.key}
|
||||
key={item.pulse && !prefersReducedMotion ? `inbox-pulse-${inboxPulse}` : item.key}
|
||||
type="button"
|
||||
onClick={item.onClick}
|
||||
{...motionProps}
|
||||
className={`shrink-0 flex items-center gap-2.5 px-3.5 py-2.5 rounded-xl border transition-all text-start min-w-[108px] ${
|
||||
className={`shrink-0 flex items-center gap-2.5 px-3.5 py-2.5 rounded-xl border transition-all text-start min-w-[128px] ${
|
||||
item.accent
|
||||
? 'border-brand-accent/30 bg-brand-accent/[0.06] hover:border-brand-accent/50 hover:bg-brand-accent/10 shadow-sm'
|
||||
: 'border-border/25 bg-white/60 dark:bg-zinc-900/40 hover:border-border/50'
|
||||
@@ -114,7 +113,7 @@ export function DashboardActionStrip({
|
||||
}`}>
|
||||
{item.value}
|
||||
</p>
|
||||
<p className="text-[8px] font-mono font-bold uppercase tracking-wider text-concrete truncate mt-0.5">
|
||||
<p className="text-[13px] font-medium text-concrete truncate mt-0.5">
|
||||
{item.label}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useRef } from 'react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { Search, Bot, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
|
||||
import { Search, Bot, ChevronLeft, ChevronRight, Loader2, Check } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
|
||||
|
||||
@@ -14,6 +14,11 @@ export interface AgentSuggestion {
|
||||
suggestedFrequency: string
|
||||
}
|
||||
|
||||
export interface CreatedAgentNotice {
|
||||
id: string | null
|
||||
topic: string
|
||||
}
|
||||
|
||||
export interface DashboardAgentCarouselProps {
|
||||
suggestions: AgentSuggestion[]
|
||||
loading?: boolean
|
||||
@@ -21,6 +26,9 @@ export interface DashboardAgentCarouselProps {
|
||||
formatFrequency: (f: string) => string
|
||||
onAccept: (id: string) => void
|
||||
onDismiss: (id: string) => void
|
||||
createdAgent?: CreatedAgentNotice | null
|
||||
onOpenCreated?: () => void
|
||||
onClearCreated?: () => void
|
||||
prefersReducedMotion?: boolean
|
||||
}
|
||||
|
||||
@@ -31,29 +39,46 @@ export function DashboardAgentCarousel({
|
||||
formatFrequency,
|
||||
onAccept,
|
||||
onDismiss,
|
||||
createdAgent,
|
||||
onOpenCreated,
|
||||
onClearCreated,
|
||||
prefersReducedMotion,
|
||||
}: DashboardAgentCarouselProps) {
|
||||
const { t } = useLanguage()
|
||||
const [idx, setIdx] = useState(0)
|
||||
const directionRef = useRef(1)
|
||||
const safeIdx = suggestions.length === 0 ? 0 : Math.min(idx, suggestions.length - 1)
|
||||
|
||||
const navActions = suggestions.length > 1 ? (
|
||||
const goPrev = () => {
|
||||
directionRef.current = -1
|
||||
setIdx(i => Math.max(0, i - 1))
|
||||
}
|
||||
const goNext = () => {
|
||||
directionRef.current = 1
|
||||
setIdx(i => Math.min(suggestions.length - 1, i + 1))
|
||||
}
|
||||
|
||||
const showCreated = !!createdAgent
|
||||
const showNav = !showCreated && suggestions.length > 1
|
||||
|
||||
const navActions = showNav ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIdx(i => Math.max(0, i - 1))}
|
||||
disabled={idx === 0}
|
||||
onClick={goPrev}
|
||||
disabled={safeIdx === 0}
|
||||
className="p-1 rounded border border-border/30 disabled:opacity-25"
|
||||
aria-label={t('homeDashboard.intelPrev')}
|
||||
>
|
||||
<ChevronLeft size={12} />
|
||||
</button>
|
||||
<span className="text-[8px] font-mono text-concrete px-1">
|
||||
{idx + 1}/{suggestions.length}
|
||||
{safeIdx + 1}/{suggestions.length}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIdx(i => Math.min(suggestions.length - 1, i + 1))}
|
||||
disabled={idx >= suggestions.length - 1}
|
||||
onClick={goNext}
|
||||
disabled={safeIdx >= suggestions.length - 1}
|
||||
className="p-1 rounded border border-border/30 disabled:opacity-25"
|
||||
aria-label={t('homeDashboard.intelNext')}
|
||||
>
|
||||
@@ -73,9 +98,18 @@ export function DashboardAgentCarousel({
|
||||
icon={<Bot size={12} className="text-brand-accent" />}
|
||||
title={t('homeDashboard.suggestedResearch')}
|
||||
actions={navActions}
|
||||
wrapTitle
|
||||
/>
|
||||
|
||||
{suggestions.length === 0 ? (
|
||||
{showCreated && createdAgent ? (
|
||||
<CreatedAgentPanel
|
||||
topic={createdAgent.topic}
|
||||
hasMore={suggestions.length > 0}
|
||||
onOpen={onOpenCreated}
|
||||
onSeeNext={onClearCreated}
|
||||
t={t}
|
||||
/>
|
||||
) : suggestions.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-border/35 bg-stone-50/50 dark:bg-zinc-950/30 p-3">
|
||||
<p className="text-[11px] text-concrete leading-relaxed">
|
||||
{t('homeDashboard.agentsEmpty')}
|
||||
@@ -83,7 +117,8 @@ export function DashboardAgentCarousel({
|
||||
</div>
|
||||
) : (
|
||||
<AgentSlide
|
||||
current={suggestions[idx]}
|
||||
current={suggestions[safeIdx]}
|
||||
direction={directionRef.current}
|
||||
actingId={actingId}
|
||||
formatFrequency={formatFrequency}
|
||||
onAccept={onAccept}
|
||||
@@ -96,8 +131,57 @@ export function DashboardAgentCarousel({
|
||||
)
|
||||
}
|
||||
|
||||
function CreatedAgentPanel({
|
||||
topic,
|
||||
hasMore,
|
||||
onOpen,
|
||||
onSeeNext,
|
||||
t,
|
||||
}: {
|
||||
topic: string
|
||||
hasMore: boolean
|
||||
onOpen?: () => void
|
||||
onSeeNext?: () => void
|
||||
t: (key: string) => string
|
||||
}) {
|
||||
return (
|
||||
<div className="p-3.5 rounded-xl border border-border/25 bg-stone-50/60 dark:bg-zinc-950/40">
|
||||
<div className="flex items-start gap-2 mb-2">
|
||||
<Check size={14} className="text-brand-accent shrink-0 mt-0.5" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-ink dark:text-dark-ink leading-snug">
|
||||
{t('homeDashboard.agentCreatedInCard')}
|
||||
</p>
|
||||
{topic ? (
|
||||
<p className="text-[11px] text-concrete leading-relaxed mt-1">{topic}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-3">
|
||||
{hasMore && onSeeNext ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSeeNext}
|
||||
className="text-[9px] font-mono uppercase px-2.5 py-1.5 rounded-lg border border-border/40 text-concrete hover:text-ink transition-colors"
|
||||
>
|
||||
{t('homeDashboard.agentSeeNextSuggestion')}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="flex-1 inline-flex items-center justify-center gap-1 text-[9px] font-mono uppercase px-2.5 py-1.5 rounded-lg bg-brand-accent text-white font-bold hover:bg-brand-accent/90"
|
||||
>
|
||||
{t('homeDashboard.agentOpenCreated')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentSlide({
|
||||
current,
|
||||
direction,
|
||||
actingId,
|
||||
formatFrequency,
|
||||
onAccept,
|
||||
@@ -106,6 +190,7 @@ function AgentSlide({
|
||||
t,
|
||||
}: {
|
||||
current: AgentSuggestion
|
||||
direction: number
|
||||
actingId: string | null
|
||||
formatFrequency: (f: string) => string
|
||||
onAccept: (id: string) => void
|
||||
@@ -115,7 +200,11 @@ function AgentSlide({
|
||||
}) {
|
||||
const slide = prefersReducedMotion
|
||||
? { initial: {}, animate: {}, exit: {} }
|
||||
: { initial: { opacity: 0, y: 8 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, y: -8 } }
|
||||
: {
|
||||
initial: { opacity: 0, x: 14 * direction },
|
||||
animate: { opacity: 1, x: 0 },
|
||||
exit: { opacity: 0, x: -14 * direction },
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
@@ -148,7 +237,7 @@ function AgentSlide({
|
||||
type="button"
|
||||
disabled={actingId === current.id}
|
||||
onClick={() => onAccept(current.id)}
|
||||
className="flex-1 inline-flex items-center justify-center gap-1 text-[9px] font-mono uppercase px-2.5 py-1.5 rounded-lg bg-ink text-white dark:bg-white dark:text-black font-bold hover:opacity-90 disabled:opacity-40"
|
||||
className="flex-1 inline-flex items-center justify-center gap-1 text-[9px] font-mono uppercase px-2.5 py-1.5 rounded-lg bg-brand-accent text-white font-bold hover:bg-brand-accent/90 disabled:opacity-40"
|
||||
>
|
||||
{actingId === current.id ? <Loader2 size={10} className="animate-spin" /> : null}
|
||||
{t('homeDashboard.createAgent')}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { motion, useReducedMotion } from 'motion/react'
|
||||
import { Inbox, GraduationCap, Mail, Pin, Bot, BarChart3 } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { RevisionHeatmap } from '@/components/flashcards/revision-heatmap'
|
||||
import { UsageMeter } from '@/components/usage-meter'
|
||||
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
|
||||
import { pathNoteTitle } from '@/lib/dashboard/path-title'
|
||||
import type { DashboardWidgetId } from '@/lib/dashboard/layout'
|
||||
|
||||
interface DashboardWidgetShellProps {
|
||||
@@ -40,16 +42,40 @@ export function DashboardWidgetShell({
|
||||
)
|
||||
}
|
||||
|
||||
function inboxDisplayTitle(
|
||||
note: { title: string | null; excerpt?: string },
|
||||
fallback: string,
|
||||
): string {
|
||||
const titled = note.title?.trim()
|
||||
if (titled) return titled
|
||||
const excerpt = note.excerpt?.trim()
|
||||
if (!excerpt) return fallback
|
||||
return excerpt.length > 80 ? `${excerpt.slice(0, 80).trim()}…` : excerpt
|
||||
}
|
||||
|
||||
export function DashboardInboxWidget({
|
||||
count,
|
||||
notes,
|
||||
loading,
|
||||
onOpen,
|
||||
onSelect,
|
||||
formatRelativeTime,
|
||||
}: {
|
||||
count: number
|
||||
notes: Array<{
|
||||
id: string
|
||||
title: string | null
|
||||
excerpt?: string
|
||||
notebookId: string | null
|
||||
updatedAt?: string
|
||||
}>
|
||||
loading: boolean
|
||||
onOpen: () => void
|
||||
onSelect: (id: string, notebookId: string | null) => void
|
||||
formatRelativeTime?: (date: string) => string
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
const reduced = !!useReducedMotion()
|
||||
const untitled = t('homeDashboard.untitled')
|
||||
return (
|
||||
<DashboardWidgetShell
|
||||
widgetId="inbox"
|
||||
@@ -58,19 +84,43 @@ export function DashboardInboxWidget({
|
||||
compact
|
||||
>
|
||||
{loading ? (
|
||||
<div className="h-10 rounded-lg bg-stone-50 animate-pulse" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-10 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
|
||||
<div className="h-8 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
|
||||
</div>
|
||||
) : notes.length === 0 ? (
|
||||
<p className="text-[11px] text-concrete italic py-1">{t('homeDashboard.inboxEmpty')}</p>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="w-full flex items-center justify-between gap-3 p-3 rounded-xl border border-border/20 hover:border-brand-accent/30 hover:bg-brand-accent/[0.03] transition-all text-start"
|
||||
>
|
||||
<div>
|
||||
<p className="text-2xl font-serif font-bold text-ink dark:text-dark-ink leading-none">{count}</p>
|
||||
<p className="text-[10px] text-concrete mt-1">{t('homeDashboard.toOrganize')}</p>
|
||||
</div>
|
||||
<span className="text-[9px] font-mono uppercase font-bold text-brand-accent">{t('homeDashboard.widgetOpen')} →</span>
|
||||
</button>
|
||||
<div className="space-y-1.5">
|
||||
{notes.map((note, idx) => (
|
||||
<motion.button
|
||||
key={note.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(note.id, note.notebookId)}
|
||||
initial={reduced ? false : { opacity: 0, x: 8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: reduced ? 0 : 0.22, delay: reduced ? 0 : Math.min(idx * 0.05, 0.15), ease: [0.16, 1, 0.3, 1] }}
|
||||
className="w-full text-start p-2.5 rounded-xl border border-border/20 hover:border-brand-accent/30 hover:bg-brand-accent/[0.03] transition-all"
|
||||
>
|
||||
<p className="text-[11px] text-ink dark:text-dark-ink truncate">
|
||||
{inboxDisplayTitle(note, untitled)}
|
||||
</p>
|
||||
<p className="text-[9px] text-concrete truncate mt-0.5">
|
||||
{t('homeDashboard.inbox')}
|
||||
{note.updatedAt && formatRelativeTime
|
||||
? ` · ${formatRelativeTime(note.updatedAt)}`
|
||||
: ''}
|
||||
</p>
|
||||
</motion.button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="w-full text-start px-1 pt-1 text-[9px] font-mono uppercase font-bold text-brand-accent hover:underline"
|
||||
>
|
||||
{t('homeDashboard.inboxOpenList')} →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</DashboardWidgetShell>
|
||||
)
|
||||
@@ -94,7 +144,7 @@ export function DashboardRevisionWidget({
|
||||
compact
|
||||
>
|
||||
{loading ? (
|
||||
<div className="h-10 rounded-lg bg-stone-50 animate-pulse" />
|
||||
<div className="h-10 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
@@ -135,7 +185,7 @@ export function DashboardStatsWidget({
|
||||
>
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[0, 1, 2].map(i => <div key={i} className="h-12 rounded-lg bg-stone-50 animate-pulse" />)}
|
||||
{[0, 1, 2].map(i => <div key={i} className="h-12 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />)}
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" onClick={onOpen} className="w-full text-start">
|
||||
@@ -176,8 +226,8 @@ export function DashboardAgentActivityWidget({
|
||||
>
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
<div className="h-8 rounded-lg bg-stone-50 animate-pulse" />
|
||||
<div className="h-8 rounded-lg bg-stone-50 animate-pulse" />
|
||||
<div className="h-8 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
|
||||
<div className="h-8 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
|
||||
</div>
|
||||
) : actions.length === 0 ? (
|
||||
<p className="text-[10px] text-concrete italic py-2">{t('homeDashboard.widgetAgentActivityEmpty')}</p>
|
||||
@@ -222,7 +272,7 @@ export function DashboardGmailWidget({
|
||||
compact
|
||||
>
|
||||
{loading ? (
|
||||
<div className="h-10 rounded-lg bg-stone-50 animate-pulse" />
|
||||
<div className="h-10 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
|
||||
) : connected ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -249,12 +299,22 @@ export function DashboardPinnedWidget({
|
||||
notes,
|
||||
loading,
|
||||
onSelect,
|
||||
formatRelativeTime,
|
||||
}: {
|
||||
notes: { id: string; title: string | null; notebookId: string | null }[]
|
||||
notes: {
|
||||
id: string
|
||||
title: string | null
|
||||
excerpt?: string
|
||||
notebookId: string | null
|
||||
updatedAt?: string
|
||||
notebook?: { name: string; color: string | null } | null
|
||||
}[]
|
||||
loading: boolean
|
||||
onSelect: (id: string, notebookId: string | null) => void
|
||||
formatRelativeTime?: (date: string) => string
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
const untitled = t('homeDashboard.untitled')
|
||||
return (
|
||||
<DashboardWidgetShell
|
||||
widgetId="pinned"
|
||||
@@ -264,22 +324,42 @@ export function DashboardPinnedWidget({
|
||||
>
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
<div className="h-8 rounded-lg bg-stone-50 animate-pulse" />
|
||||
<div className="h-8 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
|
||||
</div>
|
||||
) : notes.length === 0 ? (
|
||||
<p className="text-[10px] text-concrete italic py-2">{t('homeDashboard.widgetPinnedEmpty')}</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{notes.map(n => (
|
||||
<button
|
||||
key={n.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(n.id, n.notebookId)}
|
||||
className="w-full text-[10px] text-ink dark:text-dark-ink truncate text-start p-2 rounded-lg hover:bg-brand-accent/[0.04] transition-colors"
|
||||
>
|
||||
{n.title || t('homeDashboard.untitled')}
|
||||
</button>
|
||||
))}
|
||||
<div className="space-y-1.5">
|
||||
{notes.map(n => {
|
||||
const displayTitle = pathNoteTitle(n.title, n.excerpt) || untitled
|
||||
const notebookName = n.notebook?.name || t('homeDashboard.pinnedNoNotebook')
|
||||
const notebookColor = n.notebook?.color || '#C4A574'
|
||||
return (
|
||||
<button
|
||||
key={n.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(n.id, n.notebookId)}
|
||||
className="w-full flex items-center gap-2.5 p-2 rounded-xl border border-border/20 hover:border-brand-accent/30 hover:bg-brand-accent/[0.03] transition-all text-start group"
|
||||
>
|
||||
<span
|
||||
className="w-1 h-7 rounded-full shrink-0"
|
||||
style={{ backgroundColor: notebookColor }}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[11px] text-ink dark:text-dark-ink truncate group-hover:text-brand-accent transition-colors">
|
||||
{displayTitle}
|
||||
</p>
|
||||
<p className="text-[9px] text-concrete truncate mt-0.5">
|
||||
{notebookName}
|
||||
{n.updatedAt && formatRelativeTime
|
||||
? ` · ${formatRelativeTime(n.updatedAt)}`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</DashboardWidgetShell>
|
||||
@@ -309,8 +389,7 @@ export function DashboardActivityWidget({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<RevisionHeatmap data={data} />
|
||||
<p className="text-[9px] text-concrete mt-2">{t('homeDashboard.widgetActivityHint')}</p>
|
||||
<RevisionHeatmap data={data} kind="edits" />
|
||||
</>
|
||||
)}
|
||||
</DashboardWidgetShell>
|
||||
@@ -397,15 +476,12 @@ export function DashboardFlashcardsProgressWidget({
|
||||
style={{ width: `${retention}%` }}
|
||||
/>
|
||||
</div>
|
||||
{dueCount > 0 ? (
|
||||
<p className="text-[10px] font-medium text-brand-accent group-hover:underline">
|
||||
{t('homeDashboard.flashDueCta', { count: dueCount })} →
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[9px] text-concrete group-hover:text-brand-accent transition-colors">
|
||||
{t('homeDashboard.flashOpenCta')} →
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[9px] text-concrete leading-relaxed mb-2">
|
||||
{t('homeDashboard.flashRetentionHint')}
|
||||
</p>
|
||||
<p className="text-[10px] font-medium text-brand-accent group-hover:underline">
|
||||
{dueCount > 0 ? t('homeDashboard.flashDueCta') : t('homeDashboard.flashOpenCta')} →
|
||||
</p>
|
||||
</button>
|
||||
)}
|
||||
</DashboardWidgetShell>
|
||||
|
||||
@@ -19,12 +19,22 @@ interface BridgeNote {
|
||||
}
|
||||
|
||||
const CLUSTER_COLORS = ['#F87171', '#60A5FA', '#34D399', '#FBBF24', '#A78BFA', '#F472B6', '#2DD4BF']
|
||||
const EASE = [0.16, 1, 0.3, 1] as const
|
||||
|
||||
function clusterPoint(index: number, count: number): { x: number; y: number } {
|
||||
if (count === 1) return { x: 50, y: 42 }
|
||||
const angle = -Math.PI / 2 + (index * 2 * Math.PI) / count
|
||||
const rx = count <= 3 ? 28 : 36
|
||||
const ry = count <= 3 ? 24 : 30
|
||||
return { x: 50 + Math.cos(angle) * rx, y: 44 + Math.sin(angle) * ry }
|
||||
}
|
||||
|
||||
export interface DashboardMindOrbitProps {
|
||||
clusters: Cluster[]
|
||||
bridgeNotes: BridgeNote[]
|
||||
loading?: boolean
|
||||
onOpenInsights: () => void
|
||||
onOpenCluster?: (clusterId: number) => void
|
||||
onNoteSelect: (id: string) => void
|
||||
prefersReducedMotion?: boolean
|
||||
}
|
||||
@@ -34,6 +44,7 @@ export function DashboardMindOrbit({
|
||||
bridgeNotes,
|
||||
loading,
|
||||
onOpenInsights,
|
||||
onOpenCluster,
|
||||
onNoteSelect,
|
||||
prefersReducedMotion,
|
||||
}: DashboardMindOrbitProps) {
|
||||
@@ -43,6 +54,8 @@ export function DashboardMindOrbit({
|
||||
.slice(0, 5)
|
||||
const maxCount = topClusters[0]?.noteIds.length || 1
|
||||
const topBridge = bridgeNotes[0]
|
||||
const reduced = !!prefersReducedMotion
|
||||
const points = topClusters.map((_, idx) => clusterPoint(idx, topClusters.length))
|
||||
|
||||
if (loading) {
|
||||
return <div className="h-[180px] rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
|
||||
@@ -80,21 +93,60 @@ export function DashboardMindOrbit({
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-center gap-3 min-h-[100px] py-2">
|
||||
<motion.div className="relative h-[200px] overflow-visible">
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
className="absolute inset-0 w-full h-full pointer-events-none"
|
||||
aria-hidden
|
||||
>
|
||||
{points.map((point, idx) => {
|
||||
const color = CLUSTER_COLORS[topClusters[idx].clusterId % CLUSTER_COLORS.length]
|
||||
return (
|
||||
<motion.path
|
||||
key={`spoke-${topClusters[idx].clusterId}`}
|
||||
d={`M 50 44 L ${point.x} ${point.y}`}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={0.7}
|
||||
strokeLinecap="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
initial={reduced ? false : { pathLength: 0, opacity: 0 }}
|
||||
animate={{ pathLength: 1, opacity: 0.4 }}
|
||||
transition={{ duration: reduced ? 0 : 0.45, delay: reduced ? 0 : 0.12 + idx * 0.05, ease: EASE }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
|
||||
<div
|
||||
className="absolute left-1/2 top-[44%] w-2.5 h-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-brand-accent shadow-[0_2px_8px_rgba(164,113,72,0.45)] pointer-events-none"
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
{topClusters.map((cluster, idx) => {
|
||||
const color = CLUSTER_COLORS[cluster.clusterId % CLUSTER_COLORS.length]
|
||||
const scale = 0.65 + (cluster.noteIds.length / maxCount) * 0.55
|
||||
const size = Math.round(56 * scale)
|
||||
const label = cluster.name || `${t('homeDashboard.theme')} ${cluster.clusterId + 1}`
|
||||
const size = Math.round(52 * scale)
|
||||
const label = cluster.name?.trim() || t('homeDashboard.theme')
|
||||
const point = points[idx]
|
||||
return (
|
||||
<motion.button
|
||||
key={cluster.clusterId}
|
||||
type="button"
|
||||
whileHover={prefersReducedMotion ? undefined : { scale: 1.06 }}
|
||||
whileTap={prefersReducedMotion ? undefined : { scale: 0.97 }}
|
||||
onClick={onOpenInsights}
|
||||
className="relative flex flex-col items-center gap-1.5 group"
|
||||
style={{ width: size + 16 }}
|
||||
whileHover={reduced ? undefined : { scale: 1.06 }}
|
||||
whileTap={reduced ? undefined : { scale: 0.97 }}
|
||||
onClick={() => (onOpenCluster ? onOpenCluster(cluster.clusterId) : onOpenInsights())}
|
||||
className="absolute flex flex-col items-center gap-1 group z-[1] hover:z-10"
|
||||
aria-label={label}
|
||||
style={{
|
||||
left: `${point.x}%`,
|
||||
top: `${point.y}%`,
|
||||
width: Math.max(size + 24, 128),
|
||||
}}
|
||||
initial={reduced ? { x: '-50%', y: '-50%' } : { x: '-50%', y: '-50%', scale: 0.82, opacity: 0.35 }}
|
||||
animate={{ x: '-50%', y: '-50%', scale: 1, opacity: 1 }}
|
||||
transition={{ duration: reduced ? 0 : 0.38, delay: reduced ? 0 : 0.18 + idx * 0.05, ease: EASE }}
|
||||
>
|
||||
<div
|
||||
className="rounded-full border-2 flex items-center justify-center font-mono font-bold text-white shadow-sm group-hover:shadow-md transition-shadow"
|
||||
@@ -108,19 +160,25 @@ export function DashboardMindOrbit({
|
||||
>
|
||||
{cluster.noteIds.length}
|
||||
</div>
|
||||
<span className="text-[8px] font-medium text-ink/80 dark:text-dark-ink/80 text-center line-clamp-2 leading-tight max-w-[72px] group-hover:text-brand-accent transition-colors">
|
||||
<span className="text-[9px] font-medium text-ink/80 dark:text-dark-ink/80 text-center line-clamp-2 leading-snug max-w-[128px] group-hover:text-brand-accent transition-colors">
|
||||
{label}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute top-full mt-1 max-w-[200px] px-2 py-1 rounded-md bg-ink text-white text-[10px] leading-snug text-center opacity-0 group-hover:opacity-100 transition-opacity shadow-lg z-20">
|
||||
{label}
|
||||
</span>
|
||||
</motion.button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{topBridge?.note && (
|
||||
<button
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={() => onNoteSelect(topBridge.noteId)}
|
||||
className="w-full mt-2 p-2.5 rounded-xl border border-brand-accent/20 bg-brand-accent/[0.04] hover:bg-brand-accent/[0.08] transition-all text-start flex items-center gap-2 group"
|
||||
initial={reduced ? false : { opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: reduced ? 0 : 0.32, delay: reduced ? 0 : 0.48, ease: EASE }}
|
||||
>
|
||||
<Zap size={11} className="text-brand-accent shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -132,7 +190,7 @@ export function DashboardMindOrbit({
|
||||
<span className="text-[8px] font-mono font-bold text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded-full shrink-0">
|
||||
{Math.round(topBridge.bridgeScore * 100)}%
|
||||
</span>
|
||||
</button>
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -12,16 +12,16 @@ import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row
|
||||
import type { DashboardPath, DashboardPathType } from '@/lib/dashboard/path-types'
|
||||
|
||||
const TYPE_META: Record<DashboardPathType, { Icon: LucideIcon; accent: string }> = {
|
||||
continue: { Icon: PenLine, accent: 'text-ink' },
|
||||
continue: { Icon: PenLine, accent: 'text-brand-accent' },
|
||||
connect: { Icon: Link2, accent: 'text-brand-accent' },
|
||||
'add-link': { Icon: Plus, accent: 'text-emerald-600' },
|
||||
bridge: { Icon: GitBranch, accent: 'text-violet-600' },
|
||||
research: { Icon: Sparkles, accent: 'text-sky-600' },
|
||||
explore: { Icon: Compass, accent: 'text-amber-600' },
|
||||
'add-link': { Icon: Plus, accent: 'text-brand-accent' },
|
||||
bridge: { Icon: GitBranch, accent: 'text-brand-accent' },
|
||||
research: { Icon: Sparkles, accent: 'text-brand-accent' },
|
||||
explore: { Icon: Compass, accent: 'text-brand-accent' },
|
||||
organize: { Icon: Inbox, accent: 'text-brand-accent' },
|
||||
review: { Icon: GraduationCap, accent: 'text-brand-accent' },
|
||||
resurface: { Icon: Lightbulb, accent: 'text-brand-accent' },
|
||||
daily: { Icon: BookOpen, accent: 'text-concrete' },
|
||||
daily: { Icon: BookOpen, accent: 'text-brand-accent' },
|
||||
}
|
||||
|
||||
export interface DashboardNextPathsProps {
|
||||
@@ -41,7 +41,8 @@ export function DashboardNextPaths({
|
||||
onAction,
|
||||
prefersReducedMotion,
|
||||
}: DashboardNextPathsProps) {
|
||||
const { t } = useLanguage()
|
||||
const { t, language } = useLanguage()
|
||||
const rtl = language === 'ar' || language === 'fa'
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -84,6 +85,8 @@ export function DashboardNextPaths({
|
||||
const hero = paths[0]
|
||||
const rest = paths.slice(1, 5)
|
||||
const HeroIcon = TYPE_META[hero.type].Icon
|
||||
const reduced = !!prefersReducedMotion
|
||||
const ease = [0.16, 1, 0.3, 1] as const
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-brand-accent/20 bg-gradient-to-br from-white via-white to-brand-accent/[0.04] dark:from-zinc-900 dark:via-zinc-900 dark:to-brand-accent/[0.06] shadow-sm overflow-hidden">
|
||||
@@ -109,7 +112,11 @@ export function DashboardNextPaths({
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={() => onAction(hero)}
|
||||
whileHover={prefersReducedMotion ? undefined : { y: -1 }}
|
||||
whileHover={reduced ? undefined : { y: -1 }}
|
||||
key={hero.id}
|
||||
initial={reduced ? false : { clipPath: rtl ? 'inset(0 0 0 72%)' : 'inset(0 72% 0 0)', opacity: 0.55 }}
|
||||
animate={{ clipPath: 'inset(0 0% 0 0)', opacity: 1 }}
|
||||
transition={{ duration: reduced ? 0 : 0.48, ease }}
|
||||
className="w-full text-start px-5 py-4 hover:bg-brand-accent/[0.03] transition-colors border-b border-border/10"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -137,14 +144,17 @@ export function DashboardNextPaths({
|
||||
|
||||
{rest.length > 0 && (
|
||||
<div className="divide-y divide-border/10">
|
||||
{rest.map(path => {
|
||||
{rest.map((path, idx) => {
|
||||
const meta = TYPE_META[path.type]
|
||||
const Icon = meta.Icon
|
||||
return (
|
||||
<button
|
||||
<motion.button
|
||||
key={path.id}
|
||||
type="button"
|
||||
onClick={() => onAction(path)}
|
||||
initial={reduced ? false : { opacity: 0, x: rtl ? -10 : 10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: reduced ? 0 : 0.28, delay: reduced ? 0 : Math.min(0.08 + idx * 0.04, 0.2), ease }}
|
||||
className="w-full flex items-center gap-3 px-5 py-3 text-start hover:bg-stone-50/80 dark:hover:bg-zinc-950/40 transition-colors"
|
||||
>
|
||||
<Icon size={13} className={`shrink-0 ${meta.accent}`} />
|
||||
@@ -155,7 +165,7 @@ export function DashboardNextPaths({
|
||||
<span className="text-[8px] font-mono uppercase text-brand-accent shrink-0">
|
||||
{t(`homeDashboard.pathActions.${path.actionKey}`)}
|
||||
</span>
|
||||
</button>
|
||||
</motion.button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,7 @@ export function DashboardDailyReview({
|
||||
>
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
{[0, 1, 2].map(i => <div key={i} className="h-8 rounded-lg bg-stone-50 animate-pulse" />)}
|
||||
{[0, 1, 2].map(i => <div key={i} className="h-8 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />)}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
@@ -79,7 +79,7 @@ export function DashboardOpenLoops({
|
||||
compact
|
||||
>
|
||||
{loading ? (
|
||||
<div className="h-16 rounded-lg bg-stone-50 animate-pulse" />
|
||||
<div className="h-16 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
|
||||
) : loops.length === 0 ? (
|
||||
<p className="text-[10px] text-concrete italic py-2">{t('homeDashboard.openLoopsEmpty')}</p>
|
||||
) : (
|
||||
@@ -122,7 +122,7 @@ export function DashboardDailyNoteWidget({
|
||||
compact
|
||||
>
|
||||
{loading ? (
|
||||
<div className="h-10 rounded-lg bg-stone-50 animate-pulse" />
|
||||
<div className="h-10 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
@@ -153,12 +153,12 @@ export function DashboardLinkSuggestions({
|
||||
return (
|
||||
<DashboardWidgetShell
|
||||
widgetId="link-suggestions"
|
||||
icon={<Circle size={12} className="text-emerald-600" />}
|
||||
icon={<Circle size={12} className="text-brand-accent" />}
|
||||
title={t('homeDashboard.widgets.link-suggestions')}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
<div className="h-12 rounded-lg bg-stone-50 animate-pulse" />
|
||||
<div className="h-12 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
|
||||
</div>
|
||||
) : paths.length === 0 ? (
|
||||
<p className="text-[10px] text-concrete italic">{t('homeDashboard.linkSuggestionsEmpty')}</p>
|
||||
@@ -169,16 +169,16 @@ export function DashboardLinkSuggestions({
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => onAction(p.id)}
|
||||
className="w-full p-3 rounded-xl border border-border/20 hover:border-emerald-500/30 text-start transition-all"
|
||||
className="w-full p-3 rounded-xl border border-border/20 hover:border-brand-accent/30 text-start transition-all"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<p className="text-[11px] font-mono font-bold text-ink dark:text-dark-ink truncate">{p.title}</p>
|
||||
{p.score ? (
|
||||
<span className="text-[8px] font-mono text-emerald-600 shrink-0">{p.score}%</span>
|
||||
<span className="text-[8px] font-mono text-brand-accent shrink-0">{p.score}%</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-[10px] text-concrete line-clamp-2">{p.description}</p>
|
||||
<p className="text-[8px] font-mono uppercase text-emerald-600 mt-2">{t('homeDashboard.pathActions.addLink')} →</p>
|
||||
<p className="text-[8px] font-mono uppercase text-brand-accent mt-2">{t('homeDashboard.pathActions.addLink')} →</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -212,11 +212,11 @@ export function DashboardBridgesWidget({
|
||||
return (
|
||||
<DashboardWidgetShell
|
||||
widgetId="bridges"
|
||||
icon={<Circle size={12} className="text-violet-600" />}
|
||||
icon={<Circle size={12} className="text-brand-accent" />}
|
||||
title={t('homeDashboard.widgets.bridges')}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="h-20 rounded-lg bg-stone-50 animate-pulse" />
|
||||
<div className="h-20 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
|
||||
) : suggestions.length === 0 ? (
|
||||
<p className="text-[10px] text-concrete italic">{t('homeDashboard.bridgesEmpty')}</p>
|
||||
) : (
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
import { Clock, ChevronRight, Play, PenLine } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
|
||||
@@ -24,27 +23,33 @@ export interface DashboardResumeHeroProps {
|
||||
prefersReducedMotion?: boolean
|
||||
}
|
||||
|
||||
function resumeDisplayTitle(note: ResumeNote, fallback: string): string {
|
||||
const titled = note.title?.trim()
|
||||
if (titled) return titled
|
||||
const excerpt = note.excerpt?.trim()
|
||||
if (!excerpt) return fallback
|
||||
return excerpt.length > 80 ? `${excerpt.slice(0, 80).trim()}…` : excerpt
|
||||
}
|
||||
|
||||
export function DashboardResumeHero({
|
||||
notes,
|
||||
loading,
|
||||
onSelect,
|
||||
onCaptureFocus,
|
||||
formatRelativeTime,
|
||||
prefersReducedMotion,
|
||||
}: DashboardResumeHeroProps) {
|
||||
const { t } = useLanguage()
|
||||
const hero = notes[0]
|
||||
const rest = notes.slice(1, 6)
|
||||
const untitled = t('homeDashboard.untitled')
|
||||
const visible = notes.slice(0, 6)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border/30 bg-white dark:bg-zinc-900 p-5 min-h-[220px] animate-pulse">
|
||||
<div className="rounded-2xl border border-border/30 bg-white dark:bg-zinc-900 p-5 min-h-[180px] animate-pulse">
|
||||
<div className="h-4 w-32 bg-stone-100 dark:bg-zinc-800 rounded mb-4" />
|
||||
<div className="h-6 w-3/4 bg-stone-100 dark:bg-zinc-800 rounded mb-3" />
|
||||
<div className="h-16 bg-stone-50 dark:bg-zinc-950 rounded-xl mb-3" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-10 bg-stone-50 dark:bg-zinc-950 rounded-xl" />
|
||||
<div className="h-10 bg-stone-50 dark:bg-zinc-950 rounded-xl" />
|
||||
<div className="h-12 bg-stone-50 dark:bg-zinc-950 rounded-xl" />
|
||||
<div className="h-12 bg-stone-50 dark:bg-zinc-950 rounded-xl" />
|
||||
<div className="h-12 bg-stone-50 dark:bg-zinc-950 rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -61,7 +66,7 @@ export function DashboardResumeHero({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!hero ? (
|
||||
{visible.length === 0 ? (
|
||||
<div className="px-5 pb-5">
|
||||
<div className="rounded-xl border border-dashed border-border/40 bg-stone-50/60 dark:bg-zinc-950/40 p-5 text-center">
|
||||
<Clock size={22} className="mx-auto text-concrete/35 mb-2" strokeWidth={1.25} />
|
||||
@@ -81,73 +86,33 @@ export function DashboardResumeHero({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<motion.button
|
||||
type="button"
|
||||
whileHover={prefersReducedMotion ? undefined : { y: -1 }}
|
||||
onClick={() => onSelect(hero.id, hero.notebookId)}
|
||||
className="w-full text-start px-5 pb-3 group cursor-pointer"
|
||||
>
|
||||
<div className="p-4 rounded-xl border border-border/25 bg-gradient-to-br from-stone-50/80 to-white dark:from-zinc-950/50 dark:to-zinc-900/80 group-hover:border-brand-accent/35 group-hover:shadow-md transition-all">
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<span
|
||||
className="text-[8px] font-mono font-bold uppercase px-2 py-0.5 rounded text-white shrink-0"
|
||||
style={{ backgroundColor: hero.notebookColor }}
|
||||
>
|
||||
{hero.notebookName}
|
||||
</span>
|
||||
<span className="text-[9px] font-mono text-concrete/70 shrink-0">
|
||||
{formatRelativeTime(hero.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-base sm:text-lg font-serif font-semibold text-ink dark:text-dark-ink group-hover:text-brand-accent transition-colors leading-snug mb-2 line-clamp-2">
|
||||
{hero.title || t('homeDashboard.untitled')}
|
||||
</h3>
|
||||
{hero.excerpt ? (
|
||||
<p className="text-[11px] text-concrete leading-relaxed line-clamp-3">
|
||||
{hero.excerpt}
|
||||
<div className="px-5 pb-4 space-y-1.5">
|
||||
{visible.map(note => (
|
||||
<button
|
||||
key={note.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(note.id, note.notebookId)}
|
||||
className="w-full flex items-center gap-3 p-2.5 rounded-xl border border-border/20 bg-stone-50/40 dark:bg-zinc-950/30 hover:border-brand-accent/30 hover:bg-brand-accent/[0.03] transition-all text-start group"
|
||||
>
|
||||
<span
|
||||
className="w-1.5 h-8 rounded-full shrink-0"
|
||||
style={{ backgroundColor: note.notebookColor }}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[11px] font-semibold text-ink dark:text-dark-ink truncate group-hover:text-brand-accent transition-colors">
|
||||
{resumeDisplayTitle(note, untitled)}
|
||||
</p>
|
||||
<p className="text-[9px] text-concrete truncate mt-0.5">
|
||||
{note.notebookName}
|
||||
{' · '}
|
||||
{formatRelativeTime(note.updatedAt)}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex items-center gap-1 mt-3 text-[9px] font-mono font-bold uppercase text-brand-accent opacity-80 group-hover:opacity-100 transition-opacity">
|
||||
{t('homeDashboard.resumeOpen')}
|
||||
<ChevronRight size={12} />
|
||||
</div>
|
||||
</div>
|
||||
</motion.button>
|
||||
|
||||
{rest.length > 0 && (
|
||||
<div className="px-5 pb-4 space-y-1.5">
|
||||
<p className="text-[8px] font-mono font-bold uppercase tracking-wider text-concrete/70 mb-1">
|
||||
{t('homeDashboard.resumeAlso')}
|
||||
</p>
|
||||
{rest.map(note => (
|
||||
<button
|
||||
key={note.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(note.id, note.notebookId)}
|
||||
className="w-full flex items-center gap-3 p-2.5 rounded-xl border border-border/20 bg-stone-50/40 dark:bg-zinc-950/30 hover:border-brand-accent/30 hover:bg-brand-accent/[0.03] transition-all text-start group"
|
||||
>
|
||||
<span
|
||||
className="w-1.5 h-8 rounded-full shrink-0"
|
||||
style={{ backgroundColor: note.notebookColor }}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[11px] font-semibold text-ink dark:text-dark-ink truncate group-hover:text-brand-accent transition-colors">
|
||||
{note.title || t('homeDashboard.untitled')}
|
||||
</p>
|
||||
<p className="text-[9px] text-concrete truncate mt-0.5">
|
||||
{note.notebookName}
|
||||
{' · '}
|
||||
{formatRelativeTime(note.updatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight size={12} className="text-concrete/40 group-hover:text-brand-accent shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<ChevronRight size={12} className="text-concrete/40 group-hover:text-brand-accent shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -10,6 +10,12 @@ interface EmotionMeta {
|
||||
color: string
|
||||
}
|
||||
|
||||
export interface SentimentRelatedNote {
|
||||
id: string
|
||||
title: string | null
|
||||
notebookId: string | null
|
||||
}
|
||||
|
||||
export interface DashboardSentimentChipProps {
|
||||
available: boolean
|
||||
loading?: boolean
|
||||
@@ -17,6 +23,8 @@ export interface DashboardSentimentChipProps {
|
||||
summary?: string
|
||||
emotions?: Record<string, number>
|
||||
emotionMeta: Record<string, EmotionMeta>
|
||||
relatedNotes?: SentimentRelatedNote[]
|
||||
onSelectNote?: (id: string, notebookId: string | null) => void
|
||||
}
|
||||
|
||||
export function DashboardSentimentChip({
|
||||
@@ -26,6 +34,8 @@ export function DashboardSentimentChip({
|
||||
summary,
|
||||
emotions,
|
||||
emotionMeta,
|
||||
relatedNotes = [],
|
||||
onSelectNote,
|
||||
}: DashboardSentimentChipProps) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
@@ -80,6 +90,24 @@ export function DashboardSentimentChip({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{relatedNotes.length > 0 && onSelectNote && (
|
||||
<div className="space-y-1 pt-1">
|
||||
<p className="text-[8px] font-mono font-bold uppercase tracking-wider text-concrete/70">
|
||||
{t('homeDashboard.sentimentFromNotes')}
|
||||
</p>
|
||||
{relatedNotes.map(note => (
|
||||
<button
|
||||
key={note.id}
|
||||
type="button"
|
||||
onClick={() => onSelectNote(note.id, note.notebookId)}
|
||||
className="w-full text-start text-[11px] text-ink dark:text-dark-ink truncate px-2 py-1.5 rounded-lg hover:bg-brand-accent/[0.04] hover:text-brand-accent transition-colors"
|
||||
>
|
||||
{note.title?.trim() || t('homeDashboard.untitled')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{emotions && (
|
||||
<div className="space-y-2 pt-1">
|
||||
{Object.entries(emotions)
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
import { useState, useEffect, useCallback, useMemo, type ReactNode } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useReducedMotion } from 'motion/react'
|
||||
import { Inbox, Send, Bell, Mail } from 'lucide-react'
|
||||
import { Send, Bell, Loader2, PenLine } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
||||
import { redirectToAiConsentSettings } from '@/lib/consent/ai-consent-redirect'
|
||||
import { createNote } from '@/app/actions/notes'
|
||||
import { emitNoteChange } from '@/lib/note-change-sync'
|
||||
import { toast } from 'sonner'
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
} from '@/components/dashboard-path-widgets'
|
||||
import type { DashboardPath } from '@/lib/dashboard/path-types'
|
||||
import { buildFastPathsFromBriefing } from '@/lib/dashboard/paths-fast'
|
||||
import { pathNoteTitle, pickFocusNote } from '@/lib/dashboard/path-title'
|
||||
import { emitAiUsageChanged } from '@/lib/ai-usage-sync'
|
||||
import {
|
||||
DashboardInboxWidget,
|
||||
@@ -49,8 +51,10 @@ import type { LucideIcon } from 'lucide-react'
|
||||
interface BriefingPinnedNote {
|
||||
id: string
|
||||
title: string | null
|
||||
excerpt?: string
|
||||
notebookId: string | null
|
||||
updatedAt: string
|
||||
notebook?: { id: string; name: string; color: string | null; icon: string | null } | null
|
||||
}
|
||||
|
||||
interface ActivityDay {
|
||||
@@ -136,6 +140,7 @@ interface SentimentData {
|
||||
emotions?: Record<string, number>
|
||||
summary?: string
|
||||
topTopic?: string
|
||||
relatedNotes?: Array<{ id: string; title: string | null; notebookId: string | null }>
|
||||
}
|
||||
|
||||
interface MindMapData {
|
||||
@@ -151,7 +156,7 @@ interface MindMapData {
|
||||
}
|
||||
|
||||
interface DashboardViewProps {
|
||||
onNoteSelect: (noteId: string, notebookId: string | null) => void
|
||||
onNoteSelect: (noteId: string, notebookId: string | null, peekNoteId?: string | null) => void
|
||||
}
|
||||
|
||||
interface GmailStatus {
|
||||
@@ -216,6 +221,13 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
const [data, setData] = useState<{
|
||||
recentNotes: BriefingNote[]
|
||||
inboxCount: number
|
||||
inboxPreview?: Array<{
|
||||
id: string
|
||||
title: string | null
|
||||
excerpt?: string
|
||||
notebookId: string | null
|
||||
updatedAt?: string
|
||||
}>
|
||||
dueFlashcards: number
|
||||
upcomingReminders: BriefingReminder[]
|
||||
insights: BriefingInsight[]
|
||||
@@ -243,23 +255,30 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
const [capturing, setCapturing] = useState(false)
|
||||
const [inboxPulse, setInboxPulse] = useState(0)
|
||||
const [actingSuggestionId, setActingSuggestionId] = useState<string | null>(null)
|
||||
const [createdAgent, setCreatedAgent] = useState<{ id: string | null; topic: string } | null>(null)
|
||||
const [echoRefreshing, setEchoRefreshing] = useState(false)
|
||||
const [dismissingInsightId, setDismissingInsightId] = useState<string | null>(null)
|
||||
const [actingBridgeSuggestionKey, setActingBridgeSuggestionKey] = useState<string | null>(null)
|
||||
const [briefingError, setBriefingError] = useState(false)
|
||||
|
||||
const loadBriefing = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/briefing', { cache: 'no-store' })
|
||||
if (res.ok) setData(await res.json())
|
||||
if (res.ok) {
|
||||
setData(await res.json())
|
||||
setBriefingError(false)
|
||||
} else {
|
||||
setBriefingError(true)
|
||||
}
|
||||
} catch {
|
||||
/* état dégradé */
|
||||
setBriefingError(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadPaths = useCallback(async (briefing: NonNullable<typeof data>) => {
|
||||
setPathsEnriching(true)
|
||||
try {
|
||||
const focus = briefing.recentNotes[0]
|
||||
const focus = pickFocusNote(briefing.recentNotes)
|
||||
const res = await fetch('/api/briefing/paths', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -292,6 +311,36 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** Recharge briefing puis enrichit les pistes (évite loadPaths() sans argument). */
|
||||
const reloadBriefingAndPaths = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/briefing', { cache: 'no-store' })
|
||||
if (!res.ok) {
|
||||
setBriefingError(true)
|
||||
return
|
||||
}
|
||||
const briefing = await res.json()
|
||||
setData(briefing)
|
||||
setBriefingError(false)
|
||||
setPaths(buildFastPathsFromBriefing({
|
||||
recentNotes: (briefing.recentNotes ?? []).map((n: BriefingNote) => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
content: n.content,
|
||||
notebookId: n.notebookId,
|
||||
})),
|
||||
inboxCount: briefing.inboxCount ?? 0,
|
||||
dueFlashcards: briefing.dueFlashcards ?? 0,
|
||||
insights: briefing.insights ?? [],
|
||||
bridgeSuggestions: briefing.bridgeSuggestions ?? [],
|
||||
agentSuggestions: briefing.agentSuggestions ?? [],
|
||||
}))
|
||||
await loadPaths(briefing)
|
||||
} catch {
|
||||
setBriefingError(true)
|
||||
}
|
||||
}, [loadPaths])
|
||||
|
||||
const loadSentiment = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/briefing/sentiment', { cache: 'no-store' })
|
||||
@@ -380,12 +429,22 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
const gmail = data?.gmail
|
||||
const pinnedNotes = data?.pinnedNotes ?? []
|
||||
const writingActivity = data?.writingActivity ?? []
|
||||
const pathsList = paths
|
||||
const recentNotes = data?.recentNotes ?? []
|
||||
const pathsList = useMemo(
|
||||
() => paths.filter(p => p.type !== 'organize' && p.type !== 'review'),
|
||||
[paths],
|
||||
)
|
||||
const pathBridgeKeys = useMemo(
|
||||
() => pathsList
|
||||
.filter(p => p.type === 'bridge' && p.clusterAId != null && p.clusterBId != null)
|
||||
.map(p => `${p.clusterAId}-${p.clusterBId}`),
|
||||
[pathsList],
|
||||
)
|
||||
const focusNote = useMemo(() => pickFocusNote(recentNotes), [recentNotes])
|
||||
const openLoopsList = openLoops
|
||||
const briefingLoading = data === null
|
||||
const pathsLoading = data === null
|
||||
const pathsDetailLoading = pathsEnriching
|
||||
const recentNotes = data?.recentNotes ?? []
|
||||
const topBridgeNotes = useMemo(() => (mindMap?.bridgeNotes ?? []).slice(0, 3), [mindMap?.bridgeNotes])
|
||||
const dateLocale = localeForLanguage(language)
|
||||
|
||||
@@ -396,25 +455,19 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
|
||||
const themeCount = mindMap?.clusters.length ?? 0
|
||||
|
||||
const resumeNotes = useMemo(() => recentNotes.map(n => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
excerpt: stripHtml(n.content).slice(0, 180),
|
||||
notebookName: n.notebook?.name || t('homeDashboard.inbox'),
|
||||
notebookColor: n.notebook?.color || '#8B5CF6',
|
||||
updatedAt: n.updatedAt,
|
||||
notebookId: n.notebookId,
|
||||
})), [recentNotes, t])
|
||||
|
||||
const briefingSubtitle = useMemo(() => {
|
||||
if (briefingLoading) return ''
|
||||
const parts: string[] = []
|
||||
if (inboxCount > 0) parts.push(t('homeDashboard.pulseInbox', { count: inboxCount }))
|
||||
if (dueFlashcards > 0) parts.push(t('homeDashboard.pulseReview', { count: dueFlashcards }))
|
||||
if (discoveryCount > 0) parts.push(t('homeDashboard.pulseDiscoveries', { count: discoveryCount }))
|
||||
if (parts.length === 0) return t('homeDashboard.pulseClear')
|
||||
return parts.join(' · ')
|
||||
}, [briefingLoading, inboxCount, dueFlashcards, discoveryCount, t])
|
||||
const resumeNotes = useMemo(() => recentNotes.flatMap(n => {
|
||||
const displayTitle = pathNoteTitle(n.title, n.content)
|
||||
if (!displayTitle) return []
|
||||
return [{
|
||||
id: n.id,
|
||||
title: displayTitle,
|
||||
excerpt: stripHtml(n.content).slice(0, 180),
|
||||
notebookName: n.notebook?.name || t('homeDashboard.inbox'),
|
||||
notebookColor: n.notebook?.color || '#8B5CF6',
|
||||
updatedAt: n.updatedAt,
|
||||
notebookId: n.notebookId,
|
||||
}]
|
||||
}), [recentNotes, t])
|
||||
|
||||
const handleCapture = useCallback(async () => {
|
||||
const text = captureText.trim()
|
||||
@@ -454,9 +507,14 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
} : prev)
|
||||
}, [])
|
||||
|
||||
const handleOpenFromInsight = useCallback(async (insight: BriefingInsight, noteId: string) => {
|
||||
const handleOpenFromInsight = useCallback(async (
|
||||
insight: BriefingInsight,
|
||||
noteId: string,
|
||||
peekNoteId?: string | null,
|
||||
) => {
|
||||
await markInsightViewed(insight.id)
|
||||
onNoteSelect(noteId, null)
|
||||
const peek = peekNoteId && peekNoteId !== noteId ? peekNoteId : undefined
|
||||
onNoteSelect(noteId, null, peek)
|
||||
}, [markInsightViewed, onNoteSelect])
|
||||
|
||||
const handleDismissInsight = useCallback(async (insight: BriefingInsight) => {
|
||||
@@ -483,10 +541,13 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
try {
|
||||
const res = await fetch('/api/ai/echo')
|
||||
const json = await res.json()
|
||||
if (res.status === 403 && json.error === 'ai_consent_required') { await requestAiConsent(); return }
|
||||
if (res.status === 403 && json.code === 'ai_consent_required') {
|
||||
redirectToAiConsentSettings(router)
|
||||
return
|
||||
}
|
||||
if (!res.ok) throw new Error(json.error)
|
||||
if (json.insight) {
|
||||
await Promise.all([loadBriefing(), loadPaths()])
|
||||
await reloadBriefingAndPaths()
|
||||
emitAiUsageChanged()
|
||||
toast.success(t('homeDashboard.echoFound'))
|
||||
} else {
|
||||
@@ -497,12 +558,12 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
} finally {
|
||||
setEchoRefreshing(false)
|
||||
}
|
||||
}, [requestAiConsent, loadBriefing, loadPaths, t])
|
||||
}, [requestAiConsent, reloadBriefingAndPaths, t, router])
|
||||
|
||||
const handleEnableAi = useCallback(async () => {
|
||||
await requestAiConsent()
|
||||
await Promise.all([loadBriefing(), loadPaths()])
|
||||
}, [requestAiConsent, loadBriefing, loadPaths])
|
||||
await reloadBriefingAndPaths()
|
||||
}, [requestAiConsent, reloadBriefingAndPaths])
|
||||
|
||||
const handleDismissBridgeSuggestion = useCallback(async (s: BridgeSuggestionItem) => {
|
||||
const key = `${s.clusterAId}-${s.clusterBId}`
|
||||
@@ -563,19 +624,20 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
|
||||
const handleAcceptSuggestion = useCallback(async (id: string) => {
|
||||
setActingSuggestionId(id)
|
||||
const topic = data?.agentSuggestions?.find(s => s.id === id)?.topic ?? ''
|
||||
try {
|
||||
const res = await fetch(`/api/agents/suggestions/${id}/accept`, { method: 'POST' })
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json.error)
|
||||
setData(prev => prev ? { ...prev, agentSuggestions: prev.agentSuggestions?.filter(s => s.id !== id) ?? [] } : prev)
|
||||
setCreatedAgent({ id: json.agentId ?? null, topic })
|
||||
toast.success(t('homeDashboard.agentCreated'))
|
||||
if (json.agentId) router.push(`/agents?id=${json.agentId}`)
|
||||
} catch {
|
||||
toast.error(t('homeDashboard.agentFailed'))
|
||||
} finally {
|
||||
setActingSuggestionId(null)
|
||||
}
|
||||
}, [t, router])
|
||||
}, [t, data?.agentSuggestions])
|
||||
|
||||
const handleDismissSuggestion = useCallback(async (id: string) => {
|
||||
setActingSuggestionId(id)
|
||||
@@ -596,18 +658,14 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
if (path.noteId) onNoteSelect(path.noteId, path.notebookId ?? null)
|
||||
break
|
||||
case 'compare':
|
||||
if (path.noteId) onNoteSelect(path.noteId, path.notebookId ?? null)
|
||||
if (path.note2Id) toast.info(t('homeDashboard.pathCompareHint', { title: path.title }))
|
||||
if (path.noteId) onNoteSelect(path.noteId, path.notebookId ?? null, path.note2Id)
|
||||
break
|
||||
case 'addLink':
|
||||
if (path.noteId) {
|
||||
onNoteSelect(path.noteId, path.notebookId ?? null)
|
||||
toast.info(t('homeDashboard.pathAddLinkHint', { link: path.title }))
|
||||
}
|
||||
if (path.noteId) onNoteSelect(path.noteId, path.notebookId ?? null, path.note2Id)
|
||||
break
|
||||
case 'openInsight': {
|
||||
const insight = insights.find(i => i.id === path.insightId)
|
||||
if (insight && path.noteId) handleOpenFromInsight(insight, path.noteId)
|
||||
if (insight && path.noteId) handleOpenFromInsight(insight, path.noteId, path.note2Id)
|
||||
break
|
||||
}
|
||||
case 'createBridge': {
|
||||
@@ -672,7 +730,8 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
{
|
||||
key: 'connection',
|
||||
label: t('homeDashboard.dailyReviewConnection'),
|
||||
done: pathsList.some(p => p.type === 'connect' || p.type === 'resurface'),
|
||||
// Coché quand il n'y a plus de piste de connexion à traiter
|
||||
done: !pathsList.some(p => p.type === 'connect' || p.type === 'resurface'),
|
||||
onClick: () => {
|
||||
const p = pathsList.find(x => x.type === 'connect' || x.type === 'resurface')
|
||||
if (p) handlePathAction(p)
|
||||
@@ -709,12 +768,17 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
case 'capture':
|
||||
return wrap(
|
||||
<div className="relative rounded-xl border border-border/30 bg-white/80 dark:bg-zinc-900/80 backdrop-blur-sm shadow-sm h-full">
|
||||
<div className="flex items-center justify-between gap-2 px-3.5 pt-2.5">
|
||||
<div className="flex items-center justify-between gap-2 px-3 pt-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Inbox size={11} className="text-brand-accent shrink-0" />
|
||||
<span className="text-[8px] font-mono font-bold uppercase tracking-widest text-concrete">
|
||||
{t('homeDashboard.quickCapture')}
|
||||
</span>
|
||||
<PenLine size={11} className="text-brand-accent shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<span className="text-[8px] font-mono font-bold uppercase tracking-widest text-concrete block">
|
||||
{t('homeDashboard.quickCapture')}
|
||||
</span>
|
||||
<span className="text-[9px] text-concrete leading-tight">
|
||||
{t('homeDashboard.captureGoesToFile')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<DashboardWidgetHelp widgetId="capture" />
|
||||
</div>
|
||||
@@ -724,15 +788,19 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
onKeyDown={handleCaptureKeyDown}
|
||||
placeholder={t('homeDashboard.quickCapturePlaceholder')}
|
||||
rows={2}
|
||||
className="w-full text-sm px-3.5 pb-3 pt-1.5 pe-12 bg-transparent outline-none text-ink dark:text-dark-ink resize-none leading-relaxed placeholder:text-concrete/45"
|
||||
className="w-full text-sm px-3 pb-2 pt-1 pe-12 bg-transparent outline-none text-ink dark:text-dark-ink resize-none leading-snug h-[2.75rem] placeholder:text-concrete/45"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCapture}
|
||||
disabled={!captureText.trim() || capturing}
|
||||
className="absolute bottom-2.5 end-2.5 p-2 bg-ink text-white dark:bg-white dark:text-black rounded-lg disabled:opacity-25 hover:scale-105 active:scale-95 transition-all shadow-sm"
|
||||
className="absolute bottom-1.5 end-2 p-1.5 bg-brand-accent text-white rounded-lg disabled:opacity-25 hover:bg-brand-accent/90 hover:scale-105 active:scale-95 transition-all shadow-sm"
|
||||
aria-busy={capturing}
|
||||
aria-label={t('homeDashboard.captureSend')}
|
||||
>
|
||||
<Send size={12} />
|
||||
{capturing
|
||||
? <Loader2 size={12} className="animate-spin" />
|
||||
: <Send size={12} />}
|
||||
</button>
|
||||
</div>,
|
||||
)
|
||||
@@ -742,7 +810,7 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
paths={pathsList}
|
||||
loading={pathsLoading}
|
||||
enriching={pathsEnriching}
|
||||
focusNoteTitle={recentNotes[0]?.title}
|
||||
focusNoteTitle={focusNote ? pathNoteTitle(focusNote.title, focusNote.content) : null}
|
||||
onAction={handlePathAction}
|
||||
prefersReducedMotion={!!prefersReducedMotion}
|
||||
/>,
|
||||
@@ -841,6 +909,7 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
dismissingInsightId={dismissingInsightId}
|
||||
actingBridgeSuggestionKey={actingBridgeSuggestionKey}
|
||||
prefersReducedMotion={!!prefersReducedMotion}
|
||||
excludeBridgeSuggestionKeys={pathBridgeKeys}
|
||||
/>,
|
||||
)
|
||||
case 'reminders':
|
||||
@@ -853,11 +922,22 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
/>
|
||||
{briefingLoading ? (
|
||||
<div className="space-y-2">
|
||||
<div className="h-10 rounded-lg bg-stone-50 animate-pulse" />
|
||||
<div className="h-10 rounded-lg bg-stone-50 animate-pulse" />
|
||||
<div className="h-10 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
|
||||
<div className="h-10 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
|
||||
</div>
|
||||
) : reminders.length === 0 ? (
|
||||
<p className="text-[11px] text-concrete italic py-1">{t('homeDashboard.allCaughtUp')}</p>
|
||||
<div className="rounded-xl border border-dashed border-border/35 bg-stone-50/50 dark:bg-zinc-950/30 p-3">
|
||||
<p className="text-[11px] text-concrete leading-relaxed mb-2">
|
||||
{t('homeDashboard.remindersEmpty')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/home?reminders=1&forceList=1')}
|
||||
className="text-[9px] font-mono font-bold uppercase text-brand-accent hover:underline"
|
||||
>
|
||||
{t('homeDashboard.remindersOpenAll')} →
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{reminders.slice(0, 4).map(r => (
|
||||
@@ -875,33 +955,15 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/home?reminders=1&forceList=1')}
|
||||
className="w-full text-start px-1 pt-1 text-[9px] font-mono uppercase font-bold text-brand-accent hover:underline"
|
||||
>
|
||||
{t('homeDashboard.remindersOpenAll')} →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!briefingLoading && (
|
||||
gmail?.connected ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/settings/integrations')}
|
||||
className="w-full flex items-center justify-between gap-2 p-2.5 rounded-xl border border-border/20 hover:border-brand-accent/25 transition-all text-start mt-2"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Mail size={11} className="text-concrete" />
|
||||
<span className="text-[10px] text-ink dark:text-dark-ink truncate">{t('homeDashboard.gmailCaptures')}</span>
|
||||
</div>
|
||||
<span className="text-[8px] font-mono font-bold text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded">
|
||||
{t('homeDashboard.gmailRecent', { count: gmail.recentCaptures })}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/settings/integrations')}
|
||||
className="w-full text-[9px] font-mono uppercase tracking-wider text-concrete hover:text-brand-accent transition-colors text-start py-2 mt-2"
|
||||
>
|
||||
{t('homeDashboard.gmailConnect')} →
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>,
|
||||
)
|
||||
case 'mind-map':
|
||||
@@ -911,6 +973,7 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
bridgeNotes={mindMap?.bridgeNotes ?? []}
|
||||
loading={mindMapLoading}
|
||||
onOpenInsights={() => router.push('/insights')}
|
||||
onOpenCluster={(clusterId) => router.push(`/insights?cluster=${clusterId}`)}
|
||||
onNoteSelect={(nid) => onNoteSelect(nid, null)}
|
||||
prefersReducedMotion={!!prefersReducedMotion}
|
||||
/>,
|
||||
@@ -924,6 +987,9 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
formatFrequency={formatAgentFrequency}
|
||||
onAccept={handleAcceptSuggestion}
|
||||
onDismiss={handleDismissSuggestion}
|
||||
createdAgent={createdAgent}
|
||||
onOpenCreated={() => router.push(createdAgent?.id ? `/agents?id=${createdAgent.id}` : '/agents')}
|
||||
onClearCreated={() => setCreatedAgent(null)}
|
||||
prefersReducedMotion={!!prefersReducedMotion}
|
||||
/>,
|
||||
)
|
||||
@@ -936,14 +1002,19 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
summary={sentiment?.summary}
|
||||
emotions={sentiment?.emotions}
|
||||
emotionMeta={EMOTION_META}
|
||||
relatedNotes={sentiment?.relatedNotes}
|
||||
onSelectNote={onNoteSelect}
|
||||
/>,
|
||||
)
|
||||
case 'inbox':
|
||||
return wrap(
|
||||
<DashboardInboxWidget
|
||||
count={inboxCount}
|
||||
notes={data?.inboxPreview ?? []}
|
||||
loading={briefingLoading}
|
||||
onOpen={() => router.push('/home?forceList=1')}
|
||||
onSelect={onNoteSelect}
|
||||
formatRelativeTime={relTime}
|
||||
/>,
|
||||
)
|
||||
case 'revision':
|
||||
@@ -994,6 +1065,7 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
notes={pinnedNotes}
|
||||
loading={briefingLoading}
|
||||
onSelect={onNoteSelect}
|
||||
formatRelativeTime={relTime}
|
||||
/>,
|
||||
)
|
||||
case 'usage':
|
||||
@@ -1009,11 +1081,11 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
aiStatus, insights, topBridgeNotes, bridgeSuggestions, agentActions, handleRefreshEcho,
|
||||
handleEnableAi, echoRefreshing, handleDismissInsight, handleDismissBridgeSuggestion,
|
||||
handleCreateBridgeSuggestion, handleOpenFromInsight, dismissingInsightId,
|
||||
actingBridgeSuggestionKey, reminders, gmail, dateLocale, router, mindMap,
|
||||
agentSuggestions, actingSuggestionId, formatAgentFrequency, handleAcceptSuggestion,
|
||||
actingBridgeSuggestionKey, pathBridgeKeys, reminders, gmail, dateLocale, router, mindMap,
|
||||
agentSuggestions, actingSuggestionId, createdAgent, formatAgentFrequency, handleAcceptSuggestion,
|
||||
handleDismissSuggestion, sentiment, pinnedNotes, writingActivity, themeCount,
|
||||
pathsList, openLoopsList, handlePathAction, dailyReviewItems, linkSuggestionPaths,
|
||||
handleOpenDailyNote, flashcardStats,
|
||||
handleOpenDailyNote, flashcardStats, focusNote,
|
||||
])
|
||||
|
||||
return (
|
||||
@@ -1021,22 +1093,29 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808005_1px,transparent_1px),linear-gradient(to_bottom,#80808005_1px,transparent_1px)] bg-[size:28px_28px] pointer-events-none z-0" />
|
||||
|
||||
<div className="w-full px-4 sm:px-6 lg:px-8 xl:px-10 2xl:px-12 py-6 sm:py-8 relative z-10">
|
||||
{briefingError && !data && (
|
||||
<div className="mb-4 rounded-xl border border-rose-500/30 bg-rose-500/5 px-4 py-3 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<p className="text-sm text-ink dark:text-dark-ink">
|
||||
{t('homeDashboard.briefingLoadError')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void reloadBriefingAndPaths() }}
|
||||
className="shrink-0 text-[10px] font-mono font-bold uppercase tracking-wider px-3 py-2 rounded-lg bg-brand-accent text-white hover:bg-brand-accent/90"
|
||||
>
|
||||
{t('homeDashboard.briefingRetry')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* ── En-tête : orientation en 2 secondes ── */}
|
||||
<header className="mb-5">
|
||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-2 pb-4 border-b border-border/20">
|
||||
<div>
|
||||
<h1 className="font-serif text-2xl sm:text-3xl font-medium text-ink dark:text-dark-ink tracking-tight">
|
||||
{t('homeDashboard.title')}
|
||||
</h1>
|
||||
<p className="text-[10px] font-mono uppercase tracking-[0.2em] text-concrete font-bold mt-1">
|
||||
{new Date().toLocaleDateString(dateLocale, { weekday: 'long', day: 'numeric', month: 'long' })}
|
||||
</p>
|
||||
</div>
|
||||
{!briefingLoading && briefingSubtitle && (
|
||||
<p className="text-[11px] text-concrete leading-relaxed max-w-md sm:text-end">
|
||||
{briefingSubtitle}
|
||||
</p>
|
||||
)}
|
||||
<div className="pb-4 border-b border-border/20">
|
||||
<h1 className="font-serif text-2xl sm:text-3xl font-medium text-ink dark:text-dark-ink tracking-tight">
|
||||
{t('homeDashboard.title')}
|
||||
</h1>
|
||||
<p className="text-[10px] font-mono uppercase tracking-[0.2em] text-concrete font-bold mt-1">
|
||||
{new Date().toLocaleDateString(dateLocale, { weekday: 'long', day: 'numeric', month: 'long' })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Pulse : file d'attente cognitive en un coup d'œil */}
|
||||
@@ -1058,8 +1137,15 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="pb-24">
|
||||
<DashboardWidgetGrid renderWidget={renderWidget} />
|
||||
<div className="pb-8">
|
||||
<DashboardWidgetGrid
|
||||
renderWidget={renderWidget}
|
||||
isWidgetEmpty={(id) => {
|
||||
if (briefingLoading) return false
|
||||
if (id === 'revision') return dueFlashcards === 0
|
||||
return false
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -40,6 +40,7 @@ import { toast } from 'sonner'
|
||||
|
||||
interface DashboardWidgetGridProps {
|
||||
renderWidget: (id: DashboardWidgetId) => React.ReactNode
|
||||
isWidgetEmpty?: (id: DashboardWidgetId) => boolean
|
||||
}
|
||||
|
||||
function SortableWidget({
|
||||
@@ -139,7 +140,7 @@ function ZoneColumn({
|
||||
)
|
||||
}
|
||||
|
||||
export function DashboardWidgetGrid({ renderWidget }: DashboardWidgetGridProps) {
|
||||
export function DashboardWidgetGrid({ renderWidget, isWidgetEmpty }: DashboardWidgetGridProps) {
|
||||
const { t } = useLanguage()
|
||||
const [layout, setLayout] = useState<DashboardLayout>(() => getDefaultDashboardLayout())
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
@@ -228,15 +229,30 @@ export function DashboardWidgetGrid({ renderWidget }: DashboardWidgetGridProps)
|
||||
}, [persistLayout, t])
|
||||
|
||||
const fullWidgets = visibleWidgetsInZone(layout, 'full')
|
||||
.filter(w => editMode || !isWidgetEmpty?.(w.id))
|
||||
const mainWidgets = visibleWidgetsInZone(layout, 'main')
|
||||
.filter(w => editMode || !isWidgetEmpty?.(w.id))
|
||||
const sideWidgets = visibleWidgetsInZone(layout, 'side')
|
||||
.filter(w => editMode || !isWidgetEmpty?.(w.id))
|
||||
const hidden = hiddenWidgetIds(layout)
|
||||
const catalog = catalogByCategory(layout)
|
||||
const hasVisibleWidgets = fullWidgets.length + mainWidgets.length + sideWidgets.length > 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div className={editMode ? 'space-y-4 pb-24' : 'space-y-4'}>
|
||||
{!editMode && loaded && hasVisibleWidgets && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditMode(true)}
|
||||
className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase font-bold px-3 py-1.5 rounded-xl text-brand-accent hover:bg-brand-accent/10 transition-colors"
|
||||
>
|
||||
<LayoutGrid size={12} />
|
||||
{t('homeDashboard.widgetCustomize')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{loaded ? (
|
||||
hasVisibleWidgets ? (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
@@ -307,9 +323,8 @@ export function DashboardWidgetGrid({ renderWidget }: DashboardWidgetGridProps)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editMode && (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40 flex items-center gap-2 px-3 py-2 rounded-2xl bg-ink/92 dark:bg-zinc-900/95 text-white shadow-xl border border-white/10 backdrop-blur-md">
|
||||
{editMode ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCatalogOpen(v => !v)}
|
||||
@@ -337,18 +352,8 @@ export function DashboardWidgetGrid({ renderWidget }: DashboardWidgetGridProps)
|
||||
<Check size={12} />
|
||||
{t('homeDashboard.widgetDone')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditMode(true)}
|
||||
className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase font-bold px-4 py-2 rounded-xl bg-white/10 hover:bg-white/15 transition-colors"
|
||||
>
|
||||
<LayoutGrid size={12} />
|
||||
{t('homeDashboard.widgetCustomize')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editMode && catalogOpen && (
|
||||
<div className="fixed bottom-20 left-1/2 -translate-x-1/2 z-40 w-[min(520px,calc(100vw-2rem))] max-h-[min(60vh,480px)] overflow-y-auto custom-scrollbar p-4 rounded-2xl bg-white dark:bg-zinc-900 border border-border/40 shadow-2xl">
|
||||
|
||||
@@ -10,6 +10,7 @@ interface DashboardWidgetTitleRowProps {
|
||||
title: string
|
||||
actions?: ReactNode
|
||||
className?: string
|
||||
wrapTitle?: boolean
|
||||
}
|
||||
|
||||
/** Titre widget : actions d’abord, aide « ? » en dernier — ne masque jamais la navigation. */
|
||||
@@ -19,12 +20,15 @@ export function DashboardWidgetTitleRow({
|
||||
title,
|
||||
actions,
|
||||
className = 'mb-3',
|
||||
wrapTitle,
|
||||
}: DashboardWidgetTitleRowProps) {
|
||||
return (
|
||||
<div className={`flex items-center justify-between gap-2 ${className}`}>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
{icon}
|
||||
<h2 className="text-[10px] font-mono font-bold uppercase tracking-widest text-ink dark:text-dark-ink truncate">
|
||||
<div className={`flex items-start justify-between gap-2 ${className}`}>
|
||||
<div className="flex items-start gap-2 min-w-0 flex-1">
|
||||
{icon ? <span className="shrink-0 mt-0.5">{icon}</span> : null}
|
||||
<h2 className={`text-[13px] font-semibold uppercase tracking-wider text-ink dark:text-dark-ink ${
|
||||
wrapTitle ? 'leading-tight whitespace-normal' : 'truncate'
|
||||
}`}>
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
@@ -64,11 +64,7 @@ export function FlashcardGenerateDialog({
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
if (data.errorKey) {
|
||||
toast.error(t(data.errorKey) || data.error)
|
||||
} else {
|
||||
toast.error(data.error || t('flashcards.generateFailed'))
|
||||
}
|
||||
toast.error(data.errorKey ? t(data.errorKey) : (data.error || t('flashcards.generateFailed')))
|
||||
return
|
||||
}
|
||||
setCards(data.cards || [])
|
||||
|
||||
@@ -12,6 +12,8 @@ interface HeatmapDay {
|
||||
interface RevisionHeatmapProps {
|
||||
data: HeatmapDay[]
|
||||
className?: string
|
||||
/** Révisions de flashcards, ou notes modifiées sur le tableau de bord. */
|
||||
kind?: 'reviews' | 'edits'
|
||||
}
|
||||
|
||||
function intensityClass(count: number, max: number): string {
|
||||
@@ -28,19 +30,25 @@ function resolveDateLocale(langCode: string): string {
|
||||
return langCode
|
||||
}
|
||||
|
||||
export function RevisionHeatmap({ data, className }: RevisionHeatmapProps) {
|
||||
export function RevisionHeatmap({ data, className, kind = 'reviews' }: RevisionHeatmapProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const [hovered, setHovered] = useState<{ label: string; count: number; date: string } | null>(null)
|
||||
const [selected, setSelected] = useState<{ label: string; count: number; date: string } | null>(null)
|
||||
|
||||
const dateLocale = resolveDateLocale(language ?? 'en')
|
||||
const prefix = kind === 'edits' ? 'homeDashboard.activityHeatmap' : 'flashcards.heatmap'
|
||||
|
||||
const dayLabel = (count: number) => {
|
||||
if (count <= 0) return t(`${prefix}DayNone`)
|
||||
if (count === 1) return t(`${prefix}DayOne`)
|
||||
return t(`${prefix}Day`, { count })
|
||||
}
|
||||
|
||||
const { cells, maxCount, totalReviews, monthLabels } = useMemo(() => {
|
||||
const map = new Map(data.map((d) => [d.date, d.count]))
|
||||
const now = new Date()
|
||||
// todayUTC est le début de la journée courante à minuit UTC
|
||||
const todayUTC = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()))
|
||||
|
||||
|
||||
const cells: { date: string; count: number; label: string }[] = []
|
||||
const monthLabels: { index: number; label: string }[] = []
|
||||
let lastMonth = -1
|
||||
@@ -78,22 +86,22 @@ export function RevisionHeatmap({ data, className }: RevisionHeatmapProps) {
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
{/* En-tête */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete">
|
||||
{t('flashcards.heatmapTitle')}
|
||||
<p className="text-[13px] font-semibold uppercase tracking-wider text-concrete">
|
||||
{t(`${prefix}Title`)}
|
||||
</p>
|
||||
<span className="text-[10px] text-concrete/60">
|
||||
{totalReviews > 0 ? `${totalReviews} révisions · 90 jours` : t('flashcards.heatmapLast90')}
|
||||
<span className="text-[13px] text-concrete/70">
|
||||
{totalReviews > 0
|
||||
? t(`${prefix}Total`, { count: totalReviews })
|
||||
: t(kind === 'edits' ? 'homeDashboard.activityHeatmapLast90' : 'flashcards.heatmapLast90')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Labels de mois au-dessus de la grille */}
|
||||
<div className="relative h-4">
|
||||
<div className="relative h-6">
|
||||
{monthLabels.map((m) => (
|
||||
<span
|
||||
key={m.label + m.index}
|
||||
className="absolute text-[9px] text-concrete/60 font-medium translate-y-0.5"
|
||||
className="absolute text-[13px] text-concrete/70 font-medium leading-tight"
|
||||
style={{ left: pct(m.index) }}
|
||||
>
|
||||
{m.label}
|
||||
@@ -101,14 +109,11 @@ export function RevisionHeatmap({ data, className }: RevisionHeatmapProps) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Grille pleine largeur */}
|
||||
<div className="grid grid-cols-[repeat(15,minmax(0,1fr))] gap-1 sm:grid-cols-[repeat(18,minmax(0,1fr))]">
|
||||
{cells.map((cell) => {
|
||||
const isHovered = hovered?.date === cell.date
|
||||
const isSelected = selected?.date === cell.date
|
||||
const reviewText = cell.count > 0
|
||||
? `${cell.count} révision${cell.count > 1 ? 's' : ''}`
|
||||
: 'Aucune révision'
|
||||
const reviewText = dayLabel(cell.count)
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -134,47 +139,29 @@ export function RevisionHeatmap({ data, className }: RevisionHeatmapProps) {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Info au survol / clic */}
|
||||
<div className="h-6 flex items-center justify-between text-[11px] border-b border-border/20 pb-1">
|
||||
<div className="min-h-7 flex items-center text-[13px] border-b border-border/20 pb-1">
|
||||
{activeInfo ? (
|
||||
<p className="flex items-center gap-1.5 animate-fadeIn">
|
||||
<span className="font-semibold text-foreground">
|
||||
{activeInfo.count > 0
|
||||
? `${activeInfo.count} révision${activeInfo.count > 1 ? 's' : ''}`
|
||||
: 'Aucune révision'}
|
||||
{dayLabel(activeInfo.count)}
|
||||
</span>
|
||||
<span className="text-concrete">· {activeInfo.label}</span>
|
||||
{selected?.date === activeInfo.date && !hovered && (
|
||||
<span className="text-[9px] bg-brand-accent/10 text-brand-accent px-1.5 py-0.2 rounded-full font-medium">
|
||||
sélectionné
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[10px] text-concrete/40 italic">
|
||||
Survolez ou cliquez sur un carré pour voir le détail
|
||||
<p className="text-[13px] text-concrete/50 italic">
|
||||
{t(`${prefix}Hint`)}
|
||||
</p>
|
||||
)}
|
||||
{selected && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelected(null)}
|
||||
className="text-[10px] text-brand-accent hover:text-brand-accent/80 hover:underline transition-colors"
|
||||
>
|
||||
Effacer la sélection
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Légende */}
|
||||
<div className="flex items-center gap-2 pt-0.5">
|
||||
<span className="text-[9px] text-concrete/50">Moins</span>
|
||||
<span className="text-[13px] text-concrete/60">{t('flashcards.heatmapLess')}</span>
|
||||
<div className="flex gap-0.5">
|
||||
{['bg-black/[0.06] dark:bg-white/[0.08]', 'bg-brand-accent/20', 'bg-brand-accent/40', 'bg-brand-accent/70', 'bg-brand-accent'].map((cls, i) => (
|
||||
<div key={i} className={cn('w-3 h-3 rounded-[3px]', cls)} />
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[9px] text-concrete/50">Plus</span>
|
||||
<span className="text-[13px] text-concrete/60">{t('flashcards.heatmapMore')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -353,7 +353,7 @@ export function RevisionView() {
|
||||
const res = await fetch(`/api/flashcards/decks/${deckId}`)
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
toast.error(data.error || t('flashcards.loadDeckFailed'))
|
||||
toast.error(data.errorKey ? t(data.errorKey) : (data.error || t('flashcards.loadDeckFailed')))
|
||||
return
|
||||
}
|
||||
setActiveDeckId(deckId)
|
||||
@@ -432,7 +432,7 @@ export function RevisionView() {
|
||||
const res = await fetch(`/api/flashcards/decks/${deckId}`)
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
toast.error(data.error || t('flashcards.loadDeckFailed'))
|
||||
toast.error(data.errorKey ? t(data.errorKey) : (data.error || t('flashcards.loadDeckFailed')))
|
||||
setExpandedDeckId(null)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ export function FusionModal({
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Failed to generate fusion')
|
||||
throw new Error(data.errorKey ? t(data.errorKey) : (data.error || 'Failed to generate fusion'))
|
||||
}
|
||||
|
||||
if (!data.fusedNote) {
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
|
||||
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, ChevronDown, Tag as TagIcon, X, Menu, LayoutGrid, List, Table, Columns3, CalendarDays, Wand2, Download, Upload, Globe } from 'lucide-react'
|
||||
import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, ChevronDown, Tag as TagIcon, X, Menu, LayoutGrid, List, Table, Columns3, CalendarDays, Wand2, Download, Upload, Globe, Presentation } from 'lucide-react'
|
||||
import { emitNoteChange, NOTE_CHANGE_EVENT, type NoteChangeEvent } from '@/lib/note-change-sync'
|
||||
import { useReminderCheck } from '@/hooks/use-reminder-check'
|
||||
import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion'
|
||||
@@ -35,6 +35,8 @@ import { NotebookOrganizerDialog } from '@/components/wizard/notebook-organizer-
|
||||
import { toast } from 'sonner'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { isDashboardHomeRoute } from '@/lib/dashboard/home-route'
|
||||
import { ConfirmDeleteNoteDialog } from '@/components/confirm-delete-note-dialog'
|
||||
import { showNoteTrashedToast } from '@/lib/notes/trash-toast'
|
||||
|
||||
|
||||
type SortOrder = 'newest' | 'oldest' | 'alpha' | 'manual'
|
||||
@@ -67,6 +69,10 @@ const NotebookSiteDialog = dynamic(
|
||||
() => import('@/components/wizard/notebook-site-dialog').then(m => ({ default: m.NotebookSiteDialog })),
|
||||
{ ssr: false }
|
||||
)
|
||||
const NotebookSlidesDialog = dynamic(
|
||||
() => import('@/components/wizard/notebook-slides-dialog').then(m => ({ default: m.NotebookSlidesDialog })),
|
||||
{ ssr: false }
|
||||
)
|
||||
const StructuredViewsIntro = dynamic(
|
||||
() => import('@/components/structured-views/structured-views-intro').then(m => ({ default: m.StructuredViewsIntro })),
|
||||
{ ssr: false }
|
||||
@@ -151,10 +157,12 @@ export function HomeClient({
|
||||
const [isEnablingStructured, setIsEnablingStructured] = useState(false)
|
||||
const [showStructuredWizard, setShowStructuredWizard] = useState(false)
|
||||
const [showNotebookSite, setShowNotebookSite] = useState(false)
|
||||
const [showNotebookSlides, setShowNotebookSlides] = useState(false)
|
||||
const [aiMenuOpen, setAiMenuOpen] = useState(false)
|
||||
const aiMenuRef = useRef<HTMLDivElement>(null)
|
||||
const [showStudyPlanner, setShowStudyPlanner] = useState(false)
|
||||
const [showOrganizer, setShowOrganizer] = useState(false)
|
||||
const [notePendingDelete, setNotePendingDelete] = useState<Note | null>(null)
|
||||
|
||||
const handleExportCSV = useCallback(() => {
|
||||
if (!searchParams.get('notebook')) return
|
||||
@@ -179,7 +187,7 @@ export function HomeClient({
|
||||
toast.success(`${data.created} notes importées !`)
|
||||
window.location.reload()
|
||||
} else {
|
||||
toast.error(data.error || 'Erreur')
|
||||
toast.error(data.errorKey ? t(data.errorKey) : (data.error || 'Erreur'))
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
@@ -313,6 +321,8 @@ export function HomeClient({
|
||||
}
|
||||
|
||||
setNotes((prevNotes) => {
|
||||
if (prevNotes.some((n) => n.id === note.id)) return prevNotes
|
||||
|
||||
const notebookFilter = searchParams.get('notebook')
|
||||
const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || []
|
||||
const colorFilter = searchParams.get('color')
|
||||
@@ -528,21 +538,25 @@ export function HomeClient({
|
||||
[patchNoteInList, t]
|
||||
)
|
||||
|
||||
const handleDeleteNoteFromList = useCallback(
|
||||
async (note: Note) => {
|
||||
removeNoteFromList(note.id)
|
||||
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
|
||||
try {
|
||||
await deleteNote(note.id, { skipRevalidation: true })
|
||||
toast.success(t('notes.deleted') || 'Note supprimée')
|
||||
} catch {
|
||||
setNotes((prev) => [note, ...prev])
|
||||
emitNoteChange({ type: 'created', note })
|
||||
toast.error(t('general.error'))
|
||||
}
|
||||
},
|
||||
[removeNoteFromList, t]
|
||||
)
|
||||
const handleDeleteNoteFromList = useCallback((note: Note) => {
|
||||
setNotePendingDelete(note)
|
||||
}, [])
|
||||
|
||||
const confirmDeleteNoteFromList = useCallback(async () => {
|
||||
const note = notePendingDelete
|
||||
if (!note) return
|
||||
setNotePendingDelete(null)
|
||||
removeNoteFromList(note.id)
|
||||
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
|
||||
try {
|
||||
await deleteNote(note.id, { skipRevalidation: true })
|
||||
showNoteTrashedToast(note, t, () => setNotes((prev) => [note, ...prev]))
|
||||
} catch {
|
||||
setNotes((prev) => [note, ...prev])
|
||||
emitNoteChange({ type: 'created', note })
|
||||
toast.error(t('general.error'))
|
||||
}
|
||||
}, [notePendingDelete, removeNoteFromList, t])
|
||||
|
||||
const handleArchiveNoteFromList = useCallback(
|
||||
async (note: Note) => {
|
||||
@@ -625,12 +639,16 @@ export function HomeClient({
|
||||
removeNoteFromList(detail.noteId)
|
||||
setPinnedNotes(prev => prev.filter(n => n.id !== detail.noteId))
|
||||
} else if (detail.type === 'created' && detail.note) {
|
||||
setNotes(prev => [detail.note, ...prev])
|
||||
setNotes((prev) => {
|
||||
if (prev.some((n) => n.id === detail.note.id)) return prev
|
||||
if (filterNotesForCurrentView([detail.note]).length === 0) return prev
|
||||
return [detail.note, ...prev]
|
||||
})
|
||||
}
|
||||
}
|
||||
window.addEventListener(NOTE_CHANGE_EVENT, onNoteChange)
|
||||
return () => window.removeEventListener(NOTE_CHANGE_EVENT, onNoteChange)
|
||||
}, [patchNoteInList])
|
||||
}, [patchNoteInList, filterNotesForCurrentView])
|
||||
|
||||
const handleGridReorder = useCallback(
|
||||
async (orderedIds: string[]) => {
|
||||
@@ -652,7 +670,10 @@ export function HomeClient({
|
||||
// Garder openNote dans l'URL tant que l'éditeur est ouvert → le sidebar peut surligner la note (comme activeNoteId dans la ref.)
|
||||
useEffect(() => {
|
||||
const openNoteId = searchParams.get('openNote')
|
||||
if (!openNoteId) return
|
||||
if (!openNoteId) {
|
||||
setEditingNote(null)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
const run = async () => {
|
||||
@@ -779,7 +800,12 @@ export function HomeClient({
|
||||
|
||||
// Apply sort order to notes
|
||||
const sortedNotes = useMemo(() => {
|
||||
let sorted = [...notes]
|
||||
const seen = new Set<string>()
|
||||
let sorted = notes.filter((n) => {
|
||||
if (seen.has(n.id)) return false
|
||||
seen.add(n.id)
|
||||
return true
|
||||
})
|
||||
if (sortOrder === 'newest') sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
if (sortOrder === 'oldest') sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
||||
if (sortOrder === 'alpha') sorted.sort((a, b) => (a.title || '').localeCompare(b.title || ''))
|
||||
@@ -811,8 +837,15 @@ export function HomeClient({
|
||||
|
||||
const handleEditorClose = useCallback(() => {
|
||||
setEditingNote(null)
|
||||
// Ouvert depuis le dashboard (Comparer / Lier / clic note) → revenir au dashboard, pas à la liste des carnets.
|
||||
if (searchParams.get('from') === 'dashboard' || searchParams.get('peekNote')) {
|
||||
router.replace('/home', { scroll: false })
|
||||
return
|
||||
}
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.delete('openNote')
|
||||
params.delete('peekNote')
|
||||
params.delete('from')
|
||||
const qs = params.toString()
|
||||
router.replace(qs ? `/home?${qs}` : '/home', { scroll: false })
|
||||
}, [router, searchParams])
|
||||
@@ -826,10 +859,17 @@ export function HomeClient({
|
||||
// Show dashboard when no active filter/view params
|
||||
const showDashboard = !editingNote && isDashboardHomeRoute('/home', searchParams)
|
||||
|
||||
const handleDashboardNoteSelect = useCallback((noteId: string, notebookId: string | null) => {
|
||||
const handleDashboardNoteSelect = useCallback((
|
||||
noteId: string,
|
||||
notebookId: string | null,
|
||||
peekNoteId?: string | null,
|
||||
) => {
|
||||
const params = new URLSearchParams()
|
||||
params.set('openNote', noteId)
|
||||
if (notebookId) params.set('notebook', notebookId)
|
||||
params.set('from', 'dashboard')
|
||||
// Avec aperçu split, ne pas ouvrir le panneau carnets : il mange la largeur des deux notes.
|
||||
if (notebookId && !peekNoteId) params.set('notebook', notebookId)
|
||||
if (peekNoteId && peekNoteId !== noteId) params.set('peekNote', peekNoteId)
|
||||
router.push(`/home?${params.toString()}`)
|
||||
}, [router])
|
||||
|
||||
@@ -1136,6 +1176,11 @@ export function HomeClient({
|
||||
label: t('notebookSite.shortTitle') || 'Site web',
|
||||
action: () => { setShowNotebookSite(true); setAiMenuOpen(false) },
|
||||
},
|
||||
{
|
||||
icon: <Presentation size={14} />,
|
||||
label: t('notebook.slides') || 'Présentation',
|
||||
action: () => { setShowNotebookSlides(true); setAiMenuOpen(false) },
|
||||
},
|
||||
].map((item, i) => (
|
||||
<button
|
||||
key={i}
|
||||
@@ -1452,6 +1497,22 @@ export function HomeClient({
|
||||
onClose={() => setShowNotebookSite(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmDeleteNoteDialog
|
||||
open={notePendingDelete != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setNotePendingDelete(null)
|
||||
}}
|
||||
onConfirm={confirmDeleteNoteFromList}
|
||||
/>
|
||||
|
||||
{showNotebookSlides && currentNotebook && (
|
||||
<NotebookSlidesDialog
|
||||
notebookId={currentNotebook.id}
|
||||
notebookName={currentNotebook.name}
|
||||
onClose={() => setShowNotebookSlides(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user